diff --git a/.github/workflows/dockerhub-backend.yml b/.github/workflows/dockerhub-backend.yml index 7a6c418b..c2c09463 100644 --- a/.github/workflows/dockerhub-backend.yml +++ b/.github/workflows/dockerhub-backend.yml @@ -1,12 +1,9 @@ name: Build and Push Backend Docker Image (Docker Hub) on: - pull_request: - types: - - closed - paths: - - "server/**" - - ".github/workflows/dockerhub-backend.yml" + push: + tags: + - "v*" workflow_dispatch: env: @@ -16,11 +13,6 @@ env: jobs: build-and-push: runs-on: ubuntu-latest - if: | - github.event_name == 'workflow_dispatch' || - (github.event.pull_request.merged == true && - startsWith(github.event.pull_request.head.ref, 'release-please--branches--')) - permissions: contents: read diff --git a/.github/workflows/dockerhub-frontend.yml b/.github/workflows/dockerhub-frontend.yml index 8eba8c17..e4962691 100644 --- a/.github/workflows/dockerhub-frontend.yml +++ b/.github/workflows/dockerhub-frontend.yml @@ -1,12 +1,9 @@ name: Build and Push Frontend Docker Image on: - pull_request: - types: - - closed - paths: - - "www/**" - - ".github/workflows/dockerhub-frontend.yml" + push: + tags: + - "v*" workflow_dispatch: env: @@ -16,11 +13,6 @@ env: jobs: build-and-push: runs-on: ubuntu-latest - if: | - github.event_name == 'workflow_dispatch' || - (github.event.pull_request.merged == true && - startsWith(github.event.pull_request.head.ref, 'release-please--branches--')) - permissions: contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md index 11abb526..5bc01f91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [0.23.2](https://github.com/Monadical-SAS/reflector/compare/v0.23.1...v0.23.2) (2025-12-11) + + +### Bug Fixes + +* build on push tags ([#785](https://github.com/Monadical-SAS/reflector/issues/785)) ([d7f140b](https://github.com/Monadical-SAS/reflector/commit/d7f140b7d1f4660d5da7a0da1357f68869e0b5cd)) + +## [0.23.1](https://github.com/Monadical-SAS/reflector/compare/v0.23.0...v0.23.1) (2025-12-11) + + +### Bug Fixes + +* populate room_name in transcript GET endpoint ([#783](https://github.com/Monadical-SAS/reflector/issues/783)) ([0eba147](https://github.com/Monadical-SAS/reflector/commit/0eba1470181c7b9e0a79964a1ef28c09bcbdd9d7)) + ## [0.23.0](https://github.com/Monadical-SAS/reflector/compare/v0.22.4...v0.23.0) (2025-12-10) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 45ff4402..f897a624 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -12,6 +12,7 @@ services: restart: unless-stopped env_file: - ./www/.env + pull_policy: always environment: - KV_URL=redis://redis:6379 depends_on: diff --git a/server/migrations/versions/05f8688d6895_add_action_items.py b/server/migrations/versions/05f8688d6895_add_action_items.py new file mode 100644 index 00000000..b789263f --- /dev/null +++ b/server/migrations/versions/05f8688d6895_add_action_items.py @@ -0,0 +1,26 @@ +"""add_action_items + +Revision ID: 05f8688d6895 +Revises: bbafedfa510c +Create Date: 2025-12-12 11:57:50.209658 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "05f8688d6895" +down_revision: Union[str, None] = "bbafedfa510c" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("transcript", sa.Column("action_items", sa.JSON(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("transcript", "action_items") diff --git a/server/reflector/dailyco_api/__init__.py b/server/reflector/dailyco_api/__init__.py index 8ef95274..65be426e 100644 --- a/server/reflector/dailyco_api/__init__.py +++ b/server/reflector/dailyco_api/__init__.py @@ -18,6 +18,7 @@ from .requests import ( # Response models from .responses import ( + FinishedRecordingResponse, MeetingParticipant, MeetingParticipantsResponse, MeetingResponse, @@ -79,6 +80,7 @@ __all__ = [ "MeetingParticipant", "MeetingResponse", "RecordingResponse", + "FinishedRecordingResponse", "RecordingS3Info", "MeetingTokenResponse", "WebhookResponse", diff --git a/server/reflector/dailyco_api/responses.py b/server/reflector/dailyco_api/responses.py index 279682ae..6ac95188 100644 --- a/server/reflector/dailyco_api/responses.py +++ b/server/reflector/dailyco_api/responses.py @@ -121,7 +121,10 @@ class RecordingS3Info(BaseModel): class RecordingResponse(BaseModel): """ - Response from recording retrieval endpoint. + Response from recording retrieval endpoint (network layer). + + Duration may be None for recordings still being processed by Daily. + Use FinishedRecordingResponse for recordings ready for processing. Reference: https://docs.daily.co/reference/rest-api/recordings """ @@ -135,7 +138,9 @@ class RecordingResponse(BaseModel): max_participants: int | None = Field( None, description="Maximum participants during recording (may be missing)" ) - duration: int = Field(description="Recording duration in seconds") + duration: int | None = Field( + None, description="Recording duration in seconds (None if still processing)" + ) share_token: NonEmptyString | None = Field( None, description="Token for sharing recording" ) @@ -149,6 +154,25 @@ class RecordingResponse(BaseModel): None, description="Meeting session identifier (may be missing)" ) + def to_finished(self) -> "FinishedRecordingResponse | None": + """Convert to FinishedRecordingResponse if duration is available and status is finished.""" + if self.duration is None or self.status != "finished": + return None + return FinishedRecordingResponse(**self.model_dump()) + + +class FinishedRecordingResponse(RecordingResponse): + """ + Recording with confirmed duration - ready for processing. + + This model guarantees duration is present and status is finished. + """ + + status: Literal["finished"] = Field( + description="Recording status (always 'finished')" + ) + duration: int = Field(description="Recording duration in seconds") + class MeetingTokenResponse(BaseModel): """ diff --git a/server/reflector/db/recordings.py b/server/reflector/db/recordings.py index 18fe358b..82609b38 100644 --- a/server/reflector/db/recordings.py +++ b/server/reflector/db/recordings.py @@ -3,6 +3,7 @@ from typing import Literal import sqlalchemy as sa from pydantic import BaseModel, Field +from sqlalchemy import or_ from reflector.db import get_database, metadata from reflector.utils import generate_uuid4 @@ -79,5 +80,35 @@ class RecordingController: results = await get_database().fetch_all(query) return [Recording(**row) for row in results] + async def get_multitrack_needing_reprocessing( + self, bucket_name: str + ) -> list[Recording]: + """ + Get multitrack recordings that need reprocessing: + - Have track_keys (multitrack) + - Either have no transcript OR transcript has error status + + This is more efficient than fetching all recordings and filtering in Python. + """ + from reflector.db.transcripts import ( + transcripts, # noqa: PLC0415 cyclic import + ) + + query = ( + recordings.select() + .outerjoin(transcripts, recordings.c.id == transcripts.c.recording_id) + .where( + recordings.c.bucket_name == bucket_name, + recordings.c.track_keys.isnot(None), + or_( + transcripts.c.id.is_(None), + transcripts.c.status == "error", + ), + ) + ) + results = await get_database().fetch_all(query) + recordings_list = [Recording(**row) for row in results] + return [r for r in recordings_list if r.is_multitrack] + recordings_controller = RecordingController() diff --git a/server/reflector/db/transcripts.py b/server/reflector/db/transcripts.py index f9c3c057..736075c8 100644 --- a/server/reflector/db/transcripts.py +++ b/server/reflector/db/transcripts.py @@ -44,6 +44,7 @@ transcripts = sqlalchemy.Table( sqlalchemy.Column("title", sqlalchemy.String), sqlalchemy.Column("short_summary", sqlalchemy.String), sqlalchemy.Column("long_summary", sqlalchemy.String), + sqlalchemy.Column("action_items", sqlalchemy.JSON), sqlalchemy.Column("topics", sqlalchemy.JSON), sqlalchemy.Column("events", sqlalchemy.JSON), sqlalchemy.Column("participants", sqlalchemy.JSON), @@ -164,6 +165,10 @@ class TranscriptFinalLongSummary(BaseModel): long_summary: str +class TranscriptActionItems(BaseModel): + action_items: dict + + class TranscriptFinalTitle(BaseModel): title: str @@ -204,6 +209,7 @@ class Transcript(BaseModel): locked: bool = False short_summary: str | None = None long_summary: str | None = None + action_items: dict | None = None topics: list[TranscriptTopic] = [] events: list[TranscriptEvent] = [] participants: list[TranscriptParticipant] | None = [] @@ -368,7 +374,12 @@ class TranscriptController: room_id: str | None = None, search_term: str | None = None, return_query: bool = False, - exclude_columns: list[str] = ["topics", "events", "participants"], + exclude_columns: list[str] = [ + "topics", + "events", + "participants", + "action_items", + ], ) -> list[Transcript]: """ Get all transcripts diff --git a/server/reflector/llm.py b/server/reflector/llm.py index 0485e847..f7c9137d 100644 --- a/server/reflector/llm.py +++ b/server/reflector/llm.py @@ -16,6 +16,9 @@ from llama_index.core.workflow import ( ) from llama_index.llms.openai_like import OpenAILike from pydantic import BaseModel, ValidationError +from workflows.errors import WorkflowTimeoutError + +from reflector.utils.retry import retry T = TypeVar("T", bound=BaseModel) OutputT = TypeVar("OutputT", bound=BaseModel) @@ -229,26 +232,38 @@ class LLM: texts: list[str], output_cls: Type[T], tone_name: str | None = None, + timeout: int | None = None, ) -> T: """Get structured output from LLM with validation retry via Workflow.""" - workflow = StructuredOutputWorkflow( - output_cls=output_cls, - max_retries=self.settings_obj.LLM_PARSE_MAX_RETRIES + 1, - timeout=120, - ) + if timeout is None: + timeout = self.settings_obj.LLM_STRUCTURED_RESPONSE_TIMEOUT - result = await workflow.run( - prompt=prompt, - texts=texts, - tone_name=tone_name, - ) - - if "error" in result: - error_msg = result["error"] or "Max retries exceeded" - raise LLMParseError( + async def run_workflow(): + workflow = StructuredOutputWorkflow( output_cls=output_cls, - error_msg=error_msg, - attempts=result.get("attempts", 0), + max_retries=self.settings_obj.LLM_PARSE_MAX_RETRIES + 1, + timeout=timeout, ) - return result["success"] + result = await workflow.run( + prompt=prompt, + texts=texts, + tone_name=tone_name, + ) + + if "error" in result: + error_msg = result["error"] or "Max retries exceeded" + raise LLMParseError( + output_cls=output_cls, + error_msg=error_msg, + attempts=result.get("attempts", 0), + ) + + return result["success"] + + return await retry(run_workflow)( + retry_attempts=3, + retry_backoff_interval=1.0, + retry_backoff_max=30.0, + retry_ignore_exc_types=(WorkflowTimeoutError,), + ) diff --git a/server/reflector/pipelines/main_file_pipeline.py b/server/reflector/pipelines/main_file_pipeline.py index aff6e042..b058e5b9 100644 --- a/server/reflector/pipelines/main_file_pipeline.py +++ b/server/reflector/pipelines/main_file_pipeline.py @@ -309,6 +309,7 @@ class PipelineMainFile(PipelineMainBase): transcript, on_long_summary_callback=self.on_long_summary, on_short_summary_callback=self.on_short_summary, + on_action_items_callback=self.on_action_items, empty_pipeline=self.empty_pipeline, logger=self.logger, ) diff --git a/server/reflector/pipelines/main_live_pipeline.py b/server/reflector/pipelines/main_live_pipeline.py index 83e560d6..2b6c6d07 100644 --- a/server/reflector/pipelines/main_live_pipeline.py +++ b/server/reflector/pipelines/main_live_pipeline.py @@ -27,6 +27,7 @@ from reflector.db.recordings import recordings_controller from reflector.db.rooms import rooms_controller from reflector.db.transcripts import ( Transcript, + TranscriptActionItems, TranscriptDuration, TranscriptFinalLongSummary, TranscriptFinalShortSummary, @@ -306,6 +307,23 @@ class PipelineMainBase(PipelineRunner[PipelineMessage], Generic[PipelineMessage] data=final_short_summary, ) + @broadcast_to_sockets + async def on_action_items(self, data): + action_items = TranscriptActionItems(action_items=data.action_items) + async with self.transaction(): + transcript = await self.get_transcript() + await transcripts_controller.update( + transcript, + { + "action_items": action_items.action_items, + }, + ) + return await transcripts_controller.append_event( + transcript=transcript, + event="ACTION_ITEMS", + data=action_items, + ) + @broadcast_to_sockets async def on_duration(self, data): async with self.transaction(): @@ -465,6 +483,7 @@ class PipelineMainFinalSummaries(PipelineMainFromTopics): transcript=self._transcript, callback=self.on_long_summary, on_short_summary=self.on_short_summary, + on_action_items=self.on_action_items, ), ] diff --git a/server/reflector/pipelines/main_multitrack_pipeline.py b/server/reflector/pipelines/main_multitrack_pipeline.py index 579bfbd3..d3b28274 100644 --- a/server/reflector/pipelines/main_multitrack_pipeline.py +++ b/server/reflector/pipelines/main_multitrack_pipeline.py @@ -772,6 +772,7 @@ class PipelineMainMultitrack(PipelineMainBase): transcript, on_long_summary_callback=self.on_long_summary, on_short_summary_callback=self.on_short_summary, + on_action_items_callback=self.on_action_items, empty_pipeline=self.empty_pipeline, logger=self.logger, ) diff --git a/server/reflector/pipelines/topic_processing.py b/server/reflector/pipelines/topic_processing.py index 7f055025..a6189d63 100644 --- a/server/reflector/pipelines/topic_processing.py +++ b/server/reflector/pipelines/topic_processing.py @@ -89,6 +89,7 @@ async def generate_summaries( *, on_long_summary_callback: Callable, on_short_summary_callback: Callable, + on_action_items_callback: Callable, empty_pipeline: EmptyPipeline, logger: structlog.BoundLogger, ): @@ -96,11 +97,14 @@ async def generate_summaries( logger.warning("No topics for summary generation") return - processor = TranscriptFinalSummaryProcessor( - transcript=transcript, - callback=on_long_summary_callback, - on_short_summary=on_short_summary_callback, - ) + processor_kwargs = { + "transcript": transcript, + "callback": on_long_summary_callback, + "on_short_summary": on_short_summary_callback, + "on_action_items": on_action_items_callback, + } + + processor = TranscriptFinalSummaryProcessor(**processor_kwargs) processor.set_pipeline(empty_pipeline) for topic in topics: diff --git a/server/reflector/processors/summary/summary_builder.py b/server/reflector/processors/summary/summary_builder.py index df348093..b9770fc5 100644 --- a/server/reflector/processors/summary/summary_builder.py +++ b/server/reflector/processors/summary/summary_builder.py @@ -96,6 +96,36 @@ RECAP_PROMPT = dedent( """ ).strip() +ACTION_ITEMS_PROMPT = dedent( + """ + Identify action items from this meeting transcript. Your goal is to identify what was decided and what needs to happen next. + + Look for: + + 1. **Decisions Made**: Any decisions, choices, or conclusions reached during the meeting. For each decision: + - What was decided? (be specific) + - Who made the decision or was involved? (use actual participant names) + - Why was this decision made? (key factors, reasoning, or rationale) + + 2. **Next Steps / Action Items**: Any tasks, follow-ups, or actions that were mentioned or assigned. For each action item: + - What specific task needs to be done? (be concrete and actionable) + - Who is responsible? (use actual participant names if mentioned, or "team" if unclear) + - When is it due? (any deadlines, timeframes, or "by next meeting" type commitments) + - What context is needed? (any additional details that help understand the task) + + Guidelines: + - Be thorough and identify all action items, even if they seem minor + - Include items that were agreed upon, assigned, or committed to + - Include decisions even if they seem obvious or implicit + - If someone says "I'll do X" or "We should do Y", that's an action item + - If someone says "Let's go with option A", that's a decision + - Use the exact participant names from the transcript + - If no participant name is mentioned, you can leave assigned_to/decided_by as null + + Only return empty lists if the transcript contains NO decisions and NO action items whatsoever. + """ +).strip() + STRUCTURED_RESPONSE_PROMPT_TEMPLATE = dedent( """ Based on the following analysis, provide the information in the requested JSON format: @@ -155,6 +185,53 @@ class SubjectsResponse(BaseModel): ) +class ActionItem(BaseModel): + """A single action item from the meeting""" + + task: str = Field(description="The task or action item to be completed") + assigned_to: str | None = Field( + default=None, description="Person or team assigned to this task (name)" + ) + assigned_to_participant_id: str | None = Field( + default=None, description="Participant ID if assigned_to matches a participant" + ) + deadline: str | None = Field( + default=None, description="Deadline or timeframe mentioned for this task" + ) + context: str | None = Field( + default=None, description="Additional context or notes about this task" + ) + + +class Decision(BaseModel): + """A decision made during the meeting""" + + decision: str = Field(description="What was decided") + rationale: str | None = Field( + default=None, + description="Reasoning or key factors that influenced this decision", + ) + decided_by: str | None = Field( + default=None, description="Person or group who made the decision (name)" + ) + decided_by_participant_id: str | None = Field( + default=None, description="Participant ID if decided_by matches a participant" + ) + + +class ActionItemsResponse(BaseModel): + """Pydantic model for identified action items""" + + decisions: list[Decision] = Field( + default_factory=list, + description="List of decisions made during the meeting", + ) + next_steps: list[ActionItem] = Field( + default_factory=list, + description="List of action items and next steps to be taken", + ) + + class SummaryBuilder: def __init__(self, llm: LLM, filename: str | None = None, logger=None) -> None: self.transcript: str | None = None @@ -166,6 +243,8 @@ class SummaryBuilder: self.model_name: str = llm.model_name self.logger = logger or structlog.get_logger() self.participant_instructions: str | None = None + self.action_items: ActionItemsResponse | None = None + self.participant_name_to_id: dict[str, str] = {} if filename: self.read_transcript_from_file(filename) @@ -189,13 +268,20 @@ class SummaryBuilder: self.llm = llm async def _get_structured_response( - self, prompt: str, output_cls: Type[T], tone_name: str | None = None + self, + prompt: str, + output_cls: Type[T], + tone_name: str | None = None, + timeout: int | None = None, ) -> T: """Generic function to get structured output from LLM for non-function-calling models.""" - # Add participant instructions to the prompt if available enhanced_prompt = self._enhance_prompt_with_participants(prompt) return await self.llm.get_structured_response( - enhanced_prompt, [self.transcript], output_cls, tone_name=tone_name + enhanced_prompt, + [self.transcript], + output_cls, + tone_name=tone_name, + timeout=timeout, ) async def _get_response( @@ -216,11 +302,19 @@ class SummaryBuilder: # Participants # ---------------------------------------------------------------------------- - def set_known_participants(self, participants: list[str]) -> None: + def set_known_participants( + self, + participants: list[str], + participant_name_to_id: dict[str, str] | None = None, + ) -> None: """ Set known participants directly without LLM identification. This is used when participants are already identified and stored. They are appended at the end of the transcript, providing more context for the assistant. + + Args: + participants: List of participant names + participant_name_to_id: Optional mapping of participant names to their IDs """ if not participants: self.logger.warning("No participants provided") @@ -231,10 +325,12 @@ class SummaryBuilder: participants=participants, ) + if participant_name_to_id: + self.participant_name_to_id = participant_name_to_id + participants_md = self.format_list_md(participants) self.transcript += f"\n\n# Participants\n\n{participants_md}" - # Set instructions that will be automatically added to all prompts participants_list = ", ".join(participants) self.participant_instructions = dedent( f""" @@ -413,6 +509,92 @@ class SummaryBuilder: self.recap = str(recap_response) self.logger.info(f"Quick recap: {self.recap}") + def _map_participant_names_to_ids( + self, response: ActionItemsResponse + ) -> ActionItemsResponse: + """Map participant names in action items to participant IDs.""" + if not self.participant_name_to_id: + return response + + decisions = [] + for decision in response.decisions: + new_decision = decision.model_copy() + if ( + decision.decided_by + and decision.decided_by in self.participant_name_to_id + ): + new_decision.decided_by_participant_id = self.participant_name_to_id[ + decision.decided_by + ] + decisions.append(new_decision) + + next_steps = [] + for item in response.next_steps: + new_item = item.model_copy() + if item.assigned_to and item.assigned_to in self.participant_name_to_id: + new_item.assigned_to_participant_id = self.participant_name_to_id[ + item.assigned_to + ] + next_steps.append(new_item) + + return ActionItemsResponse(decisions=decisions, next_steps=next_steps) + + async def identify_action_items(self) -> ActionItemsResponse | None: + """Identify action items (decisions and next steps) from the transcript.""" + self.logger.info("--- identify action items using TreeSummarize") + + if not self.transcript: + self.logger.warning( + "No transcript available for action items identification" + ) + self.action_items = None + return None + + action_items_prompt = ACTION_ITEMS_PROMPT + + try: + response = await self._get_structured_response( + action_items_prompt, + ActionItemsResponse, + tone_name="Action item identifier", + timeout=settings.LLM_STRUCTURED_RESPONSE_TIMEOUT, + ) + + response = self._map_participant_names_to_ids(response) + + self.action_items = response + self.logger.info( + f"Identified {len(response.decisions)} decisions and {len(response.next_steps)} action items", + decisions_count=len(response.decisions), + next_steps_count=len(response.next_steps), + ) + + if response.decisions: + self.logger.debug( + "Decisions identified", + decisions=[d.decision for d in response.decisions], + ) + if response.next_steps: + self.logger.debug( + "Action items identified", + tasks=[item.task for item in response.next_steps], + ) + if not response.decisions and not response.next_steps: + self.logger.warning( + "No action items identified from transcript", + transcript_length=len(self.transcript), + ) + + return response + + except Exception as e: + self.logger.error( + f"Error identifying action items: {e}", + exc_info=True, + ) + self.action_items = None + return None + async def generate_summary(self, only_subjects: bool = False) -> None: """ Generate summary by extracting subjects, creating summaries for each, and generating a recap. @@ -424,6 +606,7 @@ class SummaryBuilder: await self.generate_subject_summaries() await self.generate_recap() + await self.identify_action_items() # ---------------------------------------------------------------------------- # Markdown @@ -526,8 +709,6 @@ if __name__ == "__main__": if args.summary: await sm.generate_summary() - # Note: action items generation has been removed - print("") print("-" * 80) print("") diff --git a/server/reflector/processors/transcript_final_summary.py b/server/reflector/processors/transcript_final_summary.py index dfe07aad..932a46be 100644 --- a/server/reflector/processors/transcript_final_summary.py +++ b/server/reflector/processors/transcript_final_summary.py @@ -1,7 +1,12 @@ from reflector.llm import LLM from reflector.processors.base import Processor from reflector.processors.summary.summary_builder import SummaryBuilder -from reflector.processors.types import FinalLongSummary, FinalShortSummary, TitleSummary +from reflector.processors.types import ( + ActionItems, + FinalLongSummary, + FinalShortSummary, + TitleSummary, +) from reflector.settings import settings @@ -27,15 +32,20 @@ class TranscriptFinalSummaryProcessor(Processor): builder = SummaryBuilder(self.llm, logger=self.logger) builder.set_transcript(text) - # Use known participants if available, otherwise identify them if self.transcript and self.transcript.participants: - # Extract participant names from the stored participants participant_names = [p.name for p in self.transcript.participants if p.name] if participant_names: self.logger.info( f"Using {len(participant_names)} known participants from transcript" ) - builder.set_known_participants(participant_names) + participant_name_to_id = { + p.name: p.id + for p in self.transcript.participants + if p.name and p.id + } + builder.set_known_participants( + participant_names, participant_name_to_id=participant_name_to_id + ) else: self.logger.info( "Participants field exists but is empty, identifying participants" @@ -63,7 +73,6 @@ class TranscriptFinalSummaryProcessor(Processor): self.logger.warning("No summary to output") return - # build the speakermap from the transcript speakermap = {} if self.transcript: speakermap = { @@ -76,8 +85,6 @@ class TranscriptFinalSummaryProcessor(Processor): speakermap=speakermap, ) - # build the transcript as a single string - # Replace speaker IDs with actual participant names if available text_transcript = [] unique_speakers = set() for topic in self.chunks: @@ -111,4 +118,9 @@ class TranscriptFinalSummaryProcessor(Processor): ) await self.emit(final_short_summary, name="short_summary") + if self.builder and self.builder.action_items: + action_items = self.builder.action_items.model_dump() + action_items = ActionItems(action_items=action_items) + await self.emit(action_items, name="action_items") + await self.emit(final_long_summary) diff --git a/server/reflector/processors/transcript_topic_detector.py b/server/reflector/processors/transcript_topic_detector.py index 695d3af3..154db0ec 100644 --- a/server/reflector/processors/transcript_topic_detector.py +++ b/server/reflector/processors/transcript_topic_detector.py @@ -78,7 +78,11 @@ class TranscriptTopicDetectorProcessor(Processor): """ prompt = TOPIC_PROMPT.format(text=text) response = await self.llm.get_structured_response( - prompt, [text], TopicResponse, tone_name="Topic analyzer" + prompt, + [text], + TopicResponse, + tone_name="Topic analyzer", + timeout=settings.LLM_STRUCTURED_RESPONSE_TIMEOUT, ) return response diff --git a/server/reflector/processors/types.py b/server/reflector/processors/types.py index 3369e09c..ca6d675f 100644 --- a/server/reflector/processors/types.py +++ b/server/reflector/processors/types.py @@ -264,6 +264,10 @@ class FinalShortSummary(BaseModel): duration: float +class ActionItems(BaseModel): + action_items: dict # JSON-serializable dict from ActionItemsResponse + + class FinalTitle(BaseModel): title: str diff --git a/server/reflector/settings.py b/server/reflector/settings.py index 910dd26c..da407154 100644 --- a/server/reflector/settings.py +++ b/server/reflector/settings.py @@ -79,6 +79,9 @@ class Settings(BaseSettings): LLM_PARSE_MAX_RETRIES: int = ( 3 # Max retries for JSON/validation errors (total attempts = retries + 1) ) + LLM_STRUCTURED_RESPONSE_TIMEOUT: int = ( + 300 # Timeout in seconds for structured responses (5 minutes) + ) # Diarization # backends: diff --git a/server/reflector/views/transcripts.py b/server/reflector/views/transcripts.py index 27663dc6..2e1c9d30 100644 --- a/server/reflector/views/transcripts.py +++ b/server/reflector/views/transcripts.py @@ -17,6 +17,7 @@ from pydantic import ( import reflector.auth as auth from reflector.db import get_database from reflector.db.recordings import recordings_controller +from reflector.db.rooms import rooms_controller from reflector.db.search import ( DEFAULT_SEARCH_LIMIT, SearchLimit, @@ -473,6 +474,11 @@ async def transcript_get( is_multitrack = await _get_is_multitrack(transcript) + room_name = None + if transcript.room_id: + room = await rooms_controller.get_by_id(transcript.room_id) + room_name = room.name if room else None + participants = [] if transcript.participants: user_ids = [p.user_id for p in transcript.participants if p.user_id is not None] @@ -495,6 +501,7 @@ async def transcript_get( "title": transcript.title, "short_summary": transcript.short_summary, "long_summary": transcript.long_summary, + "action_items": transcript.action_items, "created_at": transcript.created_at, "share_mode": transcript.share_mode, "source_language": transcript.source_language, @@ -503,6 +510,7 @@ async def transcript_get( "meeting_id": transcript.meeting_id, "source_kind": transcript.source_kind, "room_id": transcript.room_id, + "room_name": room_name, "audio_deleted": transcript.audio_deleted, "participants": participants, } diff --git a/server/reflector/worker/app.py b/server/reflector/worker/app.py index c0e711ae..b1256c94 100644 --- a/server/reflector/worker/app.py +++ b/server/reflector/worker/app.py @@ -38,6 +38,10 @@ else: "task": "reflector.worker.process.reprocess_failed_recordings", "schedule": crontab(hour=5, minute=0), # Midnight EST }, + "reprocess_failed_daily_recordings": { + "task": "reflector.worker.process.reprocess_failed_daily_recordings", + "schedule": crontab(hour=5, minute=0), # Midnight EST + }, "poll_daily_recordings": { "task": "reflector.worker.process.poll_daily_recordings", "schedule": 180.0, # Every 3 minutes (configurable lookback window) diff --git a/server/reflector/worker/process.py b/server/reflector/worker/process.py index 21e73723..ab163fad 100644 --- a/server/reflector/worker/process.py +++ b/server/reflector/worker/process.py @@ -12,7 +12,7 @@ from celery import shared_task from celery.utils.log import get_task_logger from pydantic import ValidationError -from reflector.dailyco_api import RecordingResponse +from reflector.dailyco_api import FinishedRecordingResponse, RecordingResponse from reflector.db.daily_participant_sessions import ( DailyParticipantSession, daily_participant_sessions_controller, @@ -322,16 +322,38 @@ async def poll_daily_recordings(): ) return - recording_ids = [rec.id for rec in api_recordings] + finished_recordings: List[FinishedRecordingResponse] = [] + for rec in api_recordings: + finished = rec.to_finished() + if finished is None: + logger.debug( + "Skipping unfinished recording", + recording_id=rec.id, + room_name=rec.room_name, + status=rec.status, + ) + continue + finished_recordings.append(finished) + + if not finished_recordings: + logger.debug( + "No finished recordings found from Daily.co API", + total_api_count=len(api_recordings), + ) + return + + recording_ids = [rec.id for rec in finished_recordings] existing_recordings = await recordings_controller.get_by_ids(recording_ids) existing_ids = {rec.id for rec in existing_recordings} - missing_recordings = [rec for rec in api_recordings if rec.id not in existing_ids] + missing_recordings = [ + rec for rec in finished_recordings if rec.id not in existing_ids + ] if not missing_recordings: logger.debug( "All recordings already in DB", - api_count=len(api_recordings), + api_count=len(finished_recordings), existing_count=len(existing_recordings), ) return @@ -339,7 +361,7 @@ async def poll_daily_recordings(): logger.info( "Found recordings missing from DB", missing_count=len(missing_recordings), - total_api_count=len(api_recordings), + total_api_count=len(finished_recordings), existing_count=len(existing_recordings), ) @@ -649,7 +671,7 @@ async def reprocess_failed_recordings(): Find recordings in Whereby S3 bucket and check if they have proper transcriptions. If not, requeue them for processing. - Note: Daily.co recordings are processed via webhooks, not this cron job. + Note: Daily.co multitrack recordings are handled by reprocess_failed_daily_recordings. """ logger.info("Checking Whereby recordings that need processing or reprocessing") @@ -702,6 +724,103 @@ async def reprocess_failed_recordings(): return reprocessed_count +@shared_task +@asynctask +async def reprocess_failed_daily_recordings(): + """ + Find Daily.co multitrack recordings in the database and check if they have proper transcriptions. + If not, requeue them for processing. + """ + logger.info( + "Checking Daily.co multitrack recordings that need processing or reprocessing" + ) + + if not settings.DAILYCO_STORAGE_AWS_BUCKET_NAME: + logger.debug( + "DAILYCO_STORAGE_AWS_BUCKET_NAME not configured; skipping Daily recording reprocessing" + ) + return 0 + + bucket_name = settings.DAILYCO_STORAGE_AWS_BUCKET_NAME + reprocessed_count = 0 + + try: + multitrack_recordings = ( + await recordings_controller.get_multitrack_needing_reprocessing(bucket_name) + ) + + logger.info( + "Found multitrack recordings needing reprocessing", + count=len(multitrack_recordings), + bucket=bucket_name, + ) + + for recording in multitrack_recordings: + if not recording.meeting_id: + logger.debug( + "Skipping recording without meeting_id", + recording_id=recording.id, + ) + continue + + meeting = await meetings_controller.get_by_id(recording.meeting_id) + if not meeting: + logger.warning( + "Meeting not found for recording", + recording_id=recording.id, + meeting_id=recording.meeting_id, + ) + continue + + transcript = None + try: + transcript = await transcripts_controller.get_by_recording_id( + recording.id + ) + except ValidationError: + await transcripts_controller.remove_by_recording_id(recording.id) + logger.warning( + "Removed invalid transcript for recording", + recording_id=recording.id, + ) + + if not recording.track_keys: + logger.warning( + "Recording has no track_keys, cannot reprocess", + recording_id=recording.id, + ) + continue + + logger.info( + "Queueing Daily recording for reprocessing", + recording_id=recording.id, + room_name=meeting.room_name, + track_count=len(recording.track_keys), + transcript_status=transcript.status if transcript else None, + ) + + process_multitrack_recording.delay( + bucket_name=bucket_name, + daily_room_name=meeting.room_name, + recording_id=recording.id, + track_keys=recording.track_keys, + ) + reprocessed_count += 1 + + except Exception as e: + logger.error( + "Error checking Daily multitrack recordings", + error=str(e), + exc_info=True, + ) + + logger.info( + "Daily reprocessing complete", + requeued_count=reprocessed_count, + ) + return reprocessed_count + + @shared_task @asynctask async def trigger_daily_reconciliation() -> None: diff --git a/server/reflector/worker/webhook.py b/server/reflector/worker/webhook.py index 57b294d8..d58cc7b3 100644 --- a/server/reflector/worker/webhook.py +++ b/server/reflector/worker/webhook.py @@ -123,6 +123,7 @@ async def send_transcript_webhook( "target_language": transcript.target_language, "status": transcript.status, "frontend_url": frontend_url, + "action_items": transcript.action_items, }, "room": { "id": room.id, diff --git a/server/tests/test_llm_retry.py b/server/tests/test_llm_retry.py index f9fe28b4..5a43c8c5 100644 --- a/server/tests/test_llm_retry.py +++ b/server/tests/test_llm_retry.py @@ -1,12 +1,14 @@ """Tests for LLM parse error recovery using llama-index Workflow""" +from time import monotonic from unittest.mock import AsyncMock, MagicMock, patch import pytest from pydantic import BaseModel, Field -from workflows.errors import WorkflowRuntimeError +from workflows.errors import WorkflowRuntimeError, WorkflowTimeoutError from reflector.llm import LLM, LLMParseError, StructuredOutputWorkflow +from reflector.utils.retry import RetryException class TestResponse(BaseModel): @@ -355,3 +357,132 @@ class TestNetworkErrorRetries: # Only called once - Workflow doesn't retry network errors assert mock_settings.llm.acomplete.call_count == 1 + + +class TestWorkflowTimeoutRetry: + """Test timeout retry mechanism in get_structured_response""" + + @pytest.mark.asyncio + async def test_timeout_retry_succeeds_on_retry(self, test_settings): + """Test that WorkflowTimeoutError triggers retry and succeeds""" + llm = LLM(settings=test_settings, temperature=0.4, max_tokens=100) + + call_count = {"count": 0} + + async def workflow_run_side_effect(*args, **kwargs): + call_count["count"] += 1 + if call_count["count"] == 1: + raise WorkflowTimeoutError("Operation timed out after 120 seconds") + return { + "success": TestResponse( + title="Test", summary="Summary", confidence=0.95 + ) + } + + with ( + patch("reflector.llm.StructuredOutputWorkflow") as mock_workflow_class, + patch("reflector.llm.TreeSummarize") as mock_summarize, + patch("reflector.llm.Settings") as mock_settings, + ): + mock_workflow = MagicMock() + mock_workflow.run = AsyncMock(side_effect=workflow_run_side_effect) + mock_workflow_class.return_value = mock_workflow + + mock_summarizer = MagicMock() + mock_summarize.return_value = mock_summarizer + mock_summarizer.aget_response = AsyncMock(return_value="Some analysis") + mock_settings.llm.acomplete = AsyncMock( + return_value=make_completion_response( + '{"title": "Test", "summary": "Summary", "confidence": 0.95}' + ) + ) + + result = await llm.get_structured_response( + prompt="Test prompt", texts=["Test text"], output_cls=TestResponse + ) + + assert result.title == "Test" + assert result.summary == "Summary" + assert call_count["count"] == 2 + + @pytest.mark.asyncio + async def test_timeout_retry_exhausts_after_max_attempts(self, test_settings): + """Test that timeout retry stops after max attempts""" + llm = LLM(settings=test_settings, temperature=0.4, max_tokens=100) + + call_count = {"count": 0} + + async def workflow_run_side_effect(*args, **kwargs): + call_count["count"] += 1 + raise WorkflowTimeoutError("Operation timed out after 120 seconds") + + with ( + patch("reflector.llm.StructuredOutputWorkflow") as mock_workflow_class, + patch("reflector.llm.TreeSummarize") as mock_summarize, + patch("reflector.llm.Settings") as mock_settings, + ): + mock_workflow = MagicMock() + mock_workflow.run = AsyncMock(side_effect=workflow_run_side_effect) + mock_workflow_class.return_value = mock_workflow + + mock_summarizer = MagicMock() + mock_summarize.return_value = mock_summarizer + mock_summarizer.aget_response = AsyncMock(return_value="Some analysis") + mock_settings.llm.acomplete = AsyncMock( + return_value=make_completion_response( + '{"title": "Test", "summary": "Summary", "confidence": 0.95}' + ) + ) + + with pytest.raises(RetryException, match="Retry attempts exceeded"): + await llm.get_structured_response( + prompt="Test prompt", texts=["Test text"], output_cls=TestResponse + ) + + assert call_count["count"] == 3 + + @pytest.mark.asyncio + async def test_timeout_retry_with_backoff(self, test_settings): + """Test that exponential backoff is applied between retries""" + llm = LLM(settings=test_settings, temperature=0.4, max_tokens=100) + + call_times = [] + + async def workflow_run_side_effect(*args, **kwargs): + call_times.append(monotonic()) + if len(call_times) < 3: + raise WorkflowTimeoutError("Operation timed out after 120 seconds") + return { + "success": TestResponse( + title="Test", summary="Summary", confidence=0.95 + ) + } + + with ( + patch("reflector.llm.StructuredOutputWorkflow") as mock_workflow_class, + patch("reflector.llm.TreeSummarize") as mock_summarize, + patch("reflector.llm.Settings") as mock_settings, + ): + mock_workflow = MagicMock() + mock_workflow.run = AsyncMock(side_effect=workflow_run_side_effect) + mock_workflow_class.return_value = mock_workflow + + mock_summarizer = MagicMock() + mock_summarize.return_value = mock_summarizer + mock_summarizer.aget_response = AsyncMock(return_value="Some analysis") + mock_settings.llm.acomplete = AsyncMock( + return_value=make_completion_response( + '{"title": "Test", "summary": "Summary", "confidence": 0.95}' + ) + ) + + result = await llm.get_structured_response( + prompt="Test prompt", texts=["Test text"], output_cls=TestResponse + ) + + assert result.title == "Test" + if len(call_times) >= 2: + time_between_calls = call_times[1] - call_times[0] + assert ( + time_between_calls >= 1.5 + ), f"Expected ~2s backoff, got {time_between_calls}s" diff --git a/server/tests/test_pipeline_main_file.py b/server/tests/test_pipeline_main_file.py index 825c8389..8a4d63dd 100644 --- a/server/tests/test_pipeline_main_file.py +++ b/server/tests/test_pipeline_main_file.py @@ -266,7 +266,11 @@ async def mock_summary_processor(): # When flush is called, simulate summary generation by calling the callbacks async def flush_with_callback(): mock_summary.flush_called = True - from reflector.processors.types import FinalLongSummary, FinalShortSummary + from reflector.processors.types import ( + ActionItems, + FinalLongSummary, + FinalShortSummary, + ) if hasattr(mock_summary, "_callback"): await mock_summary._callback( @@ -276,12 +280,19 @@ async def mock_summary_processor(): await mock_summary._on_short_summary( FinalShortSummary(short_summary="Test short summary", duration=10.0) ) + if hasattr(mock_summary, "_on_action_items"): + await mock_summary._on_action_items( + ActionItems(action_items={"test": "action item"}) + ) mock_summary.flush = flush_with_callback - def init_with_callback(transcript=None, callback=None, on_short_summary=None): + def init_with_callback( + transcript=None, callback=None, on_short_summary=None, on_action_items=None + ): mock_summary._callback = callback mock_summary._on_short_summary = on_short_summary + mock_summary._on_action_items = on_action_items return mock_summary mock_summary_class.side_effect = init_with_callback diff --git a/server/tests/test_transcripts.py b/server/tests/test_transcripts.py index 2c6acc77..9a3ff576 100644 --- a/server/tests/test_transcripts.py +++ b/server/tests/test_transcripts.py @@ -1,5 +1,8 @@ import pytest +from reflector.db.rooms import rooms_controller +from reflector.db.transcripts import transcripts_controller + @pytest.mark.asyncio async def test_transcript_create(client): @@ -182,3 +185,51 @@ async def test_transcript_mark_reviewed(authenticated_client, client): response = await client.get(f"/transcripts/{tid}") assert response.status_code == 200 assert response.json()["reviewed"] is True + + +@pytest.mark.asyncio +async def test_transcript_get_returns_room_name(authenticated_client, client): + """Test that getting a transcript returns its room_name when linked to a room.""" + # Create a room + room = await rooms_controller.add( + name="test-room-for-transcript", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + webhook_url="", + webhook_secret="", + ) + + # Create a transcript linked to the room + transcript = await transcripts_controller.add( + name="transcript-with-room", + source_kind="file", + room_id=room.id, + ) + + # Get the transcript and verify room_name is returned + response = await client.get(f"/transcripts/{transcript.id}") + assert response.status_code == 200 + assert response.json()["room_id"] == room.id + assert response.json()["room_name"] == "test-room-for-transcript" + + +@pytest.mark.asyncio +async def test_transcript_get_returns_null_room_name_when_no_room( + authenticated_client, client +): + """Test that room_name is null when transcript has no room.""" + response = await client.post("/transcripts", json={"name": "no-room-transcript"}) + assert response.status_code == 200 + tid = response.json()["id"] + + response = await client.get(f"/transcripts/{tid}") + assert response.status_code == 200 + assert response.json()["room_id"] is None + assert response.json()["room_name"] is None diff --git a/www/app/(auth)/userInfo.tsx b/www/app/(auth)/userInfo.tsx index bf6a5b62..f9725a13 100644 --- a/www/app/(auth)/userInfo.tsx +++ b/www/app/(auth)/userInfo.tsx @@ -13,9 +13,12 @@ export default function UserInfo() { ) : !isAuthenticated && !isRefreshing ? ( auth.signIn("authentik")} + onClick={(e) => { + e.preventDefault(); + auth.signIn("authentik"); + }} > Log in diff --git a/www/app/[roomName]/components/DailyRoom.tsx b/www/app/[roomName]/components/DailyRoom.tsx index 45f2dad2..db051cc6 100644 --- a/www/app/[roomName]/components/DailyRoom.tsx +++ b/www/app/[roomName]/components/DailyRoom.tsx @@ -105,7 +105,19 @@ export default function DailyRoom({ meeting }: DailyRoomProps) { } }); - await frame.join({ url: roomUrl }); + await frame.join({ + url: roomUrl, + sendSettings: { + video: { + // Optimize bandwidth for camera video + // allowAdaptiveLayers automatically adjusts quality based on network conditions + allowAdaptiveLayers: true, + // Use bandwidth-optimized preset as fallback for browsers without adaptive support + maxQuality: "medium", + }, + // Note: screenVideo intentionally not configured to preserve full quality for screen shares + }, + }); } catch (error) { console.error("Error creating Daily frame:", error); } diff --git a/www/package.json b/www/package.json index 63e68014..13895a3a 100644 --- a/www/package.json +++ b/www/package.json @@ -31,7 +31,7 @@ "ioredis": "^5.7.0", "jest-worker": "^29.6.2", "lucide-react": "^0.525.0", - "next": "^15.5.7", + "next": "^15.5.9", "next-auth": "^4.24.7", "next-themes": "^0.4.6", "nuqs": "^2.4.3", diff --git a/www/pnpm-lock.yaml b/www/pnpm-lock.yaml index f33f0b6c..4cc219ea 100644 --- a/www/pnpm-lock.yaml +++ b/www/pnpm-lock.yaml @@ -27,7 +27,7 @@ importers: version: 0.2.3(@fortawesome/fontawesome-svg-core@6.7.2)(react@18.3.1) "@sentry/nextjs": specifier: ^10.11.0 - version: 10.11.0(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(next@15.5.7(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1)(webpack@5.101.3) + version: 10.11.0(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(next@15.5.9(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1)(webpack@5.101.3) "@tanstack/react-query": specifier: ^5.85.9 version: 5.85.9(react@18.3.1) @@ -62,17 +62,17 @@ importers: specifier: ^0.525.0 version: 0.525.0(react@18.3.1) next: - specifier: ^15.5.7 - version: 15.5.7(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0) + specifier: ^15.5.9 + version: 15.5.9(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0) next-auth: specifier: ^4.24.7 - version: 4.24.11(next@15.5.7(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 4.24.11(next@15.5.9(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) nuqs: specifier: ^2.4.3 - version: 2.4.3(next@15.5.7(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1) + version: 2.4.3(next@15.5.9(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1) openapi-fetch: specifier: ^0.14.0 version: 0.14.0 @@ -509,6 +509,12 @@ packages: integrity: sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==, } + "@emnapi/runtime@1.7.1": + resolution: + { + integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==, + } + "@emnapi/wasi-threads@1.0.4": resolution: { @@ -758,189 +764,213 @@ packages: } engines: { node: ">=18.18" } - "@img/sharp-darwin-arm64@0.34.3": + "@img/colour@1.0.0": resolution: { - integrity: sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg==, + integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==, + } + engines: { node: ">=18" } + + "@img/sharp-darwin-arm64@0.34.5": + resolution: + { + integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==, } engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } cpu: [arm64] os: [darwin] - "@img/sharp-darwin-x64@0.34.3": + "@img/sharp-darwin-x64@0.34.5": resolution: { - integrity: sha512-yHpJYynROAj12TA6qil58hmPmAwxKKC7reUqtGLzsOHfP7/rniNGTL8tjWX6L3CTV4+5P4ypcS7Pp+7OB+8ihA==, + integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==, } engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } cpu: [x64] os: [darwin] - "@img/sharp-libvips-darwin-arm64@1.2.0": + "@img/sharp-libvips-darwin-arm64@1.2.4": resolution: { - integrity: sha512-sBZmpwmxqwlqG9ueWFXtockhsxefaV6O84BMOrhtg/YqbTaRdqDE7hxraVE3y6gVM4eExmfzW4a8el9ArLeEiQ==, + integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==, } cpu: [arm64] os: [darwin] - "@img/sharp-libvips-darwin-x64@1.2.0": + "@img/sharp-libvips-darwin-x64@1.2.4": resolution: { - integrity: sha512-M64XVuL94OgiNHa5/m2YvEQI5q2cl9d/wk0qFTDVXcYzi43lxuiFTftMR1tOnFQovVXNZJ5TURSDK2pNe9Yzqg==, + integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==, } cpu: [x64] os: [darwin] - "@img/sharp-libvips-linux-arm64@1.2.0": + "@img/sharp-libvips-linux-arm64@1.2.4": resolution: { - integrity: sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==, + integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==, } cpu: [arm64] os: [linux] - "@img/sharp-libvips-linux-arm@1.2.0": + "@img/sharp-libvips-linux-arm@1.2.4": resolution: { - integrity: sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==, + integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==, } cpu: [arm] os: [linux] - "@img/sharp-libvips-linux-ppc64@1.2.0": + "@img/sharp-libvips-linux-ppc64@1.2.4": resolution: { - integrity: sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==, + integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==, } cpu: [ppc64] os: [linux] - "@img/sharp-libvips-linux-s390x@1.2.0": + "@img/sharp-libvips-linux-riscv64@1.2.4": resolution: { - integrity: sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==, + integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==, + } + cpu: [riscv64] + os: [linux] + + "@img/sharp-libvips-linux-s390x@1.2.4": + resolution: + { + integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==, } cpu: [s390x] os: [linux] - "@img/sharp-libvips-linux-x64@1.2.0": + "@img/sharp-libvips-linux-x64@1.2.4": resolution: { - integrity: sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==, + integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==, } cpu: [x64] os: [linux] - "@img/sharp-libvips-linuxmusl-arm64@1.2.0": + "@img/sharp-libvips-linuxmusl-arm64@1.2.4": resolution: { - integrity: sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==, + integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==, } cpu: [arm64] os: [linux] - "@img/sharp-libvips-linuxmusl-x64@1.2.0": + "@img/sharp-libvips-linuxmusl-x64@1.2.4": resolution: { - integrity: sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==, + integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==, } cpu: [x64] os: [linux] - "@img/sharp-linux-arm64@0.34.3": + "@img/sharp-linux-arm64@0.34.5": resolution: { - integrity: sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==, + integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==, } engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } cpu: [arm64] os: [linux] - "@img/sharp-linux-arm@0.34.3": + "@img/sharp-linux-arm@0.34.5": resolution: { - integrity: sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==, + integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==, } engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } cpu: [arm] os: [linux] - "@img/sharp-linux-ppc64@0.34.3": + "@img/sharp-linux-ppc64@0.34.5": resolution: { - integrity: sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==, + integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==, } engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } cpu: [ppc64] os: [linux] - "@img/sharp-linux-s390x@0.34.3": + "@img/sharp-linux-riscv64@0.34.5": resolution: { - integrity: sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==, + integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [riscv64] + os: [linux] + + "@img/sharp-linux-s390x@0.34.5": + resolution: + { + integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==, } engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } cpu: [s390x] os: [linux] - "@img/sharp-linux-x64@0.34.3": + "@img/sharp-linux-x64@0.34.5": resolution: { - integrity: sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==, + integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==, } engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } cpu: [x64] os: [linux] - "@img/sharp-linuxmusl-arm64@0.34.3": + "@img/sharp-linuxmusl-arm64@0.34.5": resolution: { - integrity: sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==, + integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==, } engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } cpu: [arm64] os: [linux] - "@img/sharp-linuxmusl-x64@0.34.3": + "@img/sharp-linuxmusl-x64@0.34.5": resolution: { - integrity: sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==, + integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==, } engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } cpu: [x64] os: [linux] - "@img/sharp-wasm32@0.34.3": + "@img/sharp-wasm32@0.34.5": resolution: { - integrity: sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg==, + integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==, } engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } cpu: [wasm32] - "@img/sharp-win32-arm64@0.34.3": + "@img/sharp-win32-arm64@0.34.5": resolution: { - integrity: sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ==, + integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==, } engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } cpu: [arm64] os: [win32] - "@img/sharp-win32-ia32@0.34.3": + "@img/sharp-win32-ia32@0.34.5": resolution: { - integrity: sha512-xuCdhH44WxuXgOM714hn4amodJMZl3OEvf0GVTm0BEyMeA2to+8HEdRPShH0SLYptJY1uBw+SCFP9WVQi1Q/cw==, + integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==, } engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } cpu: [ia32] os: [win32] - "@img/sharp-win32-x64@0.34.3": + "@img/sharp-win32-x64@0.34.5": resolution: { - integrity: sha512-OWwz05d++TxzLEv4VnsTz5CmZ6mI6S05sfQGEMrNrQcOEERbX46332IvE7pO/EUiw7jUrrS40z/M7kPyjfl04g==, + integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==, } engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } cpu: [x64] @@ -1184,10 +1214,10 @@ packages: integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==, } - "@next/env@15.5.7": + "@next/env@15.5.9": resolution: { - integrity: sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==, + integrity: sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==, } "@next/eslint-plugin-next@15.5.3": @@ -2610,10 +2640,10 @@ packages: peerDependencies: react: ^18 || ^19 - "@tsconfig/node10@1.0.11": + "@tsconfig/node10@1.0.12": resolution: { - integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==, + integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==, } "@tsconfig/node12@1.0.11": @@ -2785,10 +2815,10 @@ packages: integrity: sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==, } - "@types/node@24.3.1": + "@types/node@25.0.2": resolution: { - integrity: sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==, + integrity: sha512-gWEkeiyYE4vqjON/+Obqcoeffmk0NF15WSBwSs7zwVA2bAbTaE0SJ7P0WNGoJn8uE7fiaV5a7dKYIJriEqOrmA==, } "@types/parse-json@4.0.2": @@ -4202,6 +4232,12 @@ packages: integrity: sha512-uhE1Ye5vgqju6OI71HTQqcBCZrvHugk0MjLak7Q+HfoBgoq5Bi+5YnwjP4fjDgrtYr/l8MVRBvzz9dPD4KyK0A==, } + caniuse-lite@1.0.30001760: + resolution: + { + integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==, + } + ccount@2.0.1: resolution: { @@ -4371,19 +4407,6 @@ packages: integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==, } - color-string@1.9.1: - resolution: - { - integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==, - } - - color@4.2.3: - resolution: - { - integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==, - } - engines: { node: ">=12.5.0" } - colorette@1.4.0: resolution: { @@ -4622,10 +4645,10 @@ packages: engines: { node: ">=0.10" } hasBin: true - detect-libc@2.0.4: + detect-libc@2.1.2: resolution: { - integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==, + integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==, } engines: { node: ">=8" } @@ -4750,10 +4773,10 @@ packages: } engines: { node: ">=10.0.0" } - enhanced-resolve@5.18.3: + enhanced-resolve@5.18.4: resolution: { - integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==, + integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==, } engines: { node: ">=10.13.0" } @@ -5711,12 +5734,6 @@ packages: integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==, } - is-arrayish@0.3.2: - resolution: - { - integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==, - } - is-async-function@2.1.1: resolution: { @@ -6392,10 +6409,10 @@ packages: integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==, } - loader-runner@4.3.0: + loader-runner@4.3.1: resolution: { - integrity: sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==, + integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==, } engines: { node: ">=6.11.5" } @@ -6863,10 +6880,10 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@15.5.7: + next@15.5.9: resolution: { - integrity: sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==, + integrity: sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==, } engines: { node: ^18.18.0 || ^19.8.0 || >= 20.0.0 } hasBin: true @@ -7870,10 +7887,10 @@ packages: integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==, } - schema-utils@4.3.2: + schema-utils@4.3.3: resolution: { - integrity: sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==, + integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==, } engines: { node: ">= 10.13.0" } @@ -7905,6 +7922,14 @@ packages: engines: { node: ">=10" } hasBin: true + semver@7.7.3: + resolution: + { + integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==, + } + engines: { node: ">=10" } + hasBin: true + serialize-javascript@6.0.2: resolution: { @@ -7932,10 +7957,10 @@ packages: } engines: { node: ">= 0.4" } - sharp@0.34.3: + sharp@0.34.5: resolution: { - integrity: sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==, + integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==, } engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } @@ -8006,12 +8031,6 @@ packages: integrity: sha512-D1SaWpOW8afq1CZGWB8xTfrT3FekjQmPValrqncJMX7QFl8YwhrPTZvMCANLtgBwwdS+7zURyqxDDEmY558tTw==, } - simple-swizzle@0.2.2: - resolution: - { - integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==, - } - slash@3.0.0: resolution: { @@ -8325,17 +8344,17 @@ packages: engines: { node: ">=14.0.0" } hasBin: true - tapable@2.2.3: + tapable@2.3.0: resolution: { - integrity: sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==, + integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==, } engines: { node: ">=6" } - terser-webpack-plugin@5.3.14: + terser-webpack-plugin@5.3.16: resolution: { - integrity: sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==, + integrity: sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==, } engines: { node: ">= 10.13.0" } peerDependencies: @@ -8351,10 +8370,10 @@ packages: uglify-js: optional: true - terser@5.44.0: + terser@5.44.1: resolution: { - integrity: sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==, + integrity: sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==, } engines: { node: ">=10" } hasBin: true @@ -8633,6 +8652,12 @@ packages: integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==, } + undici-types@7.16.0: + resolution: + { + integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==, + } + unified@11.0.5: resolution: { @@ -9365,6 +9390,11 @@ snapshots: tslib: 2.8.1 optional: true + "@emnapi/runtime@1.7.1": + dependencies: + tslib: 2.8.1 + optional: true + "@emnapi/wasi-threads@1.0.4": dependencies: tslib: 2.8.1 @@ -9533,90 +9563,101 @@ snapshots: "@humanwhocodes/retry@0.4.3": {} - "@img/sharp-darwin-arm64@0.34.3": + "@img/colour@1.0.0": + optional: true + + "@img/sharp-darwin-arm64@0.34.5": optionalDependencies: - "@img/sharp-libvips-darwin-arm64": 1.2.0 + "@img/sharp-libvips-darwin-arm64": 1.2.4 optional: true - "@img/sharp-darwin-x64@0.34.3": + "@img/sharp-darwin-x64@0.34.5": optionalDependencies: - "@img/sharp-libvips-darwin-x64": 1.2.0 + "@img/sharp-libvips-darwin-x64": 1.2.4 optional: true - "@img/sharp-libvips-darwin-arm64@1.2.0": + "@img/sharp-libvips-darwin-arm64@1.2.4": optional: true - "@img/sharp-libvips-darwin-x64@1.2.0": + "@img/sharp-libvips-darwin-x64@1.2.4": optional: true - "@img/sharp-libvips-linux-arm64@1.2.0": + "@img/sharp-libvips-linux-arm64@1.2.4": optional: true - "@img/sharp-libvips-linux-arm@1.2.0": + "@img/sharp-libvips-linux-arm@1.2.4": optional: true - "@img/sharp-libvips-linux-ppc64@1.2.0": + "@img/sharp-libvips-linux-ppc64@1.2.4": optional: true - "@img/sharp-libvips-linux-s390x@1.2.0": + "@img/sharp-libvips-linux-riscv64@1.2.4": optional: true - "@img/sharp-libvips-linux-x64@1.2.0": + "@img/sharp-libvips-linux-s390x@1.2.4": optional: true - "@img/sharp-libvips-linuxmusl-arm64@1.2.0": + "@img/sharp-libvips-linux-x64@1.2.4": optional: true - "@img/sharp-libvips-linuxmusl-x64@1.2.0": + "@img/sharp-libvips-linuxmusl-arm64@1.2.4": optional: true - "@img/sharp-linux-arm64@0.34.3": + "@img/sharp-libvips-linuxmusl-x64@1.2.4": + optional: true + + "@img/sharp-linux-arm64@0.34.5": optionalDependencies: - "@img/sharp-libvips-linux-arm64": 1.2.0 + "@img/sharp-libvips-linux-arm64": 1.2.4 optional: true - "@img/sharp-linux-arm@0.34.3": + "@img/sharp-linux-arm@0.34.5": optionalDependencies: - "@img/sharp-libvips-linux-arm": 1.2.0 + "@img/sharp-libvips-linux-arm": 1.2.4 optional: true - "@img/sharp-linux-ppc64@0.34.3": + "@img/sharp-linux-ppc64@0.34.5": optionalDependencies: - "@img/sharp-libvips-linux-ppc64": 1.2.0 + "@img/sharp-libvips-linux-ppc64": 1.2.4 optional: true - "@img/sharp-linux-s390x@0.34.3": + "@img/sharp-linux-riscv64@0.34.5": optionalDependencies: - "@img/sharp-libvips-linux-s390x": 1.2.0 + "@img/sharp-libvips-linux-riscv64": 1.2.4 optional: true - "@img/sharp-linux-x64@0.34.3": + "@img/sharp-linux-s390x@0.34.5": optionalDependencies: - "@img/sharp-libvips-linux-x64": 1.2.0 + "@img/sharp-libvips-linux-s390x": 1.2.4 optional: true - "@img/sharp-linuxmusl-arm64@0.34.3": + "@img/sharp-linux-x64@0.34.5": optionalDependencies: - "@img/sharp-libvips-linuxmusl-arm64": 1.2.0 + "@img/sharp-libvips-linux-x64": 1.2.4 optional: true - "@img/sharp-linuxmusl-x64@0.34.3": + "@img/sharp-linuxmusl-arm64@0.34.5": optionalDependencies: - "@img/sharp-libvips-linuxmusl-x64": 1.2.0 + "@img/sharp-libvips-linuxmusl-arm64": 1.2.4 optional: true - "@img/sharp-wasm32@0.34.3": + "@img/sharp-linuxmusl-x64@0.34.5": + optionalDependencies: + "@img/sharp-libvips-linuxmusl-x64": 1.2.4 + optional: true + + "@img/sharp-wasm32@0.34.5": dependencies: - "@emnapi/runtime": 1.4.5 + "@emnapi/runtime": 1.7.1 optional: true - "@img/sharp-win32-arm64@0.34.3": + "@img/sharp-win32-arm64@0.34.5": optional: true - "@img/sharp-win32-ia32@0.34.3": + "@img/sharp-win32-ia32@0.34.5": optional: true - "@img/sharp-win32-x64@0.34.3": + "@img/sharp-win32-x64@0.34.5": optional: true "@internationalized/date@3.8.2": @@ -9877,7 +9918,7 @@ snapshots: "@tybys/wasm-util": 0.10.0 optional: true - "@next/env@15.5.7": {} + "@next/env@15.5.9": {} "@next/eslint-plugin-next@15.5.3": dependencies: @@ -10684,7 +10725,7 @@ snapshots: "@sentry/core@8.55.0": {} - "@sentry/nextjs@10.11.0(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(next@15.5.7(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1)(webpack@5.101.3)": + "@sentry/nextjs@10.11.0(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(next@15.5.9(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1)(webpack@5.101.3)": dependencies: "@opentelemetry/api": 1.9.0 "@opentelemetry/semantic-conventions": 1.37.0 @@ -10698,7 +10739,7 @@ snapshots: "@sentry/vercel-edge": 10.11.0 "@sentry/webpack-plugin": 4.3.0(webpack@5.101.3) chalk: 3.0.0 - next: 15.5.7(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0) + next: 15.5.9(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0) resolve: 1.22.8 rollup: 4.50.1 stacktrace-parser: 0.1.11 @@ -10829,7 +10870,7 @@ snapshots: "@tanstack/query-core": 5.85.9 react: 18.3.1 - "@tsconfig/node10@1.0.11": + "@tsconfig/node10@1.0.12": optional: true "@tsconfig/node12@1.0.11": @@ -10941,9 +10982,9 @@ snapshots: dependencies: undici-types: 7.10.0 - "@types/node@24.3.1": + "@types/node@25.0.2": dependencies: - undici-types: 7.10.0 + undici-types: 7.16.0 "@types/parse-json@4.0.2": {} @@ -12127,6 +12168,8 @@ snapshots: caniuse-lite@1.0.30001734: {} + caniuse-lite@1.0.30001760: {} + ccount@2.0.1: {} chalk@3.0.0: @@ -12205,18 +12248,6 @@ snapshots: color-name@1.1.4: {} - color-string@1.9.1: - dependencies: - color-name: 1.1.4 - simple-swizzle: 0.2.2 - optional: true - - color@4.2.3: - dependencies: - color-convert: 2.0.1 - color-string: 1.9.1 - optional: true - colorette@1.4.0: {} combined-stream@1.0.8: @@ -12335,7 +12366,7 @@ snapshots: detect-libc@1.0.3: optional: true - detect-libc@2.0.4: + detect-libc@2.1.2: optional: true detect-newline@3.1.0: {} @@ -12405,10 +12436,10 @@ snapshots: engine.io-parser@5.2.3: {} - enhanced-resolve@5.18.3: + enhanced-resolve@5.18.4: dependencies: graceful-fs: 4.2.11 - tapable: 2.2.3 + tapable: 2.3.0 err-code@3.0.1: {} @@ -13147,9 +13178,6 @@ snapshots: is-arrayish@0.2.1: {} - is-arrayish@0.3.2: - optional: true - is-async-function@2.1.1: dependencies: async-function: 1.0.0 @@ -13629,7 +13657,7 @@ snapshots: jest-worker@27.5.1: dependencies: - "@types/node": 24.3.1 + "@types/node": 25.0.2 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -13738,7 +13766,7 @@ snapshots: lines-and-columns@1.2.4: {} - loader-runner@4.3.0: {} + loader-runner@4.3.1: {} locate-path@5.0.0: dependencies: @@ -14093,13 +14121,13 @@ snapshots: neo-async@2.6.2: {} - next-auth@4.24.11(next@15.5.7(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next-auth@4.24.11(next@15.5.9(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: "@babel/runtime": 7.28.2 "@panva/hkdf": 1.2.1 cookie: 0.7.2 jose: 4.15.9 - next: 15.5.7(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0) + next: 15.5.9(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0) oauth: 0.9.15 openid-client: 5.7.1 preact: 10.27.0 @@ -14113,11 +14141,11 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - next@15.5.7(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0): + next@15.5.9(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0): dependencies: - "@next/env": 15.5.7 + "@next/env": 15.5.9 "@swc/helpers": 0.5.15 - caniuse-lite: 1.0.30001734 + caniuse-lite: 1.0.30001760 postcss: 8.4.31 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -14133,7 +14161,7 @@ snapshots: "@next/swc-win32-x64-msvc": 15.5.7 "@opentelemetry/api": 1.9.0 sass: 1.90.0 - sharp: 0.34.3 + sharp: 0.34.5 transitivePeerDependencies: - "@babel/core" - babel-plugin-macros @@ -14159,12 +14187,12 @@ snapshots: dependencies: path-key: 3.1.1 - nuqs@2.4.3(next@15.5.7(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1): + nuqs@2.4.3(next@15.5.9(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1): dependencies: mitt: 3.0.1 react: 18.3.1 optionalDependencies: - next: 15.5.7(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0) + next: 15.5.9(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0) oauth@0.9.15: {} @@ -14734,7 +14762,7 @@ snapshots: dependencies: loose-envify: 1.4.0 - schema-utils@4.3.2: + schema-utils@4.3.3: dependencies: "@types/json-schema": 7.0.15 ajv: 8.17.1 @@ -14749,6 +14777,9 @@ snapshots: semver@7.7.2: {} + semver@7.7.3: + optional: true + serialize-javascript@6.0.2: dependencies: randombytes: 2.1.0 @@ -14775,34 +14806,36 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 - sharp@0.34.3: + sharp@0.34.5: dependencies: - color: 4.2.3 - detect-libc: 2.0.4 - semver: 7.7.2 + "@img/colour": 1.0.0 + detect-libc: 2.1.2 + semver: 7.7.3 optionalDependencies: - "@img/sharp-darwin-arm64": 0.34.3 - "@img/sharp-darwin-x64": 0.34.3 - "@img/sharp-libvips-darwin-arm64": 1.2.0 - "@img/sharp-libvips-darwin-x64": 1.2.0 - "@img/sharp-libvips-linux-arm": 1.2.0 - "@img/sharp-libvips-linux-arm64": 1.2.0 - "@img/sharp-libvips-linux-ppc64": 1.2.0 - "@img/sharp-libvips-linux-s390x": 1.2.0 - "@img/sharp-libvips-linux-x64": 1.2.0 - "@img/sharp-libvips-linuxmusl-arm64": 1.2.0 - "@img/sharp-libvips-linuxmusl-x64": 1.2.0 - "@img/sharp-linux-arm": 0.34.3 - "@img/sharp-linux-arm64": 0.34.3 - "@img/sharp-linux-ppc64": 0.34.3 - "@img/sharp-linux-s390x": 0.34.3 - "@img/sharp-linux-x64": 0.34.3 - "@img/sharp-linuxmusl-arm64": 0.34.3 - "@img/sharp-linuxmusl-x64": 0.34.3 - "@img/sharp-wasm32": 0.34.3 - "@img/sharp-win32-arm64": 0.34.3 - "@img/sharp-win32-ia32": 0.34.3 - "@img/sharp-win32-x64": 0.34.3 + "@img/sharp-darwin-arm64": 0.34.5 + "@img/sharp-darwin-x64": 0.34.5 + "@img/sharp-libvips-darwin-arm64": 1.2.4 + "@img/sharp-libvips-darwin-x64": 1.2.4 + "@img/sharp-libvips-linux-arm": 1.2.4 + "@img/sharp-libvips-linux-arm64": 1.2.4 + "@img/sharp-libvips-linux-ppc64": 1.2.4 + "@img/sharp-libvips-linux-riscv64": 1.2.4 + "@img/sharp-libvips-linux-s390x": 1.2.4 + "@img/sharp-libvips-linux-x64": 1.2.4 + "@img/sharp-libvips-linuxmusl-arm64": 1.2.4 + "@img/sharp-libvips-linuxmusl-x64": 1.2.4 + "@img/sharp-linux-arm": 0.34.5 + "@img/sharp-linux-arm64": 0.34.5 + "@img/sharp-linux-ppc64": 0.34.5 + "@img/sharp-linux-riscv64": 0.34.5 + "@img/sharp-linux-s390x": 0.34.5 + "@img/sharp-linux-x64": 0.34.5 + "@img/sharp-linuxmusl-arm64": 0.34.5 + "@img/sharp-linuxmusl-x64": 0.34.5 + "@img/sharp-wasm32": 0.34.5 + "@img/sharp-win32-arm64": 0.34.5 + "@img/sharp-win32-ia32": 0.34.5 + "@img/sharp-win32-x64": 0.34.5 optional: true shebang-command@2.0.0: @@ -14857,11 +14890,6 @@ snapshots: transitivePeerDependencies: - supports-color - simple-swizzle@0.2.2: - dependencies: - is-arrayish: 0.3.2 - optional: true - slash@3.0.0: {} socket.io-client@4.7.2: @@ -15086,18 +15114,18 @@ snapshots: transitivePeerDependencies: - ts-node - tapable@2.2.3: {} + tapable@2.3.0: {} - terser-webpack-plugin@5.3.14(webpack@5.101.3): + terser-webpack-plugin@5.3.16(webpack@5.101.3): dependencies: "@jridgewell/trace-mapping": 0.3.31 jest-worker: 27.5.1 - schema-utils: 4.3.2 + schema-utils: 4.3.3 serialize-javascript: 6.0.2 - terser: 5.44.0 + terser: 5.44.1 webpack: 5.101.3 - terser@5.44.0: + terser@5.44.1: dependencies: "@jridgewell/source-map": 0.3.11 acorn: 8.15.0 @@ -15164,7 +15192,7 @@ snapshots: ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2): dependencies: "@cspotcode/source-map-support": 0.8.1 - "@tsconfig/node10": 1.0.11 + "@tsconfig/node10": 1.0.12 "@tsconfig/node12": 1.0.11 "@tsconfig/node14": 1.0.3 "@tsconfig/node16": 1.0.4 @@ -15274,6 +15302,8 @@ snapshots: undici-types@7.10.0: {} + undici-types@7.16.0: {} + unified@11.0.5: dependencies: "@types/unist": 3.0.3 @@ -15427,19 +15457,19 @@ snapshots: acorn-import-phases: 1.0.4(acorn@8.15.0) browserslist: 4.25.2 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.18.3 + enhanced-resolve: 5.18.4 es-module-lexer: 1.7.0 eslint-scope: 5.1.1 events: 3.3.0 glob-to-regexp: 0.4.1 graceful-fs: 4.2.11 json-parse-even-better-errors: 2.3.1 - loader-runner: 4.3.0 + loader-runner: 4.3.1 mime-types: 2.1.35 neo-async: 2.6.2 - schema-utils: 4.3.2 - tapable: 2.2.3 - terser-webpack-plugin: 5.3.14(webpack@5.101.3) + schema-utils: 4.3.3 + tapable: 2.3.0 + terser-webpack-plugin: 5.3.16(webpack@5.101.3) watchpack: 2.4.4 webpack-sources: 3.3.3 transitivePeerDependencies: