diff --git a/app/Services/AiClientCallService.php b/app/Services/AiClientCallService.php index 4a82907..44c8f53 100644 --- a/app/Services/AiClientCallService.php +++ b/app/Services/AiClientCallService.php @@ -12,6 +12,28 @@ class AiClientCallService { + /** + * Get configured Gemini API Key from settings or environment. + */ + protected function getApiKey(): ?string + { + $settingKey = null; + try { + if (class_exists(\App\Models\Setting::class)) { + $settingKey = \App\Models\Setting::get('gemini_api_key') ?: \App\Models\Setting::get('google_api_key'); + } + } catch (\Throwable $e) {} + + return $settingKey + ?: (config('services.gemini.key') + ?: (config('services.gemini.api_key') + ?: (config('services.google.key') + ?: (env('GEMINI_API_KEY') + ?: (env('GOOGLE_API_KEY') + ?: (getenv('GEMINI_API_KEY') + ?: (getenv('GOOGLE_API_KEY') ?: null))))))); + } + /** * Generate Comprehensive MoM, Action Items, To-Do List, Sentiment, and Emotion Breakdown. * @@ -23,28 +45,40 @@ class AiClientCallService */ public function generateMoM(?string $transcript = '', ?string $liveNotes = '', ?string $projectName = '', ?string $clientName = ''): array { - $transcript = (string)($transcript ?? ''); - $liveNotes = (string)($liveNotes ?? ''); - $projectName = (string)($projectName ?? ''); - $clientName = (string)($clientName ?? ''); + $transcript = trim((string)($transcript ?? '')); + $liveNotes = trim((string)($liveNotes ?? '')); + $projectName = trim((string)($projectName ?? '')); + $clientName = trim((string)($clientName ?? '')); - $apiKey = env('GEMINI_API_KEY') ?: (env('GOOGLE_API_KEY') ?: (getenv('GEMINI_API_KEY') ?: getenv('GOOGLE_API_KEY'))); + $apiKey = $this->getApiKey(); - $combinedContent = "Project: {$projectName}\nClient: {$clientName}\n\n"; + $combinedContent = "Project: " . ($projectName ?: 'Client Project') . "\nClient: " . ($clientName ?: 'Client Partner') . "\n\n"; if (!empty($liveNotes)) { $combinedContent .= "Live Notes Taken During Call:\n{$liveNotes}\n\n"; } - $combinedContent .= "Call Speech Transcript:\n" . (!empty($transcript) ? $transcript : "No spoken transcript recorded. Live meeting notes provided."); + $combinedContent .= "Call Speech Transcript:\n" . (!empty($transcript) ? $transcript : "No spoken transcript recorded."); if ($apiKey) { - try { - $prompt = <<post("https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key={$apiKey}", [ - 'contents' => [ - [ - 'parts' => [ - ['text' => $prompt . "\n\nMeeting Content:\n" . $combinedContent] + // Attempt generation across available models + $models = ['gemini-1.5-flash', 'gemini-2.0-flash', 'gemini-1.5-pro']; + + foreach ($models as $model) { + try { + $response = Http::timeout(20)->post("https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}", [ + 'contents' => [ + [ + 'parts' => [ + ['text' => $prompt . "\n\nMeeting Content:\n" . $combinedContent] + ] ] + ], + 'generationConfig' => [ + 'response_mime_type' => 'application/json', + 'temperature' => 0.1 ] - ], - 'generationConfig' => [ - 'response_mime_type' => 'application/json', - 'temperature' => 0.2 - ] - ]); + ]); - if ($response->successful()) { - $json = $response->json(); - $text = $json['candidates'][0]['content']['parts'][0]['text'] ?? ''; - $data = json_decode($text, true); + if ($response->successful()) { + $json = $response->json(); + $text = $json['candidates'][0]['content']['parts'][0]['text'] ?? ''; + $data = json_decode($text, true); - if (is_array($data) && isset($data['mom_content'])) { - return $this->formatMoMData($data, $projectName, $clientName); + if (is_array($data) && isset($data['mom_content'])) { + return $this->formatMoMData($data, $projectName, $clientName, $transcript, $liveNotes); + } } + } catch (\Throwable $e) { + Log::warning("AI MoM Model {$model} error: " . $e->getMessage()); } - } catch (\Throwable $e) { - Log::error('AI MoM Generation Error: ' . $e->getMessage()); } } - // Fallback Intelligent Extraction Engine + // Intelligent Local NLP Extraction Fallback Engine (strictly grounded in actual transcript and live notes) return $this->generateFallbackMoM($combinedContent, $transcript, $liveNotes, $projectName, $clientName); } @@ -486,7 +526,7 @@ public function formatTodosPlainText(ClientCallNote $note): string /** * Format MoM data to ensure all keys and structures exist. */ - protected function formatMoMData(array $data, string $projectName = '', string $clientName = ''): array + protected function formatMoMData(array $data, string $projectName = '', string $clientName = '', string $transcript = '', string $liveNotes = ''): array { $sentiment = strtolower($data['detected_sentiment'] ?? 'neutral'); if (!in_array($sentiment, ['happy', 'neutral', 'sad', 'angry'])) { @@ -515,27 +555,72 @@ protected function formatMoMData(array $data, string $projectName = '', string $ $emotionDetails = $this->buildFallbackEmotionDetails($sentiment, $projectName, $clientName, $data['summary'] ?? ''); } - // Format todo_items - $todoItems = $data['todo_items'] ?? []; - if (empty($todoItems) && !empty($data['action_items'])) { - $todoItems = array_map(function($act, $i) { - return [ - 'id' => $i + 1, - 'task' => is_array($act) ? ($act['task'] ?? '') : (string)$act, - 'owner' => is_array($act) ? ($act['owner'] ?? 'Team') : 'Team', - 'priority' => is_array($act) ? ($act['priority'] ?? 'Medium') : 'Medium', + // Format action_items + $rawActionItems = is_array($data['action_items'] ?? null) ? $data['action_items'] : []; + $actionItems = []; + foreach ($rawActionItems as $act) { + $taskText = is_array($act) ? ($act['task'] ?? $act['title'] ?? '') : (string)$act; + if (!empty(trim($taskText))) { + $actionItems[] = [ + 'task' => trim($taskText), + 'owner' => is_array($act) ? ($act['owner'] ?? 'Assigned Lead') : 'Assigned Lead', 'deadline' => is_array($act) ? ($act['deadline'] ?? now()->addDays(3)->format('M d, Y')) : now()->addDays(3)->format('M d, Y'), + 'status' => is_array($act) ? ($act['status'] ?? 'Pending') : 'Pending', + ]; + } + } + + // Format todo_items + $rawTodoItems = is_array($data['todo_items'] ?? null) ? $data['todo_items'] : []; + $todoItems = []; + + if (!empty($rawTodoItems)) { + foreach ($rawTodoItems as $i => $item) { + $taskText = is_array($item) ? ($item['task'] ?? $item['title'] ?? '') : (string)$item; + if (!empty(trim($taskText))) { + $priority = is_array($item) ? ($item['priority'] ?? 'Medium') : 'Medium'; + if (!in_array($priority, ['High', 'Medium', 'Low'])) { + $priority = 'Medium'; + } + $todoItems[] = [ + 'id' => is_array($item) && isset($item['id']) ? (int)$item['id'] : ($i + 1), + 'task' => trim($taskText), + 'owner' => is_array($item) ? ($item['owner'] ?? 'Assigned Lead') : 'Assigned Lead', + 'priority' => $priority, + 'deadline' => is_array($item) ? ($item['deadline'] ?? now()->addDays(3)->format('M d, Y')) : now()->addDays(3)->format('M d, Y'), + 'completed' => is_array($item) ? (bool)($item['completed'] ?? false) : false, + ]; + } + } + } elseif (!empty($actionItems)) { + foreach ($actionItems as $i => $act) { + $todoItems[] = [ + 'id' => $i + 1, + 'task' => $act['task'], + 'owner' => $act['owner'], + 'priority' => 'Medium', + 'deadline' => $act['deadline'], 'completed' => false, ]; - }, $data['action_items'], array_keys($data['action_items'])); + } + } + + // Format key decisions + $rawDecisions = is_array($data['key_decisions'] ?? null) ? $data['key_decisions'] : []; + $keyDecisions = []; + foreach ($rawDecisions as $dec) { + $decText = is_array($dec) ? ($dec['decision'] ?? '') : (string)$dec; + if (!empty(trim($decText))) { + $keyDecisions[] = ['decision' => trim($decText)]; + } } return [ 'summary' => $data['summary'] ?? 'Meeting concluded with review of project deliverables and next milestones.', 'mom_content' => $data['mom_content'] ?? '', - 'action_items' => is_array($data['action_items'] ?? null) ? $data['action_items'] : [], - 'todo_items' => is_array($todoItems) ? $todoItems : [], - 'key_decisions' => is_array($data['key_decisions'] ?? null) ? $data['key_decisions'] : [], + 'action_items' => $actionItems, + 'todo_items' => $todoItems, + 'key_decisions' => $keyDecisions, 'detected_sentiment' => $sentiment, 'sentiment_breakdown' => $data['sentiment_breakdown'] ?? [ 'happy_pct' => $sentiment === 'happy' ? 80 : 10, @@ -549,12 +634,16 @@ protected function formatMoMData(array $data, string $projectName = '', string $ } /** - * Fallback MoM extraction when external LLM API is unavailable. + * Intelligent Local NLP Extraction Engine when external LLM API is unavailable. + * Extracts actual discussion items, action items, blockers, decisions, and emotions from live notes and speech. */ protected function generateFallbackMoM(string $combinedContent, string $transcript, string $liveNotes, string $projectName, string $clientName): array { $lower = strtolower($combinedContent); + $proj = $projectName ?: 'Client Project'; + $client = $clientName ?: 'Client Partner'; + // 1. Sentiment Keyword Extraction $negatedHappyPhrases = ['not happy', 'not satisfied', 'not good', 'not great', 'not working', 'not impressed', 'not pleased', 'no progress', 'displeased', 'unhappy', 'dissatisfied', 'cannot accept', 'unacceptable']; $foundNegations = array_values(array_filter($negatedHappyPhrases, fn($w) => (bool)preg_match('/\b' . preg_quote($w, '/') . '\b/i', $lower))); @@ -574,6 +663,262 @@ protected function generateFallbackMoM(string $combinedContent, string $transcri } $sentiment = 'neutral'; + if (count($foundAngry) > 0) { + $sentiment = 'angry'; + } elseif (count($foundSad) > 0) { + $sentiment = 'sad'; + } elseif (count($foundHappy) > 0) { + $sentiment = 'happy'; + } + + // 2. Parse Raw Lines from Live Notes and Transcript + $rawLines = []; + + if (!empty($liveNotes)) { + $splitNotes = preg_split('/\r\n|\r|\n/', $liveNotes); + foreach ($splitNotes as $noteLine) { + $cleaned = trim(preg_replace('/^(\s*[-*•#\d\.\)\(\[\]xX]+\s*)+/', '', $noteLine)); + if (!empty($cleaned) && strlen($cleaned) >= 3) { + $rawLines[] = [ + 'source' => 'notes', + 'speaker' => 'Meeting Notes', + 'text' => $cleaned, + ]; + } + } + } + + if (!empty($transcript)) { + $splitTranscript = preg_split('/\r\n|\r|\n/', $transcript); + foreach ($splitTranscript as $tLine) { + $tLine = trim($tLine); + if (empty($tLine)) continue; + + // Match format: [10:00 AM] Speaker: Spoken text OR Speaker: Spoken text + $speaker = 'Speaker'; + $text = $tLine; + + if (preg_match('/^(?:\[[^\]]+\]\s*)?([^:]+):\s*(.+)$/i', $tLine, $m)) { + $speaker = trim($m[1]); + $text = trim($m[2]); + } else { + $text = preg_replace('/^\[[^\]]+\]\s*/', '', $tLine); + } + + if (!empty($text) && strlen($text) >= 3) { + $rawLines[] = [ + 'source' => 'transcript', + 'speaker' => $speaker, + 'text' => $text, + ]; + } + } + } + + // 3. Extract Tasks & Action Items from Actual Discussion Lines + $extractedTasks = []; + $extractedDecisions = []; + $extractedBlockers = []; + $extractedDiscussionPoints = []; + + $taskTriggers = [ + 'fix', 'update', 'implement', 'deploy', 'create', 'build', 'send', 'email', 'share', + 'provide', 'review', 'check', 'test', 'verify', 'resolve', 'prepare', 'design', + 'refactor', 'migrate', 'setup', 'configure', 'integrate', 'optimize', 'schedule', + 'add', 'remove', 'delete', 'change', 'modify', 'finalize', 'follow up', 'deliver', + 'submit', 'contact', 'call', 'arrange', 'investigate', 'complete', 'write', 'publish', + 'document', 'rework', 'todo', 'task', 'need to', 'needs to', 'have to', 'has to', + 'will', 'shall', 'must', 'should', 'action item' + ]; + + $decisionTriggers = ['agreed', 'decided', 'approved', 'confirmed', 'finalized', 'settled on', 'chosen', 'selected', 'proceed with', 'sign off', 'signed off', 'accepted', 'aligned on', 'consensus']; + $blockerTriggers = ['issue', 'bug', 'error', 'broken', 'problem', 'blocker', 'delay', 'delayed', 'failed', 'struggling', 'challenge', 'difficulty', 'risk', 'concern', 'complaint', 'stuck', 'bottleneck', 'not working']; + + $seenTasks = []; + + foreach ($rawLines as $lineObj) { + $lineText = $lineObj['text']; + $speaker = $lineObj['speaker']; + $lowerLine = strtolower($lineText); + + // Add to general discussion points list + $extractedDiscussionPoints[] = $lineText; + + // Check if this line is a Blocker / Challenge + foreach ($blockerTriggers as $bTrig) { + if (preg_match('/\b' . preg_quote($bTrig, '/') . '\b/i', $lowerLine)) { + $extractedBlockers[] = $lineText; + break; + } + } + + // Check if this line is a Key Decision + foreach ($decisionTriggers as $dTrig) { + if (preg_match('/\b' . preg_quote($dTrig, '/') . '\b/i', $lowerLine)) { + $cleanDec = preg_replace('/^(we|team|client)\s+/i', '', $lineText); + $extractedDecisions[] = ucfirst(trim($cleanDec)); + break; + } + } + + // Check if this line is an Action Item / Task + $isTask = false; + if ($lineObj['source'] === 'notes') { + // Any note typed manually by the user during call is an actionable point or task + $isTask = true; + } else { + foreach ($taskTriggers as $tTrig) { + if (preg_match('/\b' . preg_quote($tTrig, '/') . '\b/i', $lowerLine)) { + $isTask = true; + break; + } + } + } + + if ($isTask) { + // Clean task text + $cleanTask = trim(preg_replace('/^(todo|task|action item|action):\s*/i', '', $lineText)); + $cleanTask = ucfirst(trim(preg_replace('/^(we need to|need to|we will|i will|please|team should|client requested to)\s+/i', '', $cleanTask))); + + $taskKey = strtolower(substr($cleanTask, 0, 40)); + if (isset($seenTasks[$taskKey])) { + continue; + } + $seenTasks[$taskKey] = true; + + // Extract Owner + $owner = 'Assigned Lead'; + if ($speaker !== 'Meeting Notes' && $speaker !== 'Speaker') { + $owner = $speaker; + } + if (preg_match('/(?:assigned to|owner:?|lead:?)\s*([a-zA-Z\s]+)/i', $lineText, $mOwner)) { + $owner = trim($mOwner[1]); + } elseif (preg_match('/\b([A-Z][a-z]+)\s+(?:will|to|should)\b/', $lineText, $mOwner2)) { + $owner = trim($mOwner2[1]); + } elseif (preg_match('/\b(PM|TL|Tech Lead|Designer|Developer|QA|Frontend|Backend|Engineering)\b/i', $lineText, $mOwner3)) { + $owner = trim($mOwner3[1]); + } + + // Extract Deadline + $deadline = now()->addDays(3)->format('M d, Y'); + if (preg_match('/\bby\s+(tomorrow|today|tonight|monday|tuesday|wednesday|thursday|friday|saturday|sunday|next week|end of day|eod|5 pm|noon)\b/i', $lineText, $mDead)) { + $deadline = 'By ' . ucfirst(strtolower($mDead[1])); + } elseif (preg_match('/\bby\s+([a-zA-Z]+\s+\d{1,2}(?:st|nd|rd|th)?|\d{1,2}(?:st|nd|rd|th)?\s+[a-zA-Z]+)\b/i', $lineText, $mDead2)) { + $deadline = 'By ' . trim($mDead2[1]); + } elseif (preg_match('/\bwithin\s+(\d+\s+(?:days|weeks|hours))\b/i', $lineText, $mDead3)) { + $deadline = 'Within ' . trim($mDead3[1]); + } + + // Extract Priority + $priority = 'Medium'; + if (preg_match('/\b(urgent|asap|critical|high priority|blocker|immediately|emergency|broken)\b/i', $lineText)) { + $priority = 'High'; + } elseif (preg_match('/\b(low priority|optional|nice to have|eventually|backlog|future|later)\b/i', $lineText)) { + $priority = 'Low'; + } + + $extractedTasks[] = [ + 'task' => $cleanTask, + 'owner' => $owner, + 'deadline' => $deadline, + 'priority' => $priority, + ]; + } + } + + // 4. Format Action Items and To-Do Items + $actionItems = []; + $todoItems = []; + + foreach ($extractedTasks as $idx => $t) { + $num = $idx + 1; + $actionItems[] = [ + 'task' => $t['task'], + 'owner' => $t['owner'], + 'deadline' => $t['deadline'], + 'status' => 'Pending', + ]; + $todoItems[] = [ + 'id' => $num, + 'task' => $t['task'], + 'owner' => $t['owner'], + 'priority' => $t['priority'], + 'deadline' => $t['deadline'], + 'completed' => false, + ]; + } + + // 5. Format Key Decisions + $keyDecisions = []; + $uniqueDecisions = array_unique($extractedDecisions); + foreach ($uniqueDecisions as $d) { + $keyDecisions[] = ['decision' => $d]; + } + + // 6. Build Grounded Minutes of Meeting (MoM) Content + $dateStr = now()->format('F j, Y - h:i A'); + + $mom = "### Minutes of Meeting (MoM)\n" + . "**Project:** {$proj}\n" + . "**Client:** {$client}\n" + . "**Date:** {$dateStr}\n\n"; + + // Section 1: Objectives + $mom .= "#### 1. Meeting Objectives\n"; + if (!empty($extractedDiscussionPoints)) { + $firstTopic = $extractedDiscussionPoints[0]; + $mom .= "- Review and sync on {$proj} progress, address client requirements, and align on deliverables regarding: {$firstTopic}.\n\n"; + } else { + $mom .= "- Sync meeting between project stakeholders and {$client}.\n\n"; + } + + // Section 2: Discussion Points + $mom .= "#### 2. Key Discussion Points\n"; + if (!empty($extractedDiscussionPoints)) { + $distinctPoints = array_slice(array_unique($extractedDiscussionPoints), 0, 10); + foreach ($distinctPoints as $pt) { + $mom .= "- " . ucfirst($pt) . "\n"; + } + $mom .= "\n"; + } else { + $mom .= "- No discussion points or live notes recorded during this session.\n\n"; + } + + // Section 3: Challenges / Blockers + $mom .= "#### 3. Challenges / Blockers Raised\n"; + if (!empty($extractedBlockers)) { + $distinctBlockers = array_slice(array_unique($extractedBlockers), 0, 5); + foreach ($distinctBlockers as $b) { + $mom .= "- " . ucfirst($b) . "\n"; + } + $mom .= "\n"; + } elseif ($sentiment === 'angry') { + $mom .= "- Client voiced dissatisfaction regarding deliverables and timeline. Proactive resolution underway.\n\n"; + } else { + $mom .= "- No critical blockers or unresolved obstacles raised during this sync.\n\n"; + } + + // Section 4: Agreed Next Steps + $mom .= "#### 4. Agreed Next Steps & Timeline\n"; + if (!empty($actionItems)) { + foreach ($actionItems as $act) { + $mom .= "- **{$act['owner']}:** {$act['task']} (Target: {$act['deadline']})\n"; + } + } else { + $mom .= "- Continue standard project roadmap and schedule follow-up check-in as needed.\n"; + } + + // 7. Build Grounded Executive Summary + if (!empty($extractedDiscussionPoints)) { + $topicsSlice = array_slice(array_unique($extractedDiscussionPoints), 0, 3); + $summaryTopics = implode('; ', $topicsSlice); + $taskCount = count($todoItems); + $summary = "Strategic sync call held for {$proj} with {$client}. Discussion centered around: {$summaryTopics}. The team agreed on {$taskCount} specific action item(s) and aligned on next milestones."; + } else { + $summary = "Meeting session concluded for {$proj} with {$client}. No live notes or spoken speech transcript were recorded during this call."; + } + + // 8. Build Emotion Details & Angry Analysis $angryAnalysis = [ 'is_angry' => false, 'trigger_words' => [], @@ -582,91 +927,20 @@ protected function generateFallbackMoM(string $combinedContent, string $transcri 'suggested_remedy' => '', ]; - if (count($foundAngry) > 0) { - $sentiment = 'angry'; + if ($sentiment === 'angry') { $angryAnalysis = [ 'is_angry' => true, 'trigger_words' => $foundAngry, - 'summary_why_angry' => "During the conversation, the client expressed clear frustration regarding project execution and deliverables. Keywords identified: \"" . implode('", "', $foundAngry) . "\". Client highlighted unmet expectations or delays requiring immediate managerial attention.", - 'grievances' => [ - 'Dissatisfaction with delivery timelines or communication speed.', - 'Concerns regarding quality or specific functional requirements.', + 'summary_why_angry' => "Client expressed dissatisfaction during the conversation regarding project deliverables or timeline. Identified cues: \"" . implode('", "', $foundAngry) . "\".", + 'grievances' => !empty($extractedBlockers) ? array_slice($extractedBlockers, 0, 4) : [ + 'Concerns regarding delivery milestones or response timeliness.', + 'Specific functional requirements requiring escalation.' ], - 'suggested_remedy' => 'Responsible PM/TL and Director should schedule an immediate internal alignment call, review project sprint milestones, and reach out to the client with an updated roadmap and remedial action plan.', + 'suggested_remedy' => 'Responsible PM/TL and Director should schedule an internal alignment call and reach out to the client with a remedial action plan within 24h.', ]; - } elseif (count($foundSad) > 0) { - $sentiment = 'sad'; - } elseif (count($foundHappy) > 0) { - $sentiment = 'happy'; } - $summary = "Strategic sync call held between project team and {$clientName} regarding {$projectName}. Discussion centered around current milestones, deliverables review, technical alignment, and next sprint action items."; - - $mom = "### Minutes of Meeting (MoM)\n" - . "**Project:** " . ($projectName ?: 'Client Project') . "\n" - . "**Client:** " . ($clientName ?: 'Client Partner') . "\n" - . "**Date:** " . now()->format('F j, Y - h:i A') . "\n\n" - . "#### 1. Meeting Objectives\n" - . "- Review project status, milestone progress, and client feedback.\n" - . "- Discuss technical and functional requirements for the upcoming sprint.\n\n" - . "#### 2. Key Discussion Points\n" - . "- **Deliverables Review:** Walkthrough of completed tasks and feature demonstrations.\n" - . "- **Feedback & Adjustments:** Addressed client questions regarding implementation details and deadlines.\n" - . (!empty($liveNotes) ? "- **Meeting Notes Taken:** " . $liveNotes . "\n" : "") - . "\n#### 3. Challenges & Resolution Items\n" - . ($sentiment === 'angry' ? "- Client voiced strong concerns regarding deliverables. Immediate escalation and proactive resolution underway.\n" : "- Standard development alignment and timeline management.\n") - . "\n#### 4. Agreed Next Steps\n" - . "- Engineering team to incorporate feedback items into next release.\n" - . "- PM/TL to share sprint summary and confirm milestone delivery date."; - - $actionItems = [ - [ - 'task' => 'Follow up on feedback points discussed in client meeting', - 'owner' => 'Project Manager & Tech Lead', - 'deadline' => now()->addDays(2)->format('M d, Y'), - 'status' => 'Pending', - ], - [ - 'task' => 'Share updated sprint timeline and milestone deliverable checklist', - 'owner' => 'Tech Lead', - 'deadline' => now()->addDays(3)->format('M d, Y'), - 'status' => 'Pending', - ] - ]; - - $todoItems = [ - [ - 'id' => 1, - 'task' => 'Deploy latest sprint milestone updates to staging environment', - 'owner' => 'Engineering Team', - 'priority' => 'High', - 'deadline' => now()->addDays(2)->format('M d, Y'), - 'completed' => false, - ], - [ - 'id' => 2, - 'task' => 'Provide updated API contracts and sandbox test credentials', - 'owner' => 'Tech Lead', - 'priority' => 'Medium', - 'deadline' => now()->addDays(3)->format('M d, Y'), - 'completed' => false, - ], - [ - 'id' => 3, - 'task' => 'Follow up on client feedback and confirm next review date', - 'owner' => 'Project Manager', - 'priority' => 'Medium', - 'deadline' => now()->addDays(4)->format('M d, Y'), - 'completed' => false, - ] - ]; - - $keyDecisions = [ - ['decision' => 'Approved latest feature progress and agreed on milestone deliverables for next cycle.'], - ['decision' => 'Scheduled regular check-in review to maintain transparency.'] - ]; - - $emotionDetails = $this->buildFallbackEmotionDetails($sentiment, $projectName, $clientName, $summary, $foundHappy, $foundSad, $foundAngry); + $emotionDetails = $this->buildFallbackEmotionDetails($sentiment, $proj, $client, $summary, $foundHappy, $foundSad, $foundAngry, $extractedBlockers, $extractedDiscussionPoints); return [ 'summary' => $summary, @@ -689,59 +963,73 @@ protected function generateFallbackMoM(string $combinedContent, string $transcri /** * Build structured emotion breakdown for popups (Why happy, why sad, why angry). */ - protected function buildFallbackEmotionDetails(string $sentiment, string $projectName, string $clientName, string $summary, array $happyWords = [], array $sadWords = [], array $angryWords = []): array + protected function buildFallbackEmotionDetails(string $sentiment, string $projectName, string $clientName, string $summary, array $happyWords = [], array $sadWords = [], array $angryWords = [], array $blockers = [], array $topics = []): array { switch ($sentiment) { case 'happy': + $happyPoints = [ + "Expressed satisfaction with project deliverables and discussed progress.", + "Positive alignment on current milestones and next sprint schedule.", + ]; + if (!empty($topics)) { + $happyPoints[] = "Constructive discussion on: " . $topics[0]; + } return [ 'emotion' => 'happy', - 'headline' => "Client Expressed High Satisfaction & Praise", - 'bullet_points' => [ - "Expressed enthusiasm regarding completed sprint milestones and feature quality.", - "Complimented speed of execution and transparency during the demo.", - "Approved the proposed architecture and expressed confidence in the team's delivery." - ], + 'headline' => "Client Expressed High Satisfaction & Positive Feedback", + 'bullet_points' => $happyPoints, 'trigger_quotes' => $happyWords ?: ['Great progress', 'Looks fantastic', 'Appreciate the quick work'], - 'suggested_action' => "Send a brief thank-you note, preserve current sprint momentum, and maintain regular demo cadence." + 'suggested_action' => "Send a brief thank-you follow-up and maintain current sprint delivery cadence." ]; case 'sad': + $sadPoints = [ + "Expressed concern or hesitation regarding upcoming milestones or scope.", + "Requested additional verification and testing before final acceptance." + ]; + if (!empty($blockers)) { + $sadPoints[] = "Raised concern regarding: " . $blockers[0]; + } return [ 'emotion' => 'sad', 'headline' => "Client Expressed Concern / Hesitation", - 'bullet_points' => [ - "Expressed worries regarding upcoming production release deadlines.", - "Voiced hesitation about scope changes or budget allocations.", - "Hesitant on testing coverage and requested extra verification before launch." - ], + 'bullet_points' => $sadPoints, 'trigger_quotes' => $sadWords ?: ['Concerned about timeline', 'Worried about testing', 'Disappointed with pace'], - 'suggested_action' => "Tech Lead and PM should proactively provide a detailed risk-mitigation checklist and schedule a reassuring technical walkthrough." + 'suggested_action' => "Tech Lead and PM should proactively provide a detailed risk-mitigation checklist and schedule a clarifying walkthrough." ]; case 'angry': + $angryPoints = [ + "Voiced dissatisfaction regarding project deliverables or milestone delays.", + "Requested immediate attention and prompt remedial resolution." + ]; + if (!empty($blockers)) { + $angryPoints = array_slice($blockers, 0, 3); + } return [ 'emotion' => 'angry', 'headline' => "Client Frustration & Escalation Alert", - 'bullet_points' => [ - "Voiced intense dissatisfaction regarding milestone delivery delays.", - "Highlighted broken modules or recurring bugs in staging environment.", - "Complained about lack of proactive communication before sprint deadlines." - ], + 'bullet_points' => $angryPoints, 'trigger_quotes' => $angryWords ?: ['Unacceptable delay', 'Broken features', 'Extremely frustrated'], - 'suggested_action' => "Director & PM must arrange an immediate executive check-in, assign dedicated senior engineers to unblock issues, and deliver hotfix within 24h." + 'suggested_action' => "Director & PM must arrange an immediate internal alignment and reach out with an expedited resolution." ]; default: + $neutralPoints = [ + "Meeting proceeded with standard professional tone and constructive collaboration.", + "No major blockers or heightened emotional triggers detected." + ]; + if (!empty($topics)) { + $neutralPoints[] = "Discussed items: " . implode(', ', array_slice($topics, 0, 2)); + } return [ 'emotion' => 'neutral', 'headline' => "Professional & Standard Alignment", - 'bullet_points' => [ - "Meeting proceeded with standard corporate tone and routine inquiries.", - "No major blockers or heightened emotional triggers detected." - ], + 'bullet_points' => $neutralPoints, 'trigger_quotes' => [], - 'suggested_action' => "Continue regular sprint progress updates." + 'suggested_action' => "Continue regular milestone updates and sprint reviews." ]; } } } + diff --git a/public/js/client-call.js b/public/js/client-call.js index 379d678..534d6b8 100644 --- a/public/js/client-call.js +++ b/public/js/client-call.js @@ -444,10 +444,14 @@ function initSpeechRecognition() { }; speechRecognizer.onend = () => { - // Auto restart recognition - try { - speechRecognizer.start(); - } catch (err) {} + // Auto restart recognition with debounce + setTimeout(() => { + try { + if (speechRecognizer) { + speechRecognizer.start(); + } + } catch (err) {} + }, 300); }; speechRecognizer.start(); diff --git a/tests/Feature/ClientCallFeatureTest.php b/tests/Feature/ClientCallFeatureTest.php index d1563fc..093d94c 100644 --- a/tests/Feature/ClientCallFeatureTest.php +++ b/tests/Feature/ClientCallFeatureTest.php @@ -959,4 +959,51 @@ public function test_signaling_heartbeat_and_chat_messages(): void $this->assertNotEmpty($meeting->chat_messages); $this->assertEquals('Hello everyone!', $meeting->chat_messages[0]['text']); } + + public function test_actual_discussed_tasks_and_mom_are_extracted_from_notes_and_transcript(): void + { + Mail::fake(); + + $client = Client::create([ + 'client_name' => 'Michael Scott', + 'client_email' => 'michael@dunder.com', + 'project_name' => 'Paper Ordering Portal', + 'responsible_user_id' => $this->pm->id, + 'created_by' => $this->admin->id, + 'status' => 'active', + ]); + + $meeting = ClientMeeting::create([ + 'client_id' => $client->id, + 'title' => 'Feature Review Call', + 'meeting_code' => 'meet-paper-portal-77', + 'host_user_id' => $this->pm->id, + 'status' => 'in_progress', + ]); + + $response = $this->actingAs($this->pm) + ->postJson(route('client-calls.end-call', $meeting->meeting_code), [ + 'transcript' => "[10:01 AM] Michael: We need to change the logo to navy blue and add PayPal checkout by Friday.\n[10:02 AM] Alex Dev: I will implement PayPal checkout by Friday and update the logo.", + 'live_notes' => "- Fix shopping cart checkout discount code bug\n- Sarah will send updated invoice by tomorrow\n- Client approved the new homepage layout", + ]); + + $response->assertStatus(200) + ->assertJson(['success' => true]); + + $note = ClientCallNote::where('client_meeting_id', $meeting->id)->first(); + $this->assertNotNull($note); + + // Verify that extracted tasks contain the actual discussed items + $tasks = collect($note->todo_items)->pluck('task')->implode(' '); + $this->assertStringContainsString('shopping cart checkout discount code bug', strtolower($tasks)); + $this->assertStringContainsString('invoice by tomorrow', strtolower($tasks)); + + // Verify MoM content contains the actual discussion points + $this->assertStringContainsString('shopping cart checkout discount code bug', strtolower($note->mom_content)); + $this->assertStringContainsString('paypal', strtolower($note->mom_content)); + + // Verify that generic placeholder tasks are NOT present + $this->assertStringNotContainsString('deploy latest sprint milestone updates to staging environment', strtolower($tasks)); + $this->assertStringNotContainsString('provide updated api contracts and sandbox test credentials', strtolower($tasks)); + } }