Fix screen-share tile recovery and make AI summaries use full transcripts.
Deploy Ladill Meet / deploy (push) Successful in 1m17s

Defer summarization until recordings finish, merge chat with deduplicated live captions, and clear the screen-share overlay so participant cards return after sharing ends.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-11 17:05:18 +00:00
co-authored by Cursor
parent aa16b31fe1
commit 393f6df167
7 changed files with 527 additions and 63 deletions
+89 -23
View File
@@ -2,9 +2,9 @@
namespace App\Services\Meet;
use App\Models\ActionItem;
use App\Models\AiSummary;
use App\Models\Transcript;
use App\Models\ActionItem;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use RuntimeException;
@@ -19,15 +19,35 @@ class MeetAiService
public function summarize(Transcript $transcript): AiSummary
{
$summary = AiSummary::create([
'owner_ref' => $transcript->owner_ref,
'session_id' => $transcript->session_id,
'transcript_id' => $transcript->id,
'status' => 'processing',
]);
$transcript->loadMissing('session.room');
if ($transcript->session) {
app(TranscriptService::class)->refreshContent($transcript, $transcript->session);
$transcript->refresh();
}
$summary = AiSummary::query()
->where('transcript_id', $transcript->id)
->first();
if ($summary) {
$summary->actionItems()->delete();
$summary->update([
'status' => 'processing',
'summary' => '',
'decisions' => [],
]);
} else {
$summary = AiSummary::create([
'owner_ref' => $transcript->owner_ref,
'session_id' => $transcript->session_id,
'transcript_id' => $transcript->id,
'status' => 'processing',
]);
}
try {
$result = $this->generateSummary((string) $transcript->content);
$result = $this->generateSummary($transcript);
$summary->update([
'status' => 'ready',
'summary' => $result['summary'] ?? '',
@@ -57,21 +77,15 @@ class MeetAiService
/**
* @return array{summary: string, decisions: array, action_items: array}
*/
protected function generateSummary(string $transcript): array
protected function generateSummary(Transcript $transcript): array
{
$content = trim((string) $transcript->content);
if (! $this->enabled()) {
return $this->stubSummary($transcript);
return $this->stubSummary($content);
}
$prompt = <<<PROMPT
Analyze this meeting transcript and respond in JSON with keys:
- summary (string, 2-4 paragraphs)
- decisions (array of strings)
- action_items (array of {assignee, description, due_date or null})
Transcript:
{$transcript}
PROMPT;
$prompt = $this->buildPrompt($transcript, $content);
$reply = $this->hasLocalKey()
? $this->viaOpenAi($prompt)
@@ -85,6 +99,55 @@ PROMPT;
return $parsed;
}
protected function buildPrompt(Transcript $transcript, string $content): string
{
$session = $transcript->session;
$room = $session?->room;
$title = $room?->title ?? 'Meeting';
$duration = 'unknown';
if ($session?->started_at && $session?->ended_at) {
$minutes = max(1, $session->started_at->diffInMinutes($session->ended_at));
$duration = $minutes.' minutes';
}
$participantCount = $session?->participants()->where('status', 'joined')->count() ?? 0;
$segments = $transcript->segments ?? [];
$chatLineCount = collect($segments)->where('source', 'chat')->count();
$captionLineCount = collect($segments)->filter(
fn (array $segment) => ($segment['source'] ?? 'caption') === 'caption',
)->count();
$isSparse = $content === ''
|| $content === 'No transcript content available.'
|| strlen($content) < 120;
$sourceNote = "The transcript combines in-meeting chat ({$chatLineCount} messages) "
."and live speech captions ({$captionLineCount} lines). "
."Caption lines are the primary record of spoken conversation — use them when chat is minimal.\n\n";
$sparseNote = $isSparse
? "IMPORTANT: The transcript below is very limited and does not include a full audio transcription. "
."If it does not contain enough detail to summarize the meeting accurately, say so clearly in the summary. "
."Do not invent topics, decisions, or action items that are not supported by the transcript.\n\n"
: "Only summarize what is explicitly supported by the transcript below.\n\n";
return <<<PROMPT
{$sourceNote}{$sparseNote}Meeting context:
- Title: {$title}
- Duration: {$duration}
- Participants in call: {$participantCount}
Analyze this meeting transcript and respond in JSON with keys:
- summary (string, 2-4 paragraphs)
- decisions (array of strings)
- action_items (array of {assignee, description, due_date or null})
Transcript:
{$content}
PROMPT;
}
protected function stubSummary(string $transcript): array
{
$preview = Str::limit($transcript, 500);
@@ -98,13 +161,16 @@ PROMPT;
protected function viaOpenAi(string $prompt): string
{
$res = Http::withToken((string) config('meet.ai.api_key'))->acceptJson()->timeout(60)
$res = Http::withToken((string) config('meet.ai.api_key'))->acceptJson()->timeout(120)
->post('https://api.openai.com/v1/chat/completions', [
'model' => config('meet.ai.model', 'gpt-4o-mini'),
'temperature' => 0.2,
'response_format' => ['type' => 'json_object'],
'messages' => [
['role' => 'system', 'content' => 'You summarize Ladill Meet transcripts. Return valid JSON only.'],
[
'role' => 'system',
'content' => 'You summarize Ladill Meet sessions from text transcripts. Return valid JSON only. Never fabricate content missing from the transcript.',
],
['role' => 'user', 'content' => $prompt],
],
]);
@@ -119,12 +185,12 @@ PROMPT;
protected function viaPlatform(string $prompt): string
{
$base = rtrim((string) config('meet.ai.platform_api_url'), '/');
$res = Http::withToken((string) config('meet.ai.platform_api_key'))->acceptJson()->timeout(60)
$res = Http::withToken((string) config('meet.ai.platform_api_key'))->acceptJson()->timeout(120)
->post($base.'/afia/chat', [
'product' => 'meet',
'message' => $prompt,
'history' => [],
'system_prompt' => 'You summarize Ladill Meet transcripts. Return valid JSON only.',
'system_prompt' => 'You summarize Ladill Meet sessions from text transcripts. Return valid JSON only. Never fabricate content missing from the transcript.',
]);
if ($res->failed()) {
+2 -2
View File
@@ -175,7 +175,7 @@ class RecordingService
app(MeetBillingService::class)->chargeRecordingStorageInitial($recording);
if ($session->room->setting('auto_ai_summary', false) || config('meet.ai.auto_summarize', true)) {
app(TranscriptService::class)->ensureForSession($session, $recording);
app(TranscriptService::class)->finalizeAndSummarize($session, $recording);
}
return $recording->fresh();
@@ -231,7 +231,7 @@ 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)->ensureForSession($session, $recording);
app(TranscriptService::class)->finalizeAndSummarize($session, $recording);
}
return $recording->fresh();
+137 -14
View File
@@ -2,29 +2,43 @@
namespace App\Services\Meet;
use App\Jobs\GenerateAiSummary;
use App\Models\Recording;
use App\Models\Session;
use App\Models\Transcript;
use App\Jobs\GenerateAiSummary;
class TranscriptService
{
public function ensureForSession(Session $session, ?Recording $recording = null): Transcript
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;
}
$transcript = Transcript::create([
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),
]);
}
$this->populateFromChat($transcript, $session);
/** @deprecated Use finalizeAndSummarize() when 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
{
$transcript = $this->getOrCreateForSession($session, $recording);
$this->refreshContent($transcript, $session);
GenerateAiSummary::dispatch($transcript->id);
@@ -38,9 +52,11 @@ class TranscriptService
'speaker' => $speaker,
'text' => trim($text),
'at' => now()->toIso8601String(),
'source' => 'caption',
];
$content = collect($segments)->map(fn ($s) => ($s['speaker'] ?? 'Speaker').': '.($s['text'] ?? ''))->implode("\n");
$segments = $this->compactCaptionSegments($segments);
$content = $this->segmentsToContent($segments);
$transcript->update([
'segments' => $segments,
@@ -51,21 +67,128 @@ class TranscriptService
return $transcript;
}
protected function populateFromChat(Transcript $transcript, Session $session): void
public function refreshContent(Transcript $transcript, Session $session): void
{
$messages = $session->messages()->orderBy('created_at')->get();
$segments = $messages->map(fn ($m) => [
'speaker' => $m->sender_name,
'text' => $m->body,
'at' => $m->created_at->toIso8601String(),
])->all();
$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();
$content = $messages->map(fn ($m) => $m->sender_name.': '.$m->body)->implode("\n");
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 ?: 'No transcript content available.',
'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");
}
}