From 9121f01b1c9202f3962cb704a30ec9960af9f98d Mon Sep 17 00:00:00 2001 From: isaacclad Date: Thu, 16 Jul 2026 00:15:13 +0000 Subject: [PATCH] Generate AI summaries when meetings end, not only after recordings. Queue summarization on session end (with a short delay for late captions), keep recording-ready as a refresh path, and unique the summary job so end plus recording do not double-process the same transcript. --- app/Jobs/GenerateAiSummary.php | 11 ++- app/Services/Meet/RecordingService.php | 11 ++- app/Services/Meet/SessionService.php | 10 ++- app/Services/Meet/TranscriptService.php | 31 ++++++- tests/Feature/MeetTranscriptServiceTest.php | 96 +++++++++++++++++++++ 5 files changed, 150 insertions(+), 9 deletions(-) diff --git a/app/Jobs/GenerateAiSummary.php b/app/Jobs/GenerateAiSummary.php index db30e96..5d59f52 100644 --- a/app/Jobs/GenerateAiSummary.php +++ b/app/Jobs/GenerateAiSummary.php @@ -4,19 +4,28 @@ namespace App\Jobs; use App\Models\Transcript; use App\Services\Meet\MeetAiService; +use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; -class GenerateAiSummary implements ShouldQueue +class GenerateAiSummary implements ShouldBeUnique, ShouldQueue { use Queueable; public int $tries = 2; + /** Prevent end-of-meeting + recording-ready from running two summaries at once. */ + public int $uniqueFor = 180; + public function __construct( public int $transcriptId, ) {} + public function uniqueId(): string + { + return 'meet-ai-summary:'.$this->transcriptId; + } + public function handle(MeetAiService $ai): void { $transcript = Transcript::find($this->transcriptId); diff --git a/app/Services/Meet/RecordingService.php b/app/Services/Meet/RecordingService.php index 2a4ff9b..4ed268b 100644 --- a/app/Services/Meet/RecordingService.php +++ b/app/Services/Meet/RecordingService.php @@ -174,8 +174,10 @@ class RecordingService app(MeetBillingService::class)->chargeRecordingStorageInitial($recording); - if ($session->room->setting('auto_ai_summary', false) || config('meet.ai.auto_summarize', true)) { - app(TranscriptService::class)->finalizeAndSummarize($session, $recording); + $transcripts = app(TranscriptService::class); + if ($transcripts->shouldAutoSummarize($session)) { + // Re-run after recording is ready so the transcript is linked and content refreshed. + $transcripts->finalizeAndSummarize($session, $recording); } return $recording->fresh(); @@ -230,8 +232,9 @@ class RecordingService app(MeetBillingService::class)->chargeRecordingStorageInitial($recording->fresh()); - if ($session->room->setting('auto_ai_summary', false) || config('meet.ai.auto_summarize', true)) { - app(TranscriptService::class)->finalizeAndSummarize($session, $recording); + $transcripts = app(TranscriptService::class); + if ($transcripts->shouldAutoSummarize($session)) { + $transcripts->finalizeAndSummarize($session, $recording); } return $recording->fresh(); diff --git a/app/Services/Meet/SessionService.php b/app/Services/Meet/SessionService.php index eb97d3d..4da5c2c 100644 --- a/app/Services/Meet/SessionService.php +++ b/app/Services/Meet/SessionService.php @@ -81,7 +81,15 @@ class SessionService app(UsageMeteringService::class)->recordSessionUsage($session); - return $session; + // Summarize on meeting end (not only when a recording is ready). Short delay + // lets the last caption/chat POSTs land before the transcript is finalized. + $session->loadMissing('room'); + $transcripts = app(TranscriptService::class); + if ($transcripts->shouldAutoSummarize($session)) { + $transcripts->finalizeAndSummarize($session->fresh(), null, 20); + } + + return $session->fresh(); } public function joinParticipant( diff --git a/app/Services/Meet/TranscriptService.php b/app/Services/Meet/TranscriptService.php index 36c8efc..53cdce0 100644 --- a/app/Services/Meet/TranscriptService.php +++ b/app/Services/Meet/TranscriptService.php @@ -29,18 +29,43 @@ class TranscriptService ]); } - /** @deprecated Use finalizeAndSummarize() when a recording is ready. */ + /** @deprecated Use finalizeAndSummarize() when a session ends or a recording is ready. */ public function ensureForSession(Session $session, ?Recording $recording = null): Transcript { return $this->finalizeAndSummarize($session, $recording); } - public function finalizeAndSummarize(Session $session, ?Recording $recording = null): Transcript + /** + * Whether this session should receive an automatic AI summary. + * Matches room setting + global meet.ai.auto_summarize config. + */ + public function shouldAutoSummarize(Session $session): bool + { + $session->loadMissing('room'); + $room = $session->room; + + if (! $room) { + return (bool) config('meet.ai.auto_summarize', true); + } + + return (bool) $room->setting('auto_ai_summary', false) + || (bool) config('meet.ai.auto_summarize', true); + } + + /** + * Build/refresh the session transcript and queue AI summarization. + * + * @param int $delaySeconds Brief delay so late caption/chat posts can land (e.g. on meeting end). + */ + public function finalizeAndSummarize(Session $session, ?Recording $recording = null, int $delaySeconds = 0): Transcript { $transcript = $this->getOrCreateForSession($session, $recording); $this->refreshContent($transcript, $session); - GenerateAiSummary::dispatch($transcript->id); + $pending = GenerateAiSummary::dispatch($transcript->id); + if ($delaySeconds > 0) { + $pending->delay(now()->addSeconds($delaySeconds)); + } return $transcript->fresh(); } diff --git a/tests/Feature/MeetTranscriptServiceTest.php b/tests/Feature/MeetTranscriptServiceTest.php index 5b4aa74..ef1c9de 100644 --- a/tests/Feature/MeetTranscriptServiceTest.php +++ b/tests/Feature/MeetTranscriptServiceTest.php @@ -106,6 +106,102 @@ class MeetTranscriptServiceTest extends TestCase Queue::assertNothingPushed(); } + public function test_ending_session_queues_ai_summary_without_recording(): void + { + Queue::fake(); + + $room = Room::create([ + 'organization_id' => $this->organization->id, + 'owner_ref' => $this->user->public_id, + 'host_user_ref' => $this->user->public_id, + 'title' => 'No recording meeting', + 'type' => 'instant', + 'status' => 'live', + 'timezone' => 'UTC', + 'settings' => [ + 'auto_ai_summary' => true, + 'live_captions' => true, + ], + ]); + + $session = Session::create([ + 'room_id' => $room->id, + 'owner_ref' => $this->user->public_id, + 'media_room_name' => 'room-'.$room->uuid, + 'status' => 'live', + 'started_at' => now()->subMinutes(15), + ]); + + Participant::create([ + 'owner_ref' => $this->user->public_id, + 'session_id' => $session->id, + 'user_ref' => $this->user->public_id, + 'role' => 'host', + 'display_name' => 'Host', + 'status' => 'joined', + 'joined_at' => now()->subMinutes(15), + ]); + + Message::create([ + 'owner_ref' => $this->user->public_id, + 'session_id' => $session->id, + 'sender_ref' => $this->user->public_id, + 'sender_name' => 'Host', + 'body' => 'We agreed to ship Friday.', + 'type' => 'public', + ]); + + app(\App\Services\Meet\SessionService::class)->end($session, $this->user->public_id); + + $session->refresh(); + $this->assertSame('ended', $session->status); + $this->assertNotNull($session->ended_at); + + $transcript = $session->transcripts()->latest()->first(); + $this->assertNotNull($transcript); + $this->assertStringContainsString('We agreed to ship Friday.', (string) $transcript->content); + + Queue::assertPushed(GenerateAiSummary::class, function (GenerateAiSummary $job) use ($transcript) { + return $job->transcriptId === $transcript->id; + }); + } + + public function test_ending_session_skips_summary_when_auto_ai_disabled(): void + { + Queue::fake(); + config(['meet.ai.auto_summarize' => false]); + + $room = Room::create([ + 'organization_id' => $this->organization->id, + 'owner_ref' => $this->user->public_id, + 'host_user_ref' => $this->user->public_id, + 'title' => 'No AI meeting', + 'type' => 'instant', + 'status' => 'live', + 'timezone' => 'UTC', + // Explicit false in settings (setting() reads room.settings first) + 'settings' => ['auto_ai_summary' => false], + ]); + + $session = Session::create([ + 'room_id' => $room->id, + 'owner_ref' => $this->user->public_id, + 'media_room_name' => 'room-'.$room->uuid, + 'status' => 'live', + 'started_at' => now()->subMinutes(5), + ]); + + // Room::setting falls back to default_settings when key missing; with explicit false + // AND config false, shouldAutoSummarize must be false. + // Note: shouldAutoSummarize is (room setting || config) — explicit false || false = false. + $this->assertFalse(app(TranscriptService::class)->shouldAutoSummarize($session)); + + app(\App\Services\Meet\SessionService::class)->end($session, $this->user->public_id); + + Queue::assertNotPushed(GenerateAiSummary::class); + $this->assertSame(0, $session->fresh()->transcripts()->count()); + } + public function test_caption_segments_collapse_progressive_duplicates(): void { Queue::fake();