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>
214 lines
7.4 KiB
PHP
214 lines
7.4 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Meet;
|
|
|
|
use App\Models\ActionItem;
|
|
use App\Models\AiSummary;
|
|
use App\Models\Transcript;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Str;
|
|
use RuntimeException;
|
|
|
|
class MeetAiService
|
|
{
|
|
public function enabled(): bool
|
|
{
|
|
return (bool) config('meet.ai.enabled', true)
|
|
&& ((string) config('meet.ai.api_key') !== '' || $this->hasPlatformRelay());
|
|
}
|
|
|
|
public function summarize(Transcript $transcript): AiSummary
|
|
{
|
|
$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($transcript);
|
|
$summary->update([
|
|
'status' => 'ready',
|
|
'summary' => $result['summary'] ?? '',
|
|
'decisions' => $result['decisions'] ?? [],
|
|
]);
|
|
|
|
foreach ($result['action_items'] ?? [] as $item) {
|
|
ActionItem::create([
|
|
'owner_ref' => $transcript->owner_ref,
|
|
'ai_summary_id' => $summary->id,
|
|
'assignee_name' => $item['assignee'] ?? null,
|
|
'description' => $item['description'] ?? '',
|
|
'due_date' => $item['due_date'] ?? null,
|
|
'status' => 'open',
|
|
]);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
report($e);
|
|
$summary->update(['status' => 'failed', 'summary' => 'AI summary could not be generated.']);
|
|
}
|
|
|
|
app(MeetNotificationService::class)->aiSummaryReady($summary->fresh());
|
|
|
|
return $summary->fresh();
|
|
}
|
|
|
|
/**
|
|
* @return array{summary: string, decisions: array, action_items: array}
|
|
*/
|
|
protected function generateSummary(Transcript $transcript): array
|
|
{
|
|
$content = trim((string) $transcript->content);
|
|
|
|
if (! $this->enabled()) {
|
|
return $this->stubSummary($content);
|
|
}
|
|
|
|
$prompt = $this->buildPrompt($transcript, $content);
|
|
|
|
$reply = $this->hasLocalKey()
|
|
? $this->viaOpenAi($prompt)
|
|
: $this->viaPlatform($prompt);
|
|
|
|
$parsed = json_decode($reply, true);
|
|
if (! is_array($parsed)) {
|
|
return ['summary' => $reply, 'decisions' => [], 'action_items' => []];
|
|
}
|
|
|
|
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);
|
|
|
|
return [
|
|
'summary' => "Meeting summary (AI not configured).\n\nTranscript preview:\n".$preview,
|
|
'decisions' => [],
|
|
'action_items' => [],
|
|
];
|
|
}
|
|
|
|
protected function viaOpenAi(string $prompt): string
|
|
{
|
|
$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 sessions from text transcripts. Return valid JSON only. Never fabricate content missing from the transcript.',
|
|
],
|
|
['role' => 'user', 'content' => $prompt],
|
|
],
|
|
]);
|
|
|
|
if ($res->failed()) {
|
|
throw new RuntimeException('OpenAI request failed: '.$res->status());
|
|
}
|
|
|
|
return trim((string) $res->json('choices.0.message.content', ''));
|
|
}
|
|
|
|
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(120)
|
|
->post($base.'/afia/chat', [
|
|
'product' => 'meet',
|
|
'message' => $prompt,
|
|
'history' => [],
|
|
'system_prompt' => 'You summarize Ladill Meet sessions from text transcripts. Return valid JSON only. Never fabricate content missing from the transcript.',
|
|
]);
|
|
|
|
if ($res->failed()) {
|
|
throw new RuntimeException('Platform AI relay failed.');
|
|
}
|
|
|
|
return trim((string) $res->json('reply', ''));
|
|
}
|
|
|
|
protected function hasLocalKey(): bool
|
|
{
|
|
return (string) config('meet.ai.api_key') !== '';
|
|
}
|
|
|
|
protected function hasPlatformRelay(): bool
|
|
{
|
|
return rtrim((string) config('meet.ai.platform_api_url'), '/') !== ''
|
|
&& (string) config('meet.ai.platform_api_key') !== '';
|
|
}
|
|
}
|