Compare commits

..

41 Commits

Author SHA1 Message Date
leo 765625c6f0 wip: replace created_at by started_at. may want to revert 2026-03-26 00:30:12 +01:00
leo e64b2901f9 Changed diarization (dict) to Transcription object 2026-03-25 17:49:23 +01:00
leo 7dc7da9109 plug user assign + replace recording_date, recording_time by recording_datetime 2026-03-25 17:14:52 +01:00
leo 1889fe6505 Add user assignment 2026-03-25 15:20:14 +01:00
leo bf54feac9a fix Dockerfile 2026-03-25 14:29:36 +01:00
leo f08ae06c5c fix lint-agents install 2026-03-25 14:23:04 +01:00
leo d05a4da470 fix tests for linting 2026-03-25 14:16:07 +01:00
leo b16062dcc7 fix linting 2026-03-25 11:55:57 +01:00
leo 665cb0edde fix conditions for transcription 2026-03-25 11:46:15 +01:00
leo 12851d940e fix frontend transcribe flag 2026-03-25 11:46:15 +01:00
leo f82f9dacd6 fix test 2026-03-25 11:46:15 +01:00
leo 96bd8cc06e enable metadata collection by default 2026-03-25 11:46:15 +01:00
leo 8261b8cff8 add tests 2026-03-25 11:46:15 +01:00
leo 18d77bc491 fix bug 2026-03-25 11:46:15 +01:00
leo 7c1624cfaa modify metadata recording conditions 2026-03-25 11:46:15 +01:00
leo 1b02bfcc63 changes to Dockerfile and compose 2026-03-25 11:46:15 +01:00
leo b350ff5650 fix bug 2026-03-25 11:46:15 +01:00
leo 399ea0f2d0 update Dockerfile and compose 2026-03-25 11:46:12 +01:00
leo d894215f48 fix async bug 2026-03-25 11:44:52 +01:00
leo 9914a87387 update docstrings 2026-03-25 11:44:52 +01:00
leo a671054186 add exception for missing conf 2026-03-25 11:44:52 +01:00
leo fef4745c77 add Recording type to variables 2026-03-25 11:44:52 +01:00
leo ca8220aa1a store dispatch id instead of search 2026-03-25 11:44:52 +01:00
leo eb3923cde3 consistently name metadata collector 2026-03-25 11:44:52 +01:00
leo c274c005ce consistently name metadata collector 2026-03-25 11:44:52 +01:00
leo 5a0344ff08 clean up 2026-03-25 11:44:52 +01:00
lebaudantoine 74cf4bf538 wip align env variable in metadata agent 2026-03-25 11:44:52 +01:00
lebaudantoine 78124dd589 wip create a temporary service to trigger the metadata agent 2026-03-25 11:44:52 +01:00
lebaudantoine a67daf1b58 wip persist metadata in the right folder with the right name 2026-03-25 11:44:52 +01:00
lebaudantoine 2aaa9b6999 wip lint the metadata agent 2026-03-25 11:44:52 +01:00
lebaudantoine 62dd62235f wip start when requested the metadata agent 2026-03-25 11:44:52 +01:00
lebaudantoine 37eabc6d8b wip create timestamp on utc timezone 2026-03-25 11:44:52 +01:00
lebaudantoine a4d741c3a8 wip rename to_dict to serialize 2026-03-25 11:44:52 +01:00
lebaudantoine cbf7842cc5 wip track chat message 2026-03-25 11:44:52 +01:00
lebaudantoine 9c5b60a19e wip listen to participant rename event 2026-03-25 11:44:52 +01:00
lebaudantoine 4d682f9715 wip rename participants_seen 2026-03-25 11:44:52 +01:00
lebaudantoine 5019ed5c9e wip temporary deploy the metadata agetn 2026-03-25 11:44:52 +01:00
lebaudantoine 7e41d54a3e wip introduce metadata agent 2026-03-25 11:44:40 +01:00
lebaudantoine d15e09997f wip align script naming 2026-03-25 11:43:59 +01:00
lebaudantoine 108db2e3e5 📌(backend) pin brevo-python to v2.x.x. in renovate
The SDK introduced breaking changes in newer versions. Lock the
dependency to v2.x.x to maintain compatibility.
2026-03-25 11:18:58 +01:00
renovate[bot] 73496406e8 ⬆️(dependencies) update python dependencies 2026-03-25 11:18:57 +01:00
30 changed files with 1737 additions and 181 deletions
+4
View File
@@ -8,6 +8,10 @@ and this project adheres to
## [Unreleased]
### Changed
- ⬆️(dependencies) update python dependencies
### Fixed
- 🔒️(backend) fix email disclosure in room invitation endpoint #1200
+5
View File
@@ -125,10 +125,15 @@ run-summary: ## start only the summary application and all needed services
@$(COMPOSE) up --force-recreate -d celery-summary-summarize
.PHONY: run-summary
run-agents: ## start the LiveKit agents (metadata collector)
@$(COMPOSE) up --force-recreate -d metadata-collector-dev
.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
+23
View File
@@ -246,6 +246,29 @@ services:
depends_on:
- redis
metadata-collector-dev:
build:
context: ./src/agents
command: ["python", "metadata_collector.py", "dev"]
environment:
- LIVEKIT_URL=ws://livekit:7880
- LIVEKIT_API_KEY=devkey
- LIVEKIT_API_SECRET=secret
- AWS_S3_ENDPOINT_URL=minio:9000
- AWS_S3_ACCESS_KEY_ID=meet
- AWS_S3_SECRET_ACCESS_KEY=password
- AWS_STORAGE_BUCKET_NAME=meet-media-storage
- AWS_S3_SECURE_ACCESS=False
volumes:
- ./src/agents:/app
depends_on:
- livekit
- minio
develop:
watch:
- action: rebuild
path: ./src/agents
redis-summary:
image: redis
ports:
+3
View File
@@ -71,6 +71,9 @@ RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
# Telephony
ROOM_TELEPHONY_ENABLED=True
# Metadata
METADATA_COLLECTOR_ENABLED=True
FRONTEND_USE_FRENCH_GOV_FOOTER=False
FRONTEND_USE_PROCONNECT_BUTTON=False
+6
View File
@@ -36,6 +36,12 @@
"matchPackageNames": ["django"],
"allowedVersions": "<6.0.0"
},
{
"groupName": "allowed brevo versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["brevo-python"],
"allowedVersions": "<3.0.0"
},
{
"enabled": false,
"groupName": "ignored js dependencies",
+16 -5
View File
@@ -15,19 +15,30 @@ COPY pyproject.toml .
RUN mkdir /install && \
pip install --prefix=/install .
FROM base AS development
WORKDIR /app
COPY pyproject.toml .
RUN pip install --no-cache-dir ".[dev]"
COPY . .
CMD ["python", "metadata_collector.py", "dev"]
FROM base AS production
WORKDIR /app
COPY --from=builder /install /usr/local
# Remove pip to reduce attack surface in production
RUN pip uninstall -y pip
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
# Un-privileged user running the application
COPY --from=builder /install /usr/local
COPY ./*.py /app/
COPY . .
CMD ["python", "multi-user-transcriber.py", "start"]
CMD ["python", "multi_user_transcriber.py", "start"]
+5
View File
@@ -0,0 +1,5 @@
"""Storage parsers specific exceptions."""
class MissingConfigError(Exception):
"""Raised when a variable is not set in configuration."""
+384
View File
@@ -0,0 +1,384 @@
"""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 load_dotenv
from livekit import api, rtc
from livekit.agents import (
Agent,
AgentServer,
AgentSession,
AutoSubscribe,
JobContext,
JobProcess,
JobRequest,
RoomInputOptions,
RoomIO,
RoomOutputOptions,
WorkerPermissions,
cli,
utils,
)
from livekit.plugins import silero
from minio import Minio
from minio.error import S3Error
from exceptions import MissingConfigError
load_dotenv()
logger = logging.getLogger("metadata-collector")
AGENT_NAME = os.getenv("METADATA_COLLECTOR_AGENT_NAME", "metadata-collector")
def prewarm(proc: JobProcess):
"""Preload voice activity detection model."""
proc.userdata["vad"] = silero.VAD.load()
server = AgentServer(
permissions=WorkerPermissions(
can_publish=False,
can_publish_data=False,
can_subscribe=True,
hidden=True,
),
)
server.setup_fnc = prewarm
@dataclass
class MetadataEvent:
"""A single timestamped event recorded during a meeting."""
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):
"""Initialize with a participant identity and shared events list."""
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 MetadataCollector:
"""Collect meeting events across all participants in a room.
Creates one AgentSession per participant to capture VAD events
(speech start/end), and listens for connection, disconnection,
and chat events. Persists all collected events as JSON to S3
on shutdown.
"""
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",
)
if (bucket_name := os.getenv("AWS_STORAGE_BUCKET_NAME")) is not None:
self.bucket_name = bucket_name
else:
raise MissingConfigError
self.ctx = ctx
self._sessions: dict[str, AgentSession] = {}
self._tasks: set[asyncio.Task] = set()
output_folder = os.getenv("AWS_S3_OUTPUT_FOLDER", "metadata")
self.output_filename = f"{output_folder}/{recording_id}-metadata.json"
# Storage for events
self.events = []
self.participants = {}
logger.info("MetadataCollector initialized")
def start(self):
"""Start listening for room-level events."""
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
):
"""Read a complete chat message and record it as an event."""
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):
"""Schedule async processing of an incoming chat stream."""
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):
"""Serialize collected events and upload as JSON to S3."""
logger.info("Persisting metadata...")
participants = []
for k, v in self.participants.items():
participants.append({"participantId": k, "name": v})
sorted_events = sorted(self.events, key=lambda e: e.timestamp)
payload = {
"events": [event.serialize() for event in sorted_events],
"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_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()
async def on_participant_entrypoint(
self, ctx: JobContext, participant: rtc.RemoteParticipant
):
"""Handle new participant by starting a VAD monitoring session."""
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)
try:
session = await self._start_session(participant)
self._sessions[participant.identity] = session
except Exception:
logger.exception("Failed to start session for %s", participant.identity)
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):
"""Update stored participant name when it changes."""
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 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()
@server.rtc_session(agent_name=AGENT_NAME, on_request=handle_job_request)
async def entrypoint(ctx: JobContext):
"""Initialize and run the metadata collector."""
logger.info("Starting metadata agent in room: %s", ctx.room.name)
recording_id = ctx.job.metadata
metadata_collector = MetadataCollector(ctx, recording_id)
metadata_collector.start()
ctx.add_participant_entrypoint(metadata_collector.on_participant_entrypoint)
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
async def cleanup():
logger.info("Shutting down metadata collector...")
await metadata_collector.aclose()
ctx.add_shutdown_callback(cleanup)
if __name__ == "__main__":
cli.run_app(server)
+10 -6
View File
@@ -4,19 +4,23 @@ name = "agents"
version = "1.12.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.3.10",
"livekit-plugins-deepgram==1.3.10",
"livekit-plugins-silero==1.3.10",
"livekit-agents==1.4.5",
"livekit-plugins-deepgram==1.4.5",
"livekit-plugins-silero==1.4.5",
"livekit-plugins-kyutai-lasuite==0.0.6",
"python-dotenv==1.2.1",
"protobuf==6.33.5"
"python-dotenv==1.2.2",
"protobuf==6.33.5",
"minio==7.2.15"
]
[project.optional-dependencies]
dev = [
"ruff==0.14.4",
"ruff==0.15.6",
]
[tool.setuptools]
py-modules = ["multi_user_transcriber", "metadata_collector", "exceptions"]
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
+14
View File
@@ -44,6 +44,10 @@ from core.recording.event.exceptions import (
)
from core.recording.event.notification import notification_service
from core.recording.event.parsers import get_parser
from core.recording.services.metadata_collector import (
MetadataCollectorException,
MetadataCollectorService,
)
from core.recording.worker.exceptions import (
RecordingStartError,
RecordingStopError,
@@ -336,6 +340,16 @@ class RoomViewSet(
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
)
if settings.METADATA_COLLECTOR_ENABLED and (
recording.mode == models.RecordingModeChoices.TRANSCRIPT
or recording.options.get("transcribe", False)
):
try:
MetadataCollectorService().start(recording)
logger.info("Started MetadataCollectorService")
except MetadataCollectorException:
logger.warning("Failed to start MetadataCollectorService")
return drf_response.Response(
{"message": f"Recording successfully started for room {room.slug}"},
status=drf_status.HTTP_201_CREATED,
@@ -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"),
"worker_id": recording.worker_id,
"owner_timezone": str(owner_access.user.timezone),
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
"context_language": owner_access.user.language,
}
@@ -0,0 +1,91 @@
"""Meeting metadata collection service."""
from logging import getLogger
from django.conf import settings
from asgiref.sync import async_to_sync, sync_to_async
from livekit.protocol.agent_dispatch import (
CreateAgentDispatchRequest,
)
from core import utils
from core.models import Recording
logger = getLogger(__name__)
class MetadataCollectorException(Exception):
"""Generic exception in the metadata collector."""
class MetadataCollectorService:
"""Service for dispatching and managing the metadata collector agent."""
@async_to_sync
async def start(self, recording: Recording):
"""Explicitly dispatch the metadata collector agent to a room."""
lkapi = utils.create_livekit_client()
room_id = str(recording.room.id)
try:
response = await lkapi.agent_dispatch.create_dispatch(
CreateAgentDispatchRequest(
agent_name=settings.METADATA_COLLECTOR_AGENT_NAME,
room=room_id,
metadata=str(recording.id),
)
)
except Exception as e:
logger.exception(
"Failed to create metadata collector agent for room %s", room_id
)
raise MetadataCollectorException(
"Failed to create metadata collector agent"
) from e
finally:
await lkapi.aclose()
dispatch_id = getattr(response, "id", None)
if not dispatch_id:
logger.error("LiveKit response missing dispatch ID for room %s", room_id)
raise MetadataCollectorException(
f"LiveKit did not return a dispatch_id for room {room_id}"
)
recording.options["metadata_collector_dispatch_id"] = dispatch_id
await sync_to_async(recording.save)(update_fields=["options"])
return dispatch_id
@async_to_sync
async def stop(self, recording: Recording):
"""Stop and delete the agent dispatch associated to the room."""
room_id = str(recording.room.id)
dispatch_id = recording.options.get("metadata_collector_dispatch_id")
lkapi = utils.create_livekit_client()
try:
if not dispatch_id:
logger.warning(
"No metadata collector dispatch ID stored for room %s", room_id
)
return None
await lkapi.agent_dispatch.delete_dispatch(
dispatch_id=str(dispatch_id), room_name=room_id
)
except Exception as e:
logger.exception(
"Failed to stop metadata collector agent dispatch for room %s",
room_id,
)
raise MetadataCollectorException(
f"Failed to stop metadata collector agent for room {room_id}"
) from e
finally:
await lkapi.aclose()
@@ -7,6 +7,7 @@ from logging import getLogger
from livekit import api
from core import models, utils
from core.models import Recording
logger = getLogger(__name__)
@@ -19,7 +20,7 @@ class RecordingEventsService:
"""Handles recording-related LiveKit webhook events."""
@staticmethod
def handle_update(recording, egress_status):
def handle_update(recording: Recording, egress_status):
"""Handle egress status updates and sync recording state to room metadata."""
room_name = str(recording.room.id)
@@ -40,7 +41,7 @@ class RecordingEventsService:
logger.exception("Failed to update room's metadata: %s", e)
@staticmethod
def handle_limit_reached(recording):
def handle_limit_reached(recording: Recording):
"""Stop recording and notify participants when limit is reached."""
recording.status = models.RecordingStatusChoices.STOPPED
+13 -1
View File
@@ -12,6 +12,10 @@ from django.conf import settings
from livekit import api
from core import models, utils
from core.recording.services.metadata_collector import (
MetadataCollectorException,
MetadataCollectorService,
)
from core.recording.services.recording_events import (
RecordingEventsError,
RecordingEventsService,
@@ -158,7 +162,7 @@ class LiveKitEventsService:
"""Handle 'egress_ended' event."""
try:
recording = models.Recording.objects.get(
recording = models.Recording.objects.select_related("room").get(
worker_id=data.egress_info.egress_id
)
except models.Recording.DoesNotExist as err:
@@ -174,6 +178,14 @@ class LiveKitEventsService:
except utils.MetadataUpdateException as e:
logger.exception("Failed to update room's metadata: %s", e)
if settings.METADATA_COLLECTOR_ENABLED and recording.options.get(
"metadata_collector_dispatch_id"
):
try:
MetadataCollectorService().stop(recording)
except MetadataCollectorException:
logger.warning("Failed to stop the MetadataCollectorService")
if (
data.egress_info.status == api.EgressStatus.EGRESS_LIMIT_REACHED
and recording.status == models.RecordingStatusChoices.ACTIVE
@@ -277,6 +277,7 @@ def test_start_recording_options_transcribe_valid_true(
):
"""Should accept transcribe with any valid pydantic true values."""
settings.RECORDING_ENABLE = True
settings.METADATA_COLLECTOR_ENABLED = False
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
@@ -487,6 +488,87 @@ def test_start_recording_options_original_mode_omitted(
assert recording.options == {}
def test_start_recording_calls_metadata_collector_start(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should call MetadataCollectorService.start when conditions are met."""
settings.RECORDING_ENABLE = True
settings.METADATA_COLLECTOR_ENABLED = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
with mock.patch(
"core.api.viewsets.MetadataCollectorService"
) as mock_collector_class:
mock_collector = mock.Mock()
mock_collector_class.return_value = mock_collector
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"transcribe": True}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
mock_collector.start.assert_called_once_with(recording)
@pytest.mark.parametrize(
"metadata_enabled,transcribe",
[
(False, True),
(False, False),
(True, False),
(True, None),
],
)
def test_start_recording_does_not_call_metadata_collector_start_when_conditions_not_met(
settings,
mock_worker_service_factory,
mock_worker_manager,
metadata_enabled,
transcribe,
):
"""Should not call MetadataCollectorService.start when conditions are not met."""
settings.RECORDING_ENABLE = True
settings.METADATA_COLLECTOR_ENABLED = metadata_enabled
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
options = {}
if transcribe is not None:
options["transcribe"] = transcribe
with mock.patch(
"core.api.viewsets.MetadataCollectorService"
) as mock_collector_class:
mock_collector = mock.Mock()
mock_collector_class.return_value = mock_collector
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": options}
if options
else {"mode": "screen_recording"},
format="json",
)
assert response.status_code == 201
mock_collector.start.assert_not_called()
@pytest.mark.parametrize("value", ["invalid_mode", "foo", 123, "SCREEN_RECORDING"])
def test_start_recording_options_original_mode_invalid(settings, value):
"""Should reject invalid recording mode values for original_mode."""
@@ -269,6 +269,63 @@ def test_handle_egress_ended_recording_not_limit_reached(
assert recording.status == "stopped"
@mock.patch("core.services.livekit_events.MetadataCollectorService")
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_ended_calls_metadata_collector_stop_when_conditions_are_met(
mock_update_room_metadata, mock_collector_class, service, settings
):
"""Should call MetadataCollectorService.stop when it exists."""
settings.METADATA_COLLECTOR_ENABLED = True
recording = RecordingFactory(
worker_id="worker-1",
status="active",
options={"metadata_collector_dispatch_id": "dispatch-123"},
)
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = EgressStatus.EGRESS_COMPLETE
mock_collector = mock.Mock()
mock_collector_class.return_value = mock_collector
service._handle_egress_ended(mock_data)
mock_collector.stop.assert_called_once_with(recording)
@pytest.mark.parametrize(
"metadata_enabled,options",
[
(True, {}),
(False, {}),
],
)
@mock.patch("core.services.livekit_events.MetadataCollectorService")
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditions_not_met(
_, mock_collector_class, metadata_enabled, options, service, settings
): # pylint: disable=too-many-arguments,too-many-positional-arguments
"""Should not call MetadataCollectorService.stop when it does not exist."""
settings.METADATA_COLLECTOR_ENABLED = metadata_enabled
recording = RecordingFactory(
worker_id="worker-1",
status="active",
options=options,
)
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = EgressStatus.EGRESS_COMPLETE
mock_collector = mock.Mock()
mock_collector_class.return_value = mock_collector
service._handle_egress_ended(mock_data)
mock_collector.stop.assert_not_called()
@mock.patch.object(LobbyService, "clear_room_cache")
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
+10
View File
@@ -808,6 +808,16 @@ class Base(Configuration):
environ_prefix=None,
)
# Metadata collector settings
METADATA_COLLECTOR_ENABLED = values.BooleanValue(
False, environ_name="METADATA_COLLECTOR_ENABLED", environ_prefix=None
)
METADATA_COLLECTOR_AGENT_NAME = values.Value(
"metadata-collector",
environ_name="METADATA_COLLECTOR_AGENT_NAME",
environ_prefix=None,
)
# External Applications
APPLICATION_CLIENT_ID_LENGTH = values.PositiveIntegerValue(
40,
+14 -14
View File
@@ -24,16 +24,16 @@ keywords = ["Django", "Contacts", "Templates", "RBAC"]
license = "MIT"
requires-python = ">=3.13"
dependencies = [
"boto3==1.42.49",
"boto3==1.42.68",
"Brotli==1.2.0",
"brevo-python==1.2.0",
"celery[redis]==5.6.2",
"dj-database-url==3.1.0",
"dj-database-url==3.1.2",
"django-configurations==2.5.1",
"django-cors-headers==4.9.0",
"django-countries==8.2.0",
"django-filter==25.2",
"django-lasuite[all]==0.0.24",
"django-lasuite[all]==0.0.25",
"django-parler==2.3",
"redis==5.2.1",
"django-redis==6.0.0",
@@ -43,21 +43,21 @@ dependencies = [
"django==5.2.12",
"djangorestframework==3.16.1",
"drf_spectacular==0.29.0",
"dockerflow==2026.1.26",
"dockerflow==2026.3.4",
"easy_thumbnails==2.10.1",
"factory_boy==3.3.3",
"gunicorn==25.1.0",
"jsonschema==4.26.0",
"markdown==3.10.2",
"nested-multipart-parser==1.6.0",
"psycopg[binary]==3.3.2",
"pydantic==2.12.4",
"PyJWT==2.12.0",
"psycopg[binary]==3.3.3",
"pydantic==2.12.5",
"PyJWT==2.12.1",
"python-frontmatter==1.1.0",
"python-magic==0.4.27",
"requests==2.32.5",
"sentry-sdk==2.53.0",
"whitenoise==6.11.0",
"sentry-sdk==2.54.0",
"whitenoise==6.12.0",
"mozilla-django-oidc==5.0.2",
"livekit-api==1.1.0",
"aiohttp==3.13.3",
@@ -72,11 +72,11 @@ dependencies = [
[dependency-groups]
dev = [
"django-extensions==4.1",
"drf-spectacular-sidecar==2026.1.1",
"drf-spectacular-sidecar==2026.3.1",
"freezegun==1.5.5",
"ipdb==0.13.13",
"ipython==9.10.0",
"pyfakefs==6.1.1",
"ipython==9.11.0",
"pyfakefs==6.1.5",
"pylint-django==2.7.0",
"pylint<4.0.0",
"pytest-cov==7.0.0",
@@ -84,8 +84,8 @@ dev = [
"pytest==9.0.2",
"pytest-icdiff==0.9",
"pytest-xdist==3.8.0",
"responses==0.25.8",
"ruff==0.15.1",
"responses==0.26.0",
"ruff==0.15.6",
"types-requests==2.32.4.20260107",
]
+98 -98
View File
@@ -159,30 +159,30 @@ wheels = [
[[package]]
name = "boto3"
version = "1.42.49"
version = "1.42.68"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/20/91/105aa17e0f3a566d33e2d8a3b32a70f553b1ad500d9756c6dd63991d8354/boto3-1.42.49.tar.gz", hash = "sha256:9cd252f640567b86e92b0a8ffdd4ade9a3018ee357c724bff6a21b8c8a41be0c", size = 112877, upload-time = "2026-02-13T20:29:57.062Z" }
sdist = { url = "https://files.pythonhosted.org/packages/06/ae/60c642aa5413e560b671da825329f510b29a77274ed0f580bde77562294d/boto3-1.42.68.tar.gz", hash = "sha256:3f349f967ab38c23425626d130962bcb363e75f042734fe856ea8c5a00eef03c", size = 112761, upload-time = "2026-03-13T19:32:17.137Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b1/1fa30cd7b26617d59efbe3a4f3660a5b8b397a4623bf1e67016c4cb6dd0e/boto3-1.42.49-py3-none-any.whl", hash = "sha256:99e1df4361c3f6ff6ade65803c043ea96314826134962dd3b385433b309eb819", size = 140606, upload-time = "2026-02-13T20:29:55.366Z" },
{ url = "https://files.pythonhosted.org/packages/fb/f6/dc6e993479dbb597d68223fbf61cb026511737696b15bd7d2a33e9b2c24f/boto3-1.42.68-py3-none-any.whl", hash = "sha256:dbff353eb7dc93cbddd7926ed24793e0174c04adbe88860dfa639568442e4962", size = 140556, upload-time = "2026-03-13T19:32:14.951Z" },
]
[[package]]
name = "botocore"
version = "1.42.64"
version = "1.42.73"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d3/3c/ac4bc939da695d2c648bf28f7b204ab741e4504e81749ccf943403cc07ca/botocore-1.42.64.tar.gz", hash = "sha256:4ee2aece227b9171ace8b749af694a77ab984fceab1639f2626bd0d6fb1aa69d", size = 14967869, upload-time = "2026-03-09T19:51:46.213Z" }
sdist = { url = "https://files.pythonhosted.org/packages/28/23/0c88ca116ef63b1ae77c901cd5d2095d22a8dbde9e80df74545db4a061b4/botocore-1.42.73.tar.gz", hash = "sha256:575858641e4949aaf2af1ced145b8524529edf006d075877af6b82ff96ad854c", size = 15008008, upload-time = "2026-03-20T19:39:40.082Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/0f/a0feb9a93da8f583217432dce71ce1940d6d8aa5884bad340872a504ba3f/botocore-1.42.64-py3-none-any.whl", hash = "sha256:f77c5cb76ed30576ed0bc73b591265d03dddffff02a9208d3ee0c790f43d3cd2", size = 14641339, upload-time = "2026-03-09T19:51:41.244Z" },
{ url = "https://files.pythonhosted.org/packages/8e/65/971f3d55015f4d133a6ff3ad74cd39f4b8dd8f53f7775a3c2ad378ea5145/botocore-1.42.73-py3-none-any.whl", hash = "sha256:7b62e2a12f7a1b08eb7360eecd23bb16fe3b7ab7f5617cf91b25476c6f86a0fe", size = 14681861, upload-time = "2026-03-20T19:39:35.341Z" },
]
[[package]]
@@ -545,14 +545,14 @@ wheels = [
[[package]]
name = "dj-database-url"
version = "3.1.0"
version = "3.1.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/95/c6/88676a7333fb7c668e626b55f8bfc8527dd863973eb1c40412b95d27747d/dj_database_url-3.1.0.tar.gz", hash = "sha256:d80218426b83f9302c8d27d4fccf52de5cf0cab179f0645fb2839f37605d1353", size = 7924, upload-time = "2026-01-04T09:18:32.693Z" }
sdist = { url = "https://files.pythonhosted.org/packages/03/f6/00b625e9d371b980aa261011d0dc906a16444cb688f94215e0dc86996eb5/dj_database_url-3.1.2.tar.gz", hash = "sha256:63c20e4bbaa51690dfd4c8d189521f6bf6bc9da9fcdb23d95d2ee8ee87f9ec62", size = 11490, upload-time = "2026-02-19T15:30:23.638Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/1b/e84f7472ab0bdacc3fd09556eb4dd40d88246941d465cc103b36a8dabcd8/dj_database_url-3.1.0-py3-none-any.whl", hash = "sha256:155a56fbbecbaaf1348ccd73bf29138b4c9988363ba08261a0f0145e392e638c", size = 8849, upload-time = "2026-01-04T09:18:43.77Z" },
{ url = "https://files.pythonhosted.org/packages/cf/a9/57c66006373381f1d3e5bd94216f1d371228a89f443d3030e010f73dd198/dj_database_url-3.1.2-py3-none-any.whl", hash = "sha256:544e015fee3efa5127a1eb1cca465f4ace578265b3671fe61d0ed7dbafb5ec8a", size = 8953, upload-time = "2026-02-19T15:30:39.37Z" },
]
[[package]]
@@ -633,7 +633,7 @@ wheels = [
[[package]]
name = "django-lasuite"
version = "0.0.24"
version = "0.0.25"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
@@ -644,9 +644,9 @@ dependencies = [
{ name = "requests" },
{ name = "requests-toolbelt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/53/dc/4002ba20b964237a609628d53b950c5aecf6387119058831e0ee33176f59/django_lasuite-0.0.24.tar.gz", hash = "sha256:3231b0178a2187405c8faae447225c5dd069263a9a09e73e2a36427be4bf2388", size = 34293, upload-time = "2026-02-11T12:25:42.558Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/bc/5826a3e5ada5dad1ff6af9a2e1eb598a0dc2cb9c4d2dbf011f1bbb310c9e/django_lasuite-0.0.25.tar.gz", hash = "sha256:ee44783942e6ead74a732f6d7280c5fca961b66581350ce01c0589c3e80684cf", size = 34819, upload-time = "2026-03-10T13:40:28.705Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/90/9170b8f443ab5d34b32d7cd386ce3b38ca35baae966da4a1d77b4f4ab3d2/django_lasuite-0.0.24-py3-none-any.whl", hash = "sha256:c37e0b3606a23fd4094ca435fa4edf7a64d325be64b0310fa6577f75d515d856", size = 52620, upload-time = "2026-02-11T12:25:40.626Z" },
{ url = "https://files.pythonhosted.org/packages/61/2f/819ebbc9d3a4b8233cef1e9b0b607724e4acf527988393b04a4036b9c235/django_lasuite-0.0.25-py3-none-any.whl", hash = "sha256:4c10f625005cd41d05e8d34269f1a3b58fea8be5296528bd58596227f50d9884", size = 54034, upload-time = "2026-03-10T13:40:26.903Z" },
]
[package.optional-dependencies]
@@ -737,11 +737,11 @@ wheels = [
[[package]]
name = "dockerflow"
version = "2026.1.26"
version = "2026.3.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/11/08/7abe8458ce6fa82e694ba9b792b4158596fd681782976699d3f15642a23a/dockerflow-2026.1.26.tar.gz", hash = "sha256:c45667ec078944a468b9a634c866587b4cf31559d0e6f369fbadd21edce66b38", size = 68042, upload-time = "2026-01-26T15:30:06.606Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/03/935f8399094611981b37634a7ee078484b3995f744135339e51385638f6c/dockerflow-2026.3.4.tar.gz", hash = "sha256:6cc7fcab2a078d06aeca8b02e22fd7f6766dca322fb30aed6910684dbc08690c", size = 68701, upload-time = "2026-03-04T09:01:28.178Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/88/26cec1e8ce09b4022233f232962fba72650c22941d7a609d10b9eec9f748/dockerflow-2026.1.26-py2.py3-none-any.whl", hash = "sha256:26f02d8842672921b2a49f1e019fac54ed2ee69dc673ed80f7b237b67394c2be", size = 35992, upload-time = "2026-01-26T15:30:05.525Z" },
{ url = "https://files.pythonhosted.org/packages/86/b9/2d7bf30eb0c24e60e61e4f6ae7574e123d3fa51b39a612e146c94158446a/dockerflow-2026.3.4-py2.py3-none-any.whl", hash = "sha256:38fe0ad30a4aedf68e589919695d502fc6f584a761b56a91f56e80cbadfbf61a", size = 36424, upload-time = "2026-03-04T09:01:26.631Z" },
]
[[package]]
@@ -763,14 +763,14 @@ wheels = [
[[package]]
name = "drf-spectacular-sidecar"
version = "2026.1.1"
version = "2026.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1e/81/c7b0e3ccbd5a039c4f4fcfecf88391a666ca1406a953886e2f39295b1c90/drf_spectacular_sidecar-2026.1.1.tar.gz", hash = "sha256:6f7c173a8ddbbbdafc7a27e028614b65f07a89ca90f996a432d57460463b56be", size = 2468060, upload-time = "2026-01-01T11:27:12.682Z" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/42/2f8c1b2846399d47094ec414bc0d6a7cce7ba95fd6545a97285eee89f7f1/drf_spectacular_sidecar-2026.3.1.tar.gz", hash = "sha256:5b7fedad66e3851f2f442480792c08115d79217959d01645b93d3d2258938be1", size = 2461501, upload-time = "2026-03-01T11:31:19.708Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/96/38725edda526f3e9e597f531beeec94b0ef433d9494f06a13b7636eecb6e/drf_spectacular_sidecar-2026.1.1-py3-none-any.whl", hash = "sha256:af8df62f1b594ec280351336d837eaf2402ab25a6bc2a1fad7aee9935821070f", size = 2489520, upload-time = "2026-01-01T11:27:11.056Z" },
{ url = "https://files.pythonhosted.org/packages/c1/28/2d5e64d101ebc5180674fcaf7b5a35e398e2f8d9688b2e8d52b0e1394e7d/drf_spectacular_sidecar-2026.3.1-py3-none-any.whl", hash = "sha256:864edb83e022e13e3941c325c3cc0c954c843fa2e1d0bc95e81887664b2d3dad", size = 2481725, upload-time = "2026-03-01T11:31:18.469Z" },
]
[[package]]
@@ -976,7 +976,7 @@ wheels = [
[[package]]
name = "ipython"
version = "9.10.0"
version = "9.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -990,9 +990,9 @@ dependencies = [
{ name = "stack-data" },
{ name = "traitlets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a6/60/2111715ea11f39b1535bed6024b7dec7918b71e5e5d30855a5b503056b50/ipython-9.10.0.tar.gz", hash = "sha256:cd9e656be97618a0676d058134cd44e6dc7012c0e5cb36a9ce96a8c904adaf77", size = 4426526, upload-time = "2026-02-02T10:00:33.594Z" }
sdist = { url = "https://files.pythonhosted.org/packages/86/28/a4698eda5a8928a45d6b693578b135b753e14fa1c2b36ee9441e69a45576/ipython-9.11.0.tar.gz", hash = "sha256:2a94bc4406b22ecc7e4cb95b98450f3ea493a76bec8896cda11b78d7752a6667", size = 4427354, upload-time = "2026-03-05T08:57:30.549Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/aa/898dec789a05731cd5a9f50605b7b44a72bd198fd0d4528e11fc610177cc/ipython-9.10.0-py3-none-any.whl", hash = "sha256:c6ab68cc23bba8c7e18e9b932797014cc61ea7fd6f19de180ab9ba73e65ee58d", size = 622774, upload-time = "2026-02-02T10:00:31.503Z" },
{ url = "https://files.pythonhosted.org/packages/b2/90/45c72becc57158facc6a6404f663b77bbcea2519ca57f760e2879ae1315d/ipython-9.11.0-py3-none-any.whl", hash = "sha256:6922d5bcf944c6e525a76a0a304451b60a2b6f875e86656d8bc2dfda5d710e19", size = 624222, upload-time = "2026-03-05T08:57:28.94Z" },
]
[[package]]
@@ -1222,24 +1222,24 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "aiohttp", specifier = "==3.13.3" },
{ name = "boto3", specifier = "==1.42.49" },
{ name = "boto3", specifier = "==1.42.68" },
{ name = "brevo-python", specifier = "==1.2.0" },
{ name = "brotli", specifier = "==1.2.0" },
{ name = "celery", extras = ["redis"], specifier = "==5.6.2" },
{ name = "dj-database-url", specifier = "==3.1.0" },
{ name = "dj-database-url", specifier = "==3.1.2" },
{ name = "django", specifier = "==5.2.12" },
{ name = "django-configurations", specifier = "==2.5.1" },
{ name = "django-cors-headers", specifier = "==4.9.0" },
{ name = "django-countries", specifier = "==8.2.0" },
{ name = "django-filter", specifier = "==25.2" },
{ name = "django-lasuite", extras = ["all"], specifier = "==0.0.24" },
{ name = "django-lasuite", extras = ["all"], specifier = "==0.0.25" },
{ name = "django-parler", specifier = "==2.3" },
{ name = "django-pydantic-field", specifier = "==0.5.4" },
{ name = "django-redis", specifier = "==6.0.0" },
{ name = "django-storages", extras = ["s3"], specifier = "==1.14.6" },
{ name = "django-timezone-field", specifier = ">=5.1" },
{ name = "djangorestframework", specifier = "==3.16.1" },
{ name = "dockerflow", specifier = "==2026.1.26" },
{ name = "dockerflow", specifier = "==2026.3.4" },
{ name = "drf-spectacular", specifier = "==0.29.0" },
{ name = "easy-thumbnails", specifier = "==2.10.1" },
{ name = "factory-boy", specifier = "==3.3.3" },
@@ -1249,25 +1249,25 @@ requires-dist = [
{ name = "markdown", specifier = "==3.10.2" },
{ name = "mozilla-django-oidc", specifier = "==5.0.2" },
{ name = "nested-multipart-parser", specifier = "==1.6.0" },
{ name = "psycopg", extras = ["binary"], specifier = "==3.3.2" },
{ name = "pydantic", specifier = "==2.12.4" },
{ name = "pyjwt", specifier = "==2.12.0" },
{ name = "psycopg", extras = ["binary"], specifier = "==3.3.3" },
{ name = "pydantic", specifier = "==2.12.5" },
{ name = "pyjwt", specifier = "==2.12.1" },
{ name = "python-frontmatter", specifier = "==1.1.0" },
{ name = "python-magic", specifier = "==0.4.27" },
{ name = "redis", specifier = "==5.2.1" },
{ name = "requests", specifier = "==2.32.5" },
{ name = "sentry-sdk", specifier = "==2.53.0" },
{ name = "whitenoise", specifier = "==6.11.0" },
{ name = "sentry-sdk", specifier = "==2.54.0" },
{ name = "whitenoise", specifier = "==6.12.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "django-extensions", specifier = "==4.1" },
{ name = "drf-spectacular-sidecar", specifier = "==2026.1.1" },
{ name = "drf-spectacular-sidecar", specifier = "==2026.3.1" },
{ name = "freezegun", specifier = "==1.5.5" },
{ name = "ipdb", specifier = "==0.13.13" },
{ name = "ipython", specifier = "==9.10.0" },
{ name = "pyfakefs", specifier = "==6.1.1" },
{ name = "ipython", specifier = "==9.11.0" },
{ name = "pyfakefs", specifier = "==6.1.5" },
{ name = "pylint", specifier = "<4.0.0" },
{ name = "pylint-django", specifier = "==2.7.0" },
{ name = "pytest", specifier = "==9.0.2" },
@@ -1275,8 +1275,8 @@ dev = [
{ name = "pytest-django", specifier = "==4.12.0" },
{ name = "pytest-icdiff", specifier = "==0.9" },
{ name = "pytest-xdist", specifier = "==3.8.0" },
{ name = "responses", specifier = "==0.25.8" },
{ name = "ruff", specifier = "==0.15.1" },
{ name = "responses", specifier = "==0.26.0" },
{ name = "ruff", specifier = "==0.15.6" },
{ name = "types-requests", specifier = "==2.32.4.20260107" },
]
@@ -1595,14 +1595,14 @@ wheels = [
[[package]]
name = "psycopg"
version = "3.3.2"
version = "3.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e0/1a/7d9ef4fdc13ef7f15b934c393edc97a35c281bb7d3c3329fbfcbe915a7c2/psycopg-3.3.2.tar.gz", hash = "sha256:707a67975ee214d200511177a6a80e56e654754c9afca06a7194ea6bbfde9ca7", size = 165630, upload-time = "2025-12-06T17:34:53.899Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/b6/379d0a960f8f435ec78720462fd94c4863e7a31237cf81bf76d0af5883bf/psycopg-3.3.3.tar.gz", hash = "sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9", size = 165624, upload-time = "2026-02-18T16:52:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/51/2779ccdf9305981a06b21a6b27e8547c948d85c41c76ff434192784a4c93/psycopg-3.3.2-py3-none-any.whl", hash = "sha256:3e94bc5f4690247d734599af56e51bae8e0db8e4311ea413f801fef82b14a99b", size = 212774, upload-time = "2025-12-06T17:31:41.414Z" },
{ url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" },
]
[package.optional-dependencies]
@@ -1612,31 +1612,31 @@ binary = [
[[package]]
name = "psycopg-binary"
version = "3.3.2"
version = "3.3.3"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/73/7ca7cb22b9ac7393fb5de7d28ca97e8347c375c8498b3bff2c99c1f38038/psycopg_binary-3.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc5a189e89cbfff174588665bb18d28d2d0428366cc9dae5864afcaa2e57380b", size = 4579068, upload-time = "2025-12-06T17:33:39.303Z" },
{ url = "https://files.pythonhosted.org/packages/f5/42/0cf38ff6c62c792fc5b55398a853a77663210ebd51ed6f0c4a05b06f95a6/psycopg_binary-3.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:083c2e182be433f290dc2c516fd72b9b47054fcd305cce791e0a50d9e93e06f2", size = 4657520, upload-time = "2025-12-06T17:33:42.536Z" },
{ url = "https://files.pythonhosted.org/packages/3b/60/df846bc84cbf2231e01b0fff48b09841fe486fa177665e50f4995b1bfa44/psycopg_binary-3.3.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:ac230e3643d1c436a2dfb59ca84357dfc6862c9f372fc5dbd96bafecae581f9f", size = 5452086, upload-time = "2025-12-06T17:33:46.54Z" },
{ url = "https://files.pythonhosted.org/packages/ab/85/30c846a00db86b1b53fd5bfd4b4edfbd0c00de8f2c75dd105610bd7568fc/psycopg_binary-3.3.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d8c899a540f6c7585cee53cddc929dd4d2db90fd828e37f5d4017b63acbc1a5d", size = 5131125, upload-time = "2025-12-06T17:33:50.413Z" },
{ url = "https://files.pythonhosted.org/packages/6d/15/9968732013373f36f8a2a3fb76104dffc8efd9db78709caa5ae1a87b1f80/psycopg_binary-3.3.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50ff10ab8c0abdb5a5451b9315538865b50ba64c907742a1385fdf5f5772b73e", size = 6722914, upload-time = "2025-12-06T17:33:54.544Z" },
{ url = "https://files.pythonhosted.org/packages/b2/ba/29e361fe02143ac5ff5a1ca3e45697344cfbebe2eaf8c4e7eec164bff9a0/psycopg_binary-3.3.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:23d2594af848c1fd3d874a9364bef50730124e72df7bb145a20cb45e728c50ed", size = 4966081, upload-time = "2025-12-06T17:33:58.477Z" },
{ url = "https://files.pythonhosted.org/packages/99/45/1be90c8f1a1a237046903e91202fb06708745c179f220b361d6333ed7641/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ea4fe6b4ead3bbbe27244ea224fcd1f53cb119afc38b71a2f3ce570149a03e30", size = 4493332, upload-time = "2025-12-06T17:34:02.011Z" },
{ url = "https://files.pythonhosted.org/packages/2e/b5/bbdc07d5f0a5e90c617abd624368182aa131485e18038b2c6c85fc054aed/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:742ce48cde825b8e52fb1a658253d6d1ff66d152081cbc76aa45e2986534858d", size = 4170781, upload-time = "2025-12-06T17:34:05.298Z" },
{ url = "https://files.pythonhosted.org/packages/d1/2a/0d45e4f4da2bd78c3237ffa03475ef3751f69a81919c54a6e610eb1a7c96/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e22bf6b54df994aff37ab52695d635f1ef73155e781eee1f5fa75bc08b58c8da", size = 3910544, upload-time = "2025-12-06T17:34:08.251Z" },
{ url = "https://files.pythonhosted.org/packages/3a/62/a8e0f092f4dbef9a94b032fb71e214cf0a375010692fbe7493a766339e47/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8db9034cde3bcdafc66980f0130813f5c5d19e74b3f2a19fb3cfbc25ad113121", size = 4220070, upload-time = "2025-12-06T17:34:11.392Z" },
{ url = "https://files.pythonhosted.org/packages/09/e6/5fc8d8aff8afa114bb4a94a0341b9309311e8bf3ab32d816032f8b984d4e/psycopg_binary-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:df65174c7cf6b05ea273ce955927d3270b3a6e27b0b12762b009ce6082b8d3fc", size = 3540922, upload-time = "2025-12-06T17:34:14.88Z" },
{ url = "https://files.pythonhosted.org/packages/bd/75/ad18c0b97b852aba286d06befb398cc6d383e9dfd0a518369af275a5a526/psycopg_binary-3.3.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9ca24062cd9b2270e4d77576042e9cc2b1d543f09da5aba1f1a3d016cea28390", size = 4596371, upload-time = "2025-12-06T17:34:18.007Z" },
{ url = "https://files.pythonhosted.org/packages/5a/79/91649d94c8d89f84af5da7c9d474bfba35b08eb8f492ca3422b08f0a6427/psycopg_binary-3.3.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c749770da0947bc972e512f35366dd4950c0e34afad89e60b9787a37e97cb443", size = 4675139, upload-time = "2025-12-06T17:34:21.374Z" },
{ url = "https://files.pythonhosted.org/packages/56/ac/b26e004880f054549ec9396594e1ffe435810b0673e428e619ed722e4244/psycopg_binary-3.3.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03b7cd73fb8c45d272a34ae7249713e32492891492681e3cf11dff9531cf37e9", size = 5456120, upload-time = "2025-12-06T17:34:25.102Z" },
{ url = "https://files.pythonhosted.org/packages/4b/8d/410681dccd6f2999fb115cc248521ec50dd2b0aba66ae8de7e81efdebbee/psycopg_binary-3.3.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:43b130e3b6edcb5ee856c7167ccb8561b473308c870ed83978ae478613764f1c", size = 5133484, upload-time = "2025-12-06T17:34:28.933Z" },
{ url = "https://files.pythonhosted.org/packages/66/30/ebbab99ea2cfa099d7b11b742ce13415d44f800555bfa4ad2911dc645b71/psycopg_binary-3.3.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c1feba5a8c617922321aef945865334e468337b8fc5c73074f5e63143013b5a", size = 6731818, upload-time = "2025-12-06T17:34:33.094Z" },
{ url = "https://files.pythonhosted.org/packages/70/02/d260646253b7ad805d60e0de47f9b811d6544078452579466a098598b6f4/psycopg_binary-3.3.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cabb2a554d9a0a6bf84037d86ca91782f087dfff2a61298d0b00c19c0bc43f6d", size = 4983859, upload-time = "2025-12-06T17:34:36.457Z" },
{ url = "https://files.pythonhosted.org/packages/72/8d/e778d7bad1a7910aa36281f092bd85c5702f508fd9bb0ea2020ffbb6585c/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74bc306c4b4df35b09bc8cecf806b271e1c5d708f7900145e4e54a2e5dedfed0", size = 4516388, upload-time = "2025-12-06T17:34:40.129Z" },
{ url = "https://files.pythonhosted.org/packages/bd/f1/64e82098722e2ab3521797584caf515284be09c1e08a872551b6edbb0074/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d79b0093f0fbf7a962d6a46ae292dc056c65d16a8ee9361f3cfbafd4c197ab14", size = 4192382, upload-time = "2025-12-06T17:34:43.279Z" },
{ url = "https://files.pythonhosted.org/packages/fa/d0/c20f4e668e89494972e551c31be2a0016e3f50d552d7ae9ac07086407599/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1586e220be05547c77afc326741dd41cc7fba38a81f9931f616ae98865439678", size = 3928660, upload-time = "2025-12-06T17:34:46.757Z" },
{ url = "https://files.pythonhosted.org/packages/0f/e1/99746c171de22539fd5eb1c9ca21dc805b54cfae502d7451d237d1dbc349/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:458696a5fa5dad5b6fb5d5862c22454434ce4fe1cf66ca6c0de5f904cbc1ae3e", size = 4239169, upload-time = "2025-12-06T17:34:49.751Z" },
{ url = "https://files.pythonhosted.org/packages/72/f7/212343c1c9cfac35fd943c527af85e9091d633176e2a407a0797856ff7b9/psycopg_binary-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:04bb2de4ba69d6f8395b446ede795e8884c040ec71d01dd07ac2b2d18d4153d1", size = 3642122, upload-time = "2025-12-06T17:34:52.506Z" },
{ url = "https://files.pythonhosted.org/packages/03/0a/cac9fdf1df16a269ba0e5f0f06cac61f826c94cadb39df028cdfe19d3a33/psycopg_binary-3.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05f32239aec25c5fb15f7948cffdc2dc0dac098e48b80a140e4ba32b572a2e7d", size = 4590414, upload-time = "2026-02-18T16:50:01.441Z" },
{ url = "https://files.pythonhosted.org/packages/9c/c0/d8f8508fbf440edbc0099b1abff33003cd80c9e66eb3a1e78834e3fb4fb9/psycopg_binary-3.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c84f9d214f2d1de2fafebc17fa68ac3f6561a59e291553dfc45ad299f4898c1", size = 4669021, upload-time = "2026-02-18T16:50:08.803Z" },
{ url = "https://files.pythonhosted.org/packages/04/05/097016b77e343b4568feddf12c72171fc513acef9a4214d21b9478569068/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e77957d2ba17cada11be09a5066d93026cdb61ada7c8893101d7fe1c6e1f3925", size = 5467453, upload-time = "2026-02-18T16:50:14.985Z" },
{ url = "https://files.pythonhosted.org/packages/91/23/73244e5feb55b5ca109cede6e97f32ef45189f0fdac4c80d75c99862729d/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:42961609ac07c232a427da7c87a468d3c82fee6762c220f38e37cfdacb2b178d", size = 5151135, upload-time = "2026-02-18T16:50:24.82Z" },
{ url = "https://files.pythonhosted.org/packages/11/49/5309473b9803b207682095201d8708bbc7842ddf3f192488a69204e36455/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae07a3114313dd91fce686cab2f4c44af094398519af0e0f854bc707e1aeedf1", size = 6737315, upload-time = "2026-02-18T16:50:35.106Z" },
{ url = "https://files.pythonhosted.org/packages/d4/5d/03abe74ef34d460b33c4d9662bf6ec1dd38888324323c1a1752133c10377/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d257c58d7b36a621dcce1d01476ad8b60f12d80eb1406aee4cf796f88b2ae482", size = 4979783, upload-time = "2026-02-18T16:50:42.067Z" },
{ url = "https://files.pythonhosted.org/packages/f0/6c/3fbf8e604e15f2f3752900434046c00c90bb8764305a1b81112bff30ba24/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07c7211f9327d522c9c47560cae00a4ecf6687f4e02d779d035dd3177b41cb12", size = 4509023, upload-time = "2026-02-18T16:50:50.116Z" },
{ url = "https://files.pythonhosted.org/packages/9c/6b/1a06b43b7c7af756c80b67eac8bfaa51d77e68635a8a8d246e4f0bb7604a/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8e7e9eca9b363dbedeceeadd8be97149d2499081f3c52d141d7cd1f395a91f83", size = 4185874, upload-time = "2026-02-18T16:50:55.97Z" },
{ url = "https://files.pythonhosted.org/packages/2b/d3/bf49e3dcaadba510170c8d111e5e69e5ae3f981c1554c5bb71c75ce354bb/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cb85b1d5702877c16f28d7b92ba030c1f49ebcc9b87d03d8c10bf45a2f1c7508", size = 3925668, upload-time = "2026-02-18T16:51:03.299Z" },
{ url = "https://files.pythonhosted.org/packages/f8/92/0aac830ed6a944fe334404e1687a074e4215630725753f0e3e9a9a595b62/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d4606c84d04b80f9138d72f1e28c6c02dc5ae0c7b8f3f8aaf89c681ce1cd1b1", size = 4234973, upload-time = "2026-02-18T16:51:09.097Z" },
{ url = "https://files.pythonhosted.org/packages/2e/96/102244653ee5a143ece5afe33f00f52fe64e389dfce8dbc87580c6d70d3d/psycopg_binary-3.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:74eae563166ebf74e8d950ff359be037b85723d99ca83f57d9b244a871d6c13b", size = 3551342, upload-time = "2026-02-18T16:51:13.892Z" },
{ url = "https://files.pythonhosted.org/packages/a2/71/7a57e5b12275fe7e7d84d54113f0226080423a869118419c9106c083a21c/psycopg_binary-3.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:497852c5eaf1f0c2d88ab74a64a8097c099deac0c71de1cbcf18659a8a04a4b2", size = 4607368, upload-time = "2026-02-18T16:51:19.295Z" },
{ url = "https://files.pythonhosted.org/packages/c7/04/cb834f120f2b2c10d4003515ef9ca9d688115b9431735e3936ae48549af8/psycopg_binary-3.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:258d1ea53464d29768bf25930f43291949f4c7becc706f6e220c515a63a24edd", size = 4687047, upload-time = "2026-02-18T16:51:23.84Z" },
{ url = "https://files.pythonhosted.org/packages/40/e9/47a69692d3da9704468041aa5ed3ad6fc7f6bb1a5ae788d261a26bbca6c7/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:111c59897a452196116db12e7f608da472fbff000693a21040e35fc978b23430", size = 5487096, upload-time = "2026-02-18T16:51:29.645Z" },
{ url = "https://files.pythonhosted.org/packages/0b/b6/0e0dd6a2f802864a4ae3dbadf4ec620f05e3904c7842b326aafc43e5f464/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:17bb6600e2455993946385249a3c3d0af52cd70c1c1cdbf712e9d696d0b0bf1b", size = 5168720, upload-time = "2026-02-18T16:51:36.499Z" },
{ url = "https://files.pythonhosted.org/packages/6f/0d/977af38ac19a6b55d22dff508bd743fd7c1901e1b73657e7937c7cccb0a3/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642050398583d61c9856210568eb09a8e4f2fe8224bf3be21b67a370e677eead", size = 6762076, upload-time = "2026-02-18T16:51:43.167Z" },
{ url = "https://files.pythonhosted.org/packages/34/40/912a39d48322cf86895c0eaf2d5b95cb899402443faefd4b09abbba6b6e1/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:533efe6dc3a7cba5e2a84e38970786bb966306863e45f3db152007e9f48638a6", size = 4997623, upload-time = "2026-02-18T16:51:47.707Z" },
{ url = "https://files.pythonhosted.org/packages/98/0c/c14d0e259c65dc7be854d926993f151077887391d5a081118907a9d89603/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5958dbf28b77ce2033482f6cb9ef04d43f5d8f4b7636e6963d5626f000efb23e", size = 4532096, upload-time = "2026-02-18T16:51:51.421Z" },
{ url = "https://files.pythonhosted.org/packages/39/21/8b7c50a194cfca6ea0fd4d1f276158307785775426e90700ab2eba5cd623/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a6af77b6626ce92b5817bf294b4d45ec1a6161dba80fc2d82cdffdd6814fd023", size = 4208884, upload-time = "2026-02-18T16:51:57.336Z" },
{ url = "https://files.pythonhosted.org/packages/c7/2c/a4981bf42cf30ebba0424971d7ce70a222ae9b82594c42fc3f2105d7b525/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:47f06fcbe8542b4d96d7392c476a74ada521c5aebdb41c3c0155f6595fc14c8d", size = 3944542, upload-time = "2026-02-18T16:52:04.266Z" },
{ url = "https://files.pythonhosted.org/packages/60/e9/b7c29b56aa0b85a4e0c4d89db691c1ceef08f46a356369144430c155a2f5/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7800e6c6b5dc4b0ca7cc7370f770f53ac83886b76afda0848065a674231e856", size = 4254339, upload-time = "2026-02-18T16:52:10.444Z" },
{ url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" },
]
[[package]]
@@ -1668,7 +1668,7 @@ wheels = [
[[package]]
name = "pydantic"
version = "2.12.4"
version = "2.12.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
@@ -1676,9 +1676,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038, upload-time = "2025-11-05T10:50:08.59Z" }
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400, upload-time = "2025-11-05T10:50:06.732Z" },
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]
[[package]]
@@ -1736,11 +1736,11 @@ wheels = [
[[package]]
name = "pyfakefs"
version = "6.1.1"
version = "6.1.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/84/843a50cfde48d6004cbf540b38b82a39737273cc76b51da6b456fea75de0/pyfakefs-6.1.1.tar.gz", hash = "sha256:1c166fdba85f3f288f89b2a410e05712914d1f7ea77b920e960fe0cbbea1294d", size = 226032, upload-time = "2026-02-09T19:44:19.539Z" }
sdist = { url = "https://files.pythonhosted.org/packages/61/87/fe3f1fbbf18480b57e6b195d4d416e01d8e55bf88743982ccb3a8845c2a2/pyfakefs-6.1.5.tar.gz", hash = "sha256:d70fb1c622a1d5c729462d31bb58eead99c1830986bc049bf8975c480a49d19b", size = 226814, upload-time = "2026-03-15T19:05:18.025Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/e2/1d74c7663aa38e405c25a30c26e8a1b92c06f8947118b9617042f4290ade/pyfakefs-6.1.1-py3-none-any.whl", hash = "sha256:e2c30fd0b7736ba03785bbc20b86de2312623cf7a11a7de334042ab0de23d46f", size = 239630, upload-time = "2026-02-09T19:44:18.093Z" },
{ url = "https://files.pythonhosted.org/packages/e0/d8/a60fb94ab2b5a9bf0e38d6e93667904d4e844d8b7e9cfb5ef0304d68da6b/pyfakefs-6.1.5-py3-none-any.whl", hash = "sha256:2c482f36bad7d1f7516d58d17818fb0dbe397a3372ec5274869386a9b7cd4883", size = 239885, upload-time = "2026-03-15T19:05:16.488Z" },
]
[[package]]
@@ -1754,11 +1754,11 @@ wheels = [
[[package]]
name = "pyjwt"
version = "2.12.0"
version = "2.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a8/10/e8192be5f38f3e8e7e046716de4cae33d56fd5ae08927a823bb916be36c1/pyjwt-2.12.0.tar.gz", hash = "sha256:2f62390b667cd8257de560b850bb5a883102a388829274147f1d724453f8fb02", size = 102511, upload-time = "2026-03-12T17:15:30.831Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/15/70/70f895f404d363d291dcf62c12c85fdd47619ad9674ac0f53364d035925a/pyjwt-2.12.0-py3-none-any.whl", hash = "sha256:9bb459d1bdd0387967d287f5656bf7ec2b9a26645d1961628cda1764e087fd6e", size = 29700, upload-time = "2026-03-12T17:15:29.257Z" },
{ url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" },
]
[[package]]
@@ -1992,16 +1992,16 @@ wheels = [
[[package]]
name = "responses"
version = "0.25.8"
version = "0.26.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyyaml" },
{ name = "requests" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0e/95/89c054ad70bfef6da605338b009b2e283485835351a9935c7bfbfaca7ffc/responses-0.25.8.tar.gz", hash = "sha256:9374d047a575c8f781b94454db5cab590b6029505f488d12899ddb10a4af1cf4", size = 79320, upload-time = "2025-08-08T19:01:46.709Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9f/b4/b7e040379838cc71bf5aabdb26998dfbe5ee73904c92c1c161faf5de8866/responses-0.26.0.tar.gz", hash = "sha256:c7f6923e6343ef3682816ba421c006626777893cb0d5e1434f674b649bac9eb4", size = 81303, upload-time = "2026-02-19T14:38:05.574Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1c/4c/cc276ce57e572c102d9542d383b2cfd551276581dc60004cb94fe8774c11/responses-0.25.8-py3-none-any.whl", hash = "sha256:0c710af92def29c8352ceadff0c3fe340ace27cf5af1bbe46fb71275bcd2831c", size = 34769, upload-time = "2025-08-08T19:01:45.018Z" },
{ url = "https://files.pythonhosted.org/packages/ce/04/7f73d05b556da048923e31a0cc878f03be7c5425ed1f268082255c75d872/responses-0.26.0-py3-none-any.whl", hash = "sha256:03ec4409088cd5c66b71ecbbbd27fe2c58ddfad801c66203457b3e6a04868c37", size = 35099, upload-time = "2026-02-19T14:38:03.847Z" },
]
[[package]]
@@ -2072,27 +2072,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.15.1"
version = "0.15.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855, upload-time = "2026-02-12T23:09:09.998Z" }
sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819, upload-time = "2026-02-12T23:09:03.598Z" },
{ url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618, upload-time = "2026-02-12T23:09:22.928Z" },
{ url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518, upload-time = "2026-02-12T23:08:58.339Z" },
{ url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811, upload-time = "2026-02-12T23:09:31.865Z" },
{ url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169, upload-time = "2026-02-12T23:09:17.306Z" },
{ url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491, upload-time = "2026-02-12T23:09:25.503Z" },
{ url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280, upload-time = "2026-02-12T23:09:19.88Z" },
{ url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336, upload-time = "2026-02-12T23:09:14.907Z" },
{ url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288, upload-time = "2026-02-12T23:09:07.475Z" },
{ url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681, upload-time = "2026-02-12T23:08:55.43Z" },
{ url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401, upload-time = "2026-02-12T23:09:27.927Z" },
{ url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452, upload-time = "2026-02-12T23:09:12.147Z" },
{ url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900, upload-time = "2026-02-12T23:09:34.418Z" },
{ url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302, upload-time = "2026-02-12T23:09:36.536Z" },
{ url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555, upload-time = "2026-02-12T23:09:29.899Z" },
{ url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956, upload-time = "2026-02-12T23:09:01.157Z" },
{ url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" },
{ url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" },
{ url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" },
{ url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" },
{ url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" },
{ url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" },
{ url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" },
{ url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" },
{ url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" },
{ url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" },
{ url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" },
{ url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" },
{ url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" },
{ url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" },
{ url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" },
{ url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" },
{ url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" },
]
[[package]]
@@ -2109,15 +2109,15 @@ wheels = [
[[package]]
name = "sentry-sdk"
version = "2.53.0"
version = "2.54.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d3/06/66c8b705179bc54087845f28fd1b72f83751b6e9a195628e2e9af9926505/sentry_sdk-2.53.0.tar.gz", hash = "sha256:6520ef2c4acd823f28efc55e43eb6ce2e6d9f954a95a3aa96b6fd14871e92b77", size = 412369, upload-time = "2026-02-16T11:11:14.743Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c8/e9/2e3a46c304e7fa21eaa70612f60354e32699c7102eb961f67448e222ad7c/sentry_sdk-2.54.0.tar.gz", hash = "sha256:2620c2575128d009b11b20f7feb81e4e4e8ae08ec1d36cbc845705060b45cc1b", size = 413813, upload-time = "2026-03-02T15:12:41.355Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/d4/2fdf854bc3b9c7f55219678f812600a20a138af2dd847d99004994eada8f/sentry_sdk-2.53.0-py2.py3-none-any.whl", hash = "sha256:46e1ed8d84355ae54406c924f6b290c3d61f4048625989a723fd622aab838899", size = 437908, upload-time = "2026-02-16T11:11:13.227Z" },
{ url = "https://files.pythonhosted.org/packages/53/39/be412cc86bc6247b8f69e9383d7950711bd86f8d0a4a4b0fe8fad685bc21/sentry_sdk-2.54.0-py2.py3-none-any.whl", hash = "sha256:fd74e0e281dcda63afff095d23ebcd6e97006102cdc8e78a29f19ecdf796a0de", size = 439198, upload-time = "2026-03-02T15:12:39.546Z" },
]
[[package]]
@@ -2271,11 +2271,11 @@ wheels = [
[[package]]
name = "whitenoise"
version = "6.11.0"
version = "6.12.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/15/95/8c81ec6b6ebcbf8aca2de7603070ccf37dbb873b03f20708e0f7c1664bc6/whitenoise-6.11.0.tar.gz", hash = "sha256:0f5bfce6061ae6611cd9396a8231e088722e4fc67bc13a111be74c738d99375f", size = 26432, upload-time = "2025-09-18T09:16:10.995Z" }
sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841, upload-time = "2026-02-27T00:05:42.028Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/e9/4366332f9295fe0647d7d3251ce18f5615fbcb12d02c79a26f8dba9221b3/whitenoise-6.11.0-py3-none-any.whl", hash = "sha256:b2aeb45950597236f53b5342b3121c5de69c8da0109362aee506ce88e022d258", size = 20197, upload-time = "2025-09-18T09:16:09.754Z" },
{ url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" },
]
[[package]]
@@ -85,6 +85,7 @@ backend:
AWS_S3_DOMAIN_REPLACE: https://minio.127.0.0.1.nip.io
CELERY_ENABLED: True
CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
METADATA_COLLECTOR_ENABLED: True
migrate:
@@ -282,12 +283,23 @@ agents:
{{- end }}
{{- end }}
ENABLE_SILERO_VAD: "false"
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
AWS_STORAGE_BUCKET_NAME: meet-media-storage
AWS_S3_OUTPUT_FOLDER: recordings
image:
repository: localhost:5001/meet-agents
pullPolicy: Always
tag: "latest"
command:
- "python"
- "metadata_collector.py"
- "start"
# Extra volume mounts to manage our local custom CA and avoid to disable ssl
extraVolumeMounts:
- name: certs
+7 -7
View File
@@ -7,20 +7,20 @@ dependencies = [
"uvicorn>=0.24.0",
"pydantic>=2.5.0",
"pydantic-settings>=2.1.0",
"celery==5.5.3",
"celery==5.6.2",
"redis==5.2.1",
"minio==7.2.18",
"minio==7.2.20",
"mutagen==1.47.0",
"openai==2.7.1",
"posthog==6.9.1",
"openai==2.28.0",
"posthog==7.9.12",
"requests==2.32.5",
"sentry-sdk[fastapi, celery]==2.43.0",
"langfuse==3.10.6"
"sentry-sdk[fastapi, celery]==2.54.0",
"langfuse==4.0.0"
]
[project.optional-dependencies]
dev = [
"ruff==0.14.4",
"ruff==0.15.6",
"pytest==9.0.2",
"responses>=0.25.8",
]
+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]
worker_id: 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.worker_id,
request.owner_timezone,
request.language,
request.download_link,
request.context_language,
+1 -1
View File
@@ -112,7 +112,7 @@ class MetadataManager:
if self._is_disabled or self.has_task_id(task_id):
return
_, filename, email, _, received_at, *_ = task_args
_, filename, _, email, _, received_at, *_ = task_args
start_time = time.time()
initial_metadata = {
+61 -20
View File
@@ -3,7 +3,9 @@
# ruff: noqa: PLR0913
import json
import os
import time
from datetime import datetime, timezone
from typing import Optional
import openai
@@ -28,6 +30,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 +61,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 +78,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 +97,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 +127,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 +144,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 +177,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],
worker_id: Optional[str],
owner_timezone: Optional[str],
language: Optional[str],
download_link: Optional[str],
context_language: Optional[str] = None,
@@ -189,13 +200,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).
worker_id: LiveKit egress ID used to fetch the egress JSON from S3.
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 +220,46 @@ 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
# Fetch recording start time from LiveKit egress JSON
recording_datetime = None
recording_start_dt = None
if worker_id:
folder = os.path.dirname(recording_filename)
egress_json_key = f"{folder}/{worker_id}.json"
try:
egress_info = file_service.read_json(egress_json_key)
started_at_ns = egress_info.get("started_at")
if started_at_ns:
recording_start_dt = datetime.fromtimestamp(
started_at_ns / 1e9, tz=timezone.utc
)
recording_datetime = recording_start_dt.isoformat()
except Exception:
logger.warning(
"Could not read egress JSON %s, falling back to no recording datetime",
egress_json_key,
)
# Assign user names from metadata and transcription
metadata = file_service.read_json(metadata_filename)
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"),
)
+281
View File
@@ -0,0 +1,281 @@
"""Assign WhisperX diarization speakers to participant identities.
Uses per-stream VAD events to match generic SPEAKER_XX labels provided
by diarization to real user id's by computing time overlap between
diarization segments and VAD intervals.
Multiple speakers can map to the same participant (e.g. two people sharing
one microphone). A participant with no matching speaker gets no assignment.
"""
import logging
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
logger = logging.getLogger(__name__)
# Minimum fraction of a speaker's total duration that must overlap with a
# participant's VAD to accept the assignment.
DEFAULT_OVERLAP_THRESHOLD = 0.5
@dataclass
class Interval:
"""A time interval in seconds relative to recording start."""
start: float
end: float
@dataclass
class SpeakerAssignment:
"""Maps a diarization speaker label to a participant."""
speaker_label: str
participant_id: str
participant_name: str
score: float
@dataclass
class AssignmentResult:
"""Result of speaker-to-participant assignment."""
assignments: list[SpeakerAssignment] = field(default_factory=list)
unassigned_speakers: list[str] = field(default_factory=list)
def apply(self, diarization: dict[str, Any]) -> dict[str, Any]:
"""Return a copy of diarization with speaker labels replaced by names.
Replaces `"speaker"` fields in segments and word_segments with the
assigned participant name. Unassigned speakers are left as-is.
Args:
diarization: WhisperX dict with `segments` and optionally
`word_segments`.
Returns:
New dict with speaker labels replaced.
"""
speaker_to_name = {
a.speaker_label: a.participant_name for a in self.assignments
}
def _replace_speaker(item: dict[str, Any]) -> dict[str, Any]:
if "speaker" in item and item["speaker"] in speaker_to_name:
return {
**item,
"speaker": f"{speaker_to_name[item['speaker']]}:{item['speaker']}", # We still use the original diarization
}
return item
result: dict[str, Any] = {}
for key, value in diarization.items():
if key in ("segments", "word_segments"):
new_items = []
for item in value:
new_item = _replace_speaker(item)
if key == "segments" and "words" in item:
new_item["words"] = [_replace_speaker(w) for w in item["words"]]
new_items.append(new_item)
result[key] = new_items
else:
result[key] = value
return result
def _merge_intervals(intervals: list[Interval]) -> list[Interval]:
"""Return a list of non-overlapping intervals sorted by start time."""
if not intervals:
return []
sorted_intervals = sorted(intervals, key=lambda interval: interval.start)
merged: list[Interval] = [
Interval(sorted_intervals[0].start, sorted_intervals[0].end)
]
for interval in sorted_intervals[1:]:
if interval.start <= merged[-1].end:
merged[-1].end = max(merged[-1].end, interval.end)
else:
merged.append(Interval(interval.start, interval.end))
return merged
def _total_duration(intervals: list[Interval]) -> float:
"""Return the sum of all interval durations."""
return sum(interval.end - interval.start for interval in intervals)
def _overlap_duration(
a_intervals: list[Interval],
b_intervals: list[Interval],
) -> float:
"""Compute total overlap between two merged interval lists, sorted by start time.
Uses a sweep-line approach in O(n + m).
"""
overlap = 0.0
i = j = 0
while i < len(a_intervals) and j < len(b_intervals):
a = a_intervals[i]
b = b_intervals[j]
lo = max(a.start, b.start)
hi = min(a.end, b.end)
if lo < hi:
overlap += hi - lo
if a.end <= b.end:
i += 1
else:
j += 1
return overlap
def _parse_iso(ts: str) -> datetime:
"""Parse an ISO-formatted timestamp string."""
return datetime.fromisoformat(ts)
def _build_participant_timelines(
metadata: dict[str, Any],
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_datetime: UTC datetime used as t=0 reference.
Returns:
participant_id → merged VAD intervals
(seconds relative to recording_start_datetime).
participant_id → display name.
Intervals are in seconds relative to recording_start_datetime.
Events before recording start are clamped to 0.
"""
events = metadata.get("events", [])
participants_info = {
p["participantId"]: p.get("name", p["participantId"])
for p in metadata.get("participants", [])
}
ref_epoch = recording_start_datetime.timestamp()
open_starts: dict[str, float] = {}
intervals: dict[str, list[Interval]] = {}
for event in events:
pid = event["participant_id"]
ts = _parse_iso(event["timestamp"]).timestamp() - ref_epoch
etype = event["type"]
# TODO: close open speech_start
if etype == "speech_start":
open_starts[pid] = max(ts, 0.0)
elif etype == "speech_end":
start = open_starts.pop(pid, None)
if start is not None:
end = max(ts, 0.0)
if end > start:
intervals.setdefault(pid, []).append(Interval(start, end))
for pid, pid_intervals in intervals.items():
intervals[pid] = _merge_intervals(pid_intervals)
return intervals, participants_info
def _build_speaker_timelines(
transcription: Any,
) -> dict[str, list[Interval]]:
"""Build interval timelines from WhisperX transcription segments."""
intervals: dict[str, list[Interval]] = {}
segments = transcription.segments if hasattr(transcription, "segments") else []
for segment in segments:
speaker = segment.get("speaker")
if speaker is None:
continue
intervals.setdefault(speaker, []).append(
Interval(segment["start"], segment["end"])
)
for speaker, speaker_intervals in intervals.items():
intervals[speaker] = _merge_intervals(speaker_intervals)
return intervals
def assign_speakers(
metadata: dict[str, Any],
transcription: Any,
recording_start_datetime: datetime,
overlap_threshold: float = DEFAULT_OVERLAP_THRESHOLD,
) -> AssignmentResult:
"""Match WhisperX speaker labels to participants.
Args:
metadata: User metadata with `events` and `participants`.
transcription: WhisperX Transcription object with a `segments` attribute.
recording_start_datetime: UTC datetime for t=0 reference.
overlap_threshold: Minimum overlap/speaker_duration to accept.
Returns:
AssignmentResult with per-speaker assignments and unassigned
speakers.
"""
participant_timelines, participant_names = _build_participant_timelines(
metadata, recording_start_datetime
)
speaker_timelines = _build_speaker_timelines(transcription)
logger.info("Info info info")
logger.info(metadata)
logger.info(recording_start_datetime)
logger.info("participant_timelines")
logger.info(participant_timelines)
logger.info("participant_names")
logger.info(participant_names)
result = AssignmentResult()
for speaker, speaker_intervals in speaker_timelines.items():
speaker_duration = _total_duration(speaker_intervals)
if speaker_duration == 0:
result.unassigned_speakers.append(speaker)
continue
best_pid: str | None = None
best_score: float = 0.0
for pid, part_intervals in participant_timelines.items():
overlap = _overlap_duration(speaker_intervals, part_intervals)
score = overlap / speaker_duration
if score > best_score:
best_score = score
best_pid = pid
if best_pid is not None and best_score >= overlap_threshold:
result.assignments.append(
SpeakerAssignment(
speaker_label=speaker,
participant_id=best_pid,
participant_name=participant_names.get(best_pid, best_pid),
score=best_score,
)
)
logger.info(
"Assigned %s -> %s (score=%.3f)",
speaker,
participant_names.get(best_pid, best_pid),
best_score,
)
else:
result.unassigned_speakers.append(speaker)
logger.info(
"Speaker %s unassigned (best=%.3f, threshold=%.3f)",
speaker,
best_score,
overlap_threshold,
)
return result
+8 -6
View File
@@ -19,12 +19,13 @@ 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",
"worker_id": "EG_test123",
"owner_timezone": "UTC",
"language": None,
"download_link": "http://example.com/file.mp4",
},
@@ -36,13 +37,14 @@ class TestTasks:
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
"EG_test123", # worker_id
"UTC", # owner_timezone
None, # language
"http://example.com/file.mp4", # download_link
None, # context_language
+477
View File
@@ -0,0 +1,477 @@
"""Tests for the speaker-to-user assignment service."""
from dataclasses import dataclass, field
from datetime import datetime
from summary.core.user_assign import (
AssignmentResult,
Interval,
SpeakerAssignment,
_merge_intervals,
_overlap_duration,
_total_duration,
assign_speakers,
)
@dataclass
class FakeTranscription:
"""Mimics the OpenAI Transcription pydantic model for testing."""
segments: list = field(default_factory=list)
RECORDING_START = datetime.fromisoformat("2026-03-17T15:30:33.000001")
METADATA_SINGLE_USER = {
"events": [
{
"participant_id": "da8d39ff-3b1c-4e8d-9a70-c630c9871bcb",
"type": "participant_connected",
"timestamp": "2026-03-17T15:30:33.000001",
},
{
"participant_id": "da8d39ff-3b1c-4e8d-9a70-c630c9871bcb",
"type": "speech_start",
"timestamp": "2026-03-17T15:30:36.039456",
},
{
"participant_id": "da8d39ff-3b1c-4e8d-9a70-c630c9871bcb",
"type": "speech_end",
"timestamp": "2026-03-17T15:30:36.589114",
},
{
"participant_id": "da8d39ff-3b1c-4e8d-9a70-c630c9871bcb",
"type": "speech_start",
"timestamp": "2026-03-17T15:30:38.887518",
},
{
"participant_id": "da8d39ff-3b1c-4e8d-9a70-c630c9871bcb",
"type": "speech_end",
"timestamp": "2026-03-17T15:30:39.438141",
},
{
"participant_id": "da8d39ff-3b1c-4e8d-9a70-c630c9871bcb",
"type": "participant_disconnected",
"timestamp": "2026-03-17T15:30:43.223255",
},
],
"participants": [
{
"participantId": "da8d39ff-3b1c-4e8d-9a70-c630c9871bcb",
"name": "cameledev",
}
],
}
DIARIZATION_SINGLE_SPEAKER = FakeTranscription(
segments=[
{
"start": 1.363,
"end": 3.545,
"text": " The stale smell.",
"speaker": "SPEAKER_00",
},
{
"start": 4.466,
"end": 6.247,
"text": "It takes heat.",
"speaker": "SPEAKER_00",
},
],
)
USER_ID = "da8d39ff-3b1c-4e8d-9a70-c630c9871bcb"
class TestMergeIntervals:
"""Tests for _merge_intervals."""
def test_empty(self):
"""Empty input returns empty list."""
assert _merge_intervals([]) == []
def test_no_overlap(self):
"""Non-overlapping intervals stay separate."""
result = _merge_intervals([Interval(1, 2), Interval(3, 4)])
assert len(result) == 2
def test_overlap(self):
"""Overlapping intervals are merged."""
result = _merge_intervals([Interval(1, 3), Interval(2, 4)])
assert len(result) == 1
assert result[0].start == 1
assert result[0].end == 4
def test_adjacent(self):
"""Adjacent intervals are merged."""
result = _merge_intervals([Interval(1, 2), Interval(2, 3)])
assert len(result) == 1
assert result[0].start == 1
assert result[0].end == 3
def test_unsorted(self):
"""Unsorted input is sorted before merging."""
result = _merge_intervals([Interval(5, 6), Interval(1, 2)])
assert len(result) == 2
assert result[0].start == 1
class TestOverlapDuration:
"""Tests for _overlap_duration."""
def test_no_overlap(self):
"""Disjoint intervals have zero overlap."""
a = [Interval(1, 2)]
b = [Interval(3, 4)]
assert _overlap_duration(a, b) == 0.0
def test_full_overlap(self):
"""Identical intervals have full overlap."""
a = [Interval(1, 3)]
b = [Interval(1, 3)]
assert _overlap_duration(a, b) == 2.0
def test_partial_overlap(self):
"""Partially overlapping intervals."""
a = [Interval(1, 3)]
b = [Interval(2, 4)]
assert _overlap_duration(a, b) == 1.0
def test_multiple_intervals(self):
"""Multiple intervals with a spanning interval."""
a = [Interval(1, 3), Interval(5, 7)]
b = [Interval(2, 6)]
assert _overlap_duration(a, b) == 2.0
def test_empty(self):
"""Empty input yields zero overlap."""
assert _overlap_duration([], [Interval(1, 2)]) == 0.0
assert _overlap_duration([Interval(1, 2)], []) == 0.0
class TestTotalDuration:
"""Tests for _total_duration."""
def test_basic(self):
"""Sum of durations for multiple intervals."""
ivs = [Interval(0, 1), Interval(2, 5)]
assert _total_duration(ivs) == 4.0
def test_empty(self):
"""Empty input returns zero."""
assert _total_duration([]) == 0.0
class TestAssignSpeakers:
"""Tests for assign_speakers."""
def test_single_speaker_single_user(self):
"""Single speaker assigned to single user with low threshold."""
result = assign_speakers(
METADATA_SINGLE_USER,
DIARIZATION_SINGLE_SPEAKER,
RECORDING_START,
overlap_threshold=0.2,
)
assert len(result.assignments) == 1
assert result.assignments[0].speaker_label == "SPEAKER_00"
assert result.assignments[0].participant_id == USER_ID
assert result.assignments[0].participant_name == "cameledev"
assert result.assignments[0].score > 0
assert result.unassigned_speakers == []
def test_no_vad_events(self):
"""Participant with no speech leaves speakers unassigned."""
metadata = {
"events": [
{
"participant_id": "user-a",
"type": "participant_connected",
"timestamp": "2026-03-17T15:30:33.000000",
},
],
"participants": [{"participantId": "user-a", "name": "Silent User"}],
}
result = assign_speakers(
metadata,
DIARIZATION_SINGLE_SPEAKER,
RECORDING_START,
)
assert len(result.assignments) == 0
assert "SPEAKER_00" in result.unassigned_speakers
def test_multiple_speakers_same_user(self):
"""Two speakers from same mic both assigned to same user."""
metadata = {
"events": [
{
"participant_id": "user-a",
"type": "speech_start",
"timestamp": "2026-03-17T15:30:35.000000",
},
{
"participant_id": "user-a",
"type": "speech_end",
"timestamp": "2026-03-17T15:30:50.000000",
},
],
"participants": [{"participantId": "user-a", "name": "Shared Mic"}],
}
transcription = FakeTranscription(
segments=[
{"start": 1.0, "end": 3.0, "speaker": "SPEAKER_00"},
{"start": 5.0, "end": 7.0, "speaker": "SPEAKER_01"},
],
)
result = assign_speakers(metadata, transcription, RECORDING_START)
assert len(result.assignments) == 2
pids = {a.participant_id for a in result.assignments}
assert pids == {"user-a"}
def test_two_users_two_speakers(self):
"""Each speaker maps to correct user by VAD overlap."""
metadata = {
"events": [
{
"participant_id": "user-a",
"type": "speech_start",
"timestamp": "2026-03-17T15:30:34.000001",
},
{
"participant_id": "user-a",
"type": "speech_end",
"timestamp": "2026-03-17T15:30:37.000001",
},
{
"participant_id": "user-b",
"type": "speech_start",
"timestamp": "2026-03-17T15:30:38.000001",
},
{
"participant_id": "user-b",
"type": "speech_end",
"timestamp": "2026-03-17T15:30:41.000001",
},
],
"participants": [
{"participantId": "user-a", "name": "Alice"},
{"participantId": "user-b", "name": "Bob"},
],
}
transcription = FakeTranscription(
segments=[
{"start": 1.5, "end": 3.5, "speaker": "SPEAKER_00"},
{"start": 5.5, "end": 7.5, "speaker": "SPEAKER_01"},
],
)
result = assign_speakers(metadata, transcription, RECORDING_START)
assert len(result.assignments) == 2
by_speaker = {a.speaker_label: a for a in result.assignments}
assert by_speaker["SPEAKER_00"].participant_name == "Alice"
assert by_speaker["SPEAKER_01"].participant_name == "Bob"
def test_overlapping_speech_two_users(self):
"""Simultaneous speech from two users still assigns each speaker correctly."""
# user-a speaks from t=1s to t=6s, user-b speaks from t=3s to t=8s
# (3s overlap where both are speaking)
# SPEAKER_00 diarization covers t=1.55.5 (mostly user-a)
# SPEAKER_01 diarization covers t=4.07.5 (mostly user-b)
metadata = {
"events": [
{
"participant_id": "user-a",
"type": "speech_start",
"timestamp": "2026-03-17T15:30:34.000001",
},
{
"participant_id": "user-b",
"type": "speech_start",
"timestamp": "2026-03-17T15:30:36.000001",
},
{
"participant_id": "user-a",
"type": "speech_end",
"timestamp": "2026-03-17T15:30:39.000001",
},
{
"participant_id": "user-b",
"type": "speech_end",
"timestamp": "2026-03-17T15:30:41.000001",
},
],
"participants": [
{"participantId": "user-a", "name": "Alice"},
{"participantId": "user-b", "name": "Bob"},
],
}
transcription = FakeTranscription(
segments=[
{"start": 1.5, "end": 5.5, "speaker": "SPEAKER_00"},
{"start": 4.0, "end": 7.5, "speaker": "SPEAKER_01"},
],
)
result = assign_speakers(
metadata, transcription, RECORDING_START, overlap_threshold=0.3
)
assert len(result.assignments) == 2
by_speaker = {a.speaker_label: a for a in result.assignments}
assert by_speaker["SPEAKER_00"].participant_name == "Alice"
assert by_speaker["SPEAKER_01"].participant_name == "Bob"
assert result.unassigned_speakers == []
def test_below_threshold(self):
"""Speaker with minimal overlap stays unassigned."""
metadata = {
"events": [
{
"participant_id": "user-a",
"type": "speech_start",
"timestamp": "2026-03-17T15:30:34.000001",
},
{
"participant_id": "user-a",
"type": "speech_end",
"timestamp": "2026-03-17T15:30:35.169950",
},
],
"participants": [{"participantId": "user-a", "name": "Brief User"}],
}
transcription = FakeTranscription(
segments=[
{"start": 1.0, "end": 10.0, "speaker": "SPEAKER_00"},
],
)
result = assign_speakers(
metadata,
transcription,
RECORDING_START,
overlap_threshold=0.5,
)
assert len(result.assignments) == 0
assert "SPEAKER_00" in result.unassigned_speakers
def test_events_before_recording_start_clamped(self):
"""Speech events before recording start are clamped to t=0."""
metadata = {
"events": [
{
"participant_id": "user-a",
"type": "speech_start",
"timestamp": "2026-03-17T15:30:31.000001", # before RECORDING_START
},
{
"participant_id": "user-a",
"type": "speech_end",
"timestamp": "2026-03-17T15:30:36.000001", # after RECORDING_START
},
],
"participants": [{"participantId": "user-a", "name": "Early User"}],
}
transcription = FakeTranscription(
segments=[
{"start": 0.0, "end": 3.0, "speaker": "SPEAKER_00"},
],
)
result = assign_speakers(metadata, transcription, RECORDING_START)
assert len(result.assignments) == 1
assert result.assignments[0].participant_name == "Early User"
def test_empty_diarization(self):
"""No segments produces empty result."""
result = assign_speakers(
METADATA_SINGLE_USER, FakeTranscription(segments=[]), RECORDING_START
)
assert result == AssignmentResult()
def test_segment_without_speaker_ignored(self):
"""Segments missing speaker key are skipped."""
transcription = FakeTranscription(
segments=[
{"start": 1.0, "end": 3.0, "text": "no speaker"},
],
)
result = assign_speakers(METADATA_SINGLE_USER, transcription, RECORDING_START)
assert result == AssignmentResult()
class TestApply:
"""Tests for AssignmentResult.apply."""
def test_replaces_speakers_in_segments_and_words(self):
"""Speaker labels replaced in segments, words, and word_segments."""
diarization = {
"segments": [
{
"start": 1.0,
"end": 3.0,
"text": "Hello",
"speaker": "SPEAKER_00",
"words": [
{
"word": "Hello",
"start": 1.0,
"end": 1.5,
"speaker": "SPEAKER_00",
},
],
},
{
"start": 4.0,
"end": 6.0,
"text": "World",
"speaker": "SPEAKER_01",
"words": [
{
"word": "World",
"start": 4.0,
"end": 4.5,
"speaker": "SPEAKER_01",
},
],
},
],
"word_segments": [
{"word": "Hello", "start": 1.0, "end": 1.5, "speaker": "SPEAKER_00"},
{"word": "World", "start": 4.0, "end": 4.5, "speaker": "SPEAKER_01"},
],
}
assignment = AssignmentResult(
assignments=[
SpeakerAssignment("SPEAKER_00", "id-a", "Alice", 0.9),
SpeakerAssignment("SPEAKER_01", "id-b", "Bob", 0.8),
],
)
result = assignment.apply(diarization)
assert result["segments"][0]["speaker"] == "Alice"
assert result["segments"][0]["words"][0]["speaker"] == "Alice"
assert result["segments"][1]["speaker"] == "Bob"
assert result["word_segments"][0]["speaker"] == "Alice"
assert result["word_segments"][1]["speaker"] == "Bob"
def test_unassigned_speakers_unchanged(self):
"""Unassigned speaker labels are left as-is."""
diarization = {
"segments": [
{"start": 1.0, "end": 3.0, "speaker": "SPEAKER_02"},
],
}
assignment = AssignmentResult(
assignments=[
SpeakerAssignment("SPEAKER_00", "id-a", "Alice", 0.9),
],
unassigned_speakers=["SPEAKER_02"],
)
result = assignment.apply(diarization)
assert result["segments"][0]["speaker"] == "SPEAKER_02"
def test_preserves_extra_keys(self):
"""Non-segment keys in diarization are preserved."""
diarization = {
"segments": [],
"language": "en",
"custom_field": 42,
}
result = AssignmentResult().apply(diarization)
assert result["language"] == "en"
assert result["custom_field"] == 42