Deploy Ladill Meet / deploy (push) Successful in 1m1s
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.
220 lines
6.8 KiB
PHP
220 lines
6.8 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Meet;
|
|
|
|
use App\Jobs\GenerateAiSummary;
|
|
use App\Models\Recording;
|
|
use App\Models\Session;
|
|
use App\Models\Transcript;
|
|
|
|
class TranscriptService
|
|
{
|
|
public function getOrCreateForSession(Session $session, ?Recording $recording = null): Transcript
|
|
{
|
|
$existing = $session->transcripts()->where('status', '!=', 'failed')->latest()->first();
|
|
if ($existing) {
|
|
if ($recording && ! $existing->recording_id) {
|
|
$existing->update(['recording_id' => $recording->id]);
|
|
}
|
|
|
|
return $existing;
|
|
}
|
|
|
|
return Transcript::create([
|
|
'owner_ref' => $session->owner_ref,
|
|
'session_id' => $session->id,
|
|
'recording_id' => $recording?->id,
|
|
'status' => 'processing',
|
|
'live_captions_enabled' => (bool) $session->room->setting('live_captions', false),
|
|
]);
|
|
}
|
|
|
|
/** @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);
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
|
|
$pending = GenerateAiSummary::dispatch($transcript->id);
|
|
if ($delaySeconds > 0) {
|
|
$pending->delay(now()->addSeconds($delaySeconds));
|
|
}
|
|
|
|
return $transcript->fresh();
|
|
}
|
|
|
|
public function appendSegment(Transcript $transcript, string $speaker, string $text): Transcript
|
|
{
|
|
$segments = $transcript->segments ?? [];
|
|
$segments[] = [
|
|
'speaker' => $speaker,
|
|
'text' => trim($text),
|
|
'at' => now()->toIso8601String(),
|
|
'source' => 'caption',
|
|
];
|
|
|
|
$segments = $this->compactCaptionSegments($segments);
|
|
$content = $this->segmentsToContent($segments);
|
|
|
|
$transcript->update([
|
|
'segments' => $segments,
|
|
'content' => $content,
|
|
'status' => 'ready',
|
|
]);
|
|
|
|
return $transcript;
|
|
}
|
|
|
|
public function refreshContent(Transcript $transcript, Session $session): void
|
|
{
|
|
$segments = $session->messages()
|
|
->orderBy('created_at')
|
|
->get()
|
|
->map(fn ($message) => [
|
|
'speaker' => $message->sender_name,
|
|
'text' => $message->body,
|
|
'at' => $message->created_at->toIso8601String(),
|
|
'source' => 'chat',
|
|
])
|
|
->all();
|
|
|
|
foreach ($transcript->segments ?? [] as $segment) {
|
|
$source = $segment['source'] ?? null;
|
|
if ($source === 'chat') {
|
|
continue;
|
|
}
|
|
|
|
$text = trim((string) ($segment['text'] ?? ''));
|
|
if ($text === '') {
|
|
continue;
|
|
}
|
|
|
|
$segments[] = array_merge($segment, ['source' => 'caption']);
|
|
}
|
|
|
|
$segments = $this->compactCaptionSegments($segments);
|
|
usort($segments, fn (array $a, array $b) => strcmp((string) ($a['at'] ?? ''), (string) ($b['at'] ?? '')));
|
|
$content = $this->segmentsToContent($segments);
|
|
|
|
$transcript->update([
|
|
'segments' => $segments,
|
|
'content' => $content !== '' ? $content : 'No transcript content available.',
|
|
'status' => 'ready',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $segments
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
protected function compactCaptionSegments(array $segments): array
|
|
{
|
|
$compact = [];
|
|
|
|
foreach ($segments as $segment) {
|
|
if (($segment['source'] ?? '') === 'chat') {
|
|
$compact[] = $segment;
|
|
|
|
continue;
|
|
}
|
|
|
|
$speaker = (string) ($segment['speaker'] ?? 'Speaker');
|
|
$text = trim((string) ($segment['text'] ?? ''));
|
|
if ($text === '') {
|
|
continue;
|
|
}
|
|
|
|
$lastCaptionIndex = $this->lastCaptionIndex($compact);
|
|
if ($lastCaptionIndex !== null && ($compact[$lastCaptionIndex]['speaker'] ?? '') === $speaker) {
|
|
$lastText = trim((string) ($compact[$lastCaptionIndex]['text'] ?? ''));
|
|
|
|
if ($this->captionTextsEquivalent($lastText, $text)) {
|
|
continue;
|
|
}
|
|
|
|
if ($lastText !== '' && $this->captionTextExtends($lastText, $text)) {
|
|
$compact[$lastCaptionIndex] = $segment;
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($lastText !== '' && $this->captionTextExtends($text, $lastText)) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
$compact[] = $segment;
|
|
}
|
|
|
|
return $compact;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $segments
|
|
*/
|
|
protected function lastCaptionIndex(array $segments): ?int
|
|
{
|
|
for ($index = count($segments) - 1; $index >= 0; $index -= 1) {
|
|
if (($segments[$index]['source'] ?? '') !== 'chat') {
|
|
return $index;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
protected function captionTextsEquivalent(string $left, string $right): bool
|
|
{
|
|
return mb_strtolower(trim($left)) === mb_strtolower(trim($right));
|
|
}
|
|
|
|
protected function captionTextExtends(string $shorter, string $longer): bool
|
|
{
|
|
$shorter = mb_strtolower(trim($shorter));
|
|
$longer = mb_strtolower(trim($longer));
|
|
|
|
if ($shorter === '' || $longer === '') {
|
|
return false;
|
|
}
|
|
|
|
return str_starts_with($longer, $shorter);
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $segments
|
|
*/
|
|
protected function segmentsToContent(array $segments): string
|
|
{
|
|
return collect($segments)
|
|
->map(fn ($segment) => ($segment['speaker'] ?? 'Speaker').': '.($segment['text'] ?? ''))
|
|
->implode("\n");
|
|
}
|
|
}
|