diff --git a/app/Http/Controllers/ClientCallController.php b/app/Http/Controllers/ClientCallController.php index e76ba1d..eba395c 100644 --- a/app/Http/Controllers/ClientCallController.php +++ b/app/Http/Controllers/ClientCallController.php @@ -282,8 +282,8 @@ public function joinGuest(Request $request, $code) */ public function detectEmotion(Request $request, $code) { - $speechText = $request->input('speech_text', ''); - $imageFrame = $request->input('image_frame', null); + $speechText = (string)($request->input('speech_text') ?? ''); + $imageFrame = $request->input('image_frame'); $result = $this->aiService->detectEmotion($speechText, $imageFrame); diff --git a/app/Services/AiClientCallService.php b/app/Services/AiClientCallService.php index ae8d485..4a82907 100644 --- a/app/Services/AiClientCallService.php +++ b/app/Services/AiClientCallService.php @@ -21,8 +21,13 @@ class AiClientCallService * @param string $clientName * @return array */ - public function generateMoM(string $transcript, string $liveNotes = '', string $projectName = '', string $clientName = ''): array + public function generateMoM(?string $transcript = '', ?string $liveNotes = '', ?string $projectName = '', ?string $clientName = ''): array { + $transcript = (string)($transcript ?? ''); + $liveNotes = (string)($liveNotes ?? ''); + $projectName = (string)($projectName ?? ''); + $clientName = (string)($clientName ?? ''); + $apiKey = env('GEMINI_API_KEY') ?: (env('GOOGLE_API_KEY') ?: (getenv('GEMINI_API_KEY') ?: getenv('GOOGLE_API_KEY'))); $combinedContent = "Project: {$projectName}\nClient: {$clientName}\n\n"; @@ -105,11 +110,11 @@ public function generateMoM(string $transcript, string $liveNotes = '', string $ * @param string|null $imageFrameBase64 * @return array */ - public function detectEmotion(string $speechText = '', ?string $imageFrameBase64 = null): array + public function detectEmotion(?string $speechText = '', ?string $imageFrameBase64 = null): array { $apiKey = env('GEMINI_API_KEY') ?: (env('GOOGLE_API_KEY') ?: (getenv('GEMINI_API_KEY') ?: getenv('GOOGLE_API_KEY'))); - $lowerText = strtolower(trim($speechText)); + $lowerText = strtolower(trim($speechText ?? '')); // 1. Check for negative polarity / negations that invert positive sentiment $negatedHappyPhrases = [ @@ -121,7 +126,7 @@ public function detectEmotion(string $speechText = '', ?string $imageFrameBase64 $foundNegations = []; foreach ($negatedHappyPhrases as $negPhrase) { - if (str_contains($lowerText, $negPhrase)) { + if (preg_match('/\b' . preg_quote($negPhrase, '/') . '\b/i', $lowerText)) { $foundNegations[] = $negPhrase; } } @@ -154,7 +159,7 @@ public function detectEmotion(string $speechText = '', ?string $imageFrameBase64 $detectedAngryWords = []; foreach ($angryTriggers as $word) { - if (preg_match('/\b' . preg_quote($word, '/') . '\b/i', $lowerText) || str_contains($lowerText, $word)) { + if (preg_match('/\b' . preg_quote($word, '/') . '\b/i', $lowerText)) { $detectedAngryWords[] = $word; } } @@ -164,7 +169,7 @@ public function detectEmotion(string $speechText = '', ?string $imageFrameBase64 $detectedSadWords = []; foreach ($sadTriggers as $word) { - if (preg_match('/\b' . preg_quote($word, '/') . '\b/i', $lowerText) || str_contains($lowerText, $word)) { + if (preg_match('/\b' . preg_quote($word, '/') . '\b/i', $lowerText)) { $detectedSadWords[] = $word; } } @@ -173,7 +178,7 @@ public function detectEmotion(string $speechText = '', ?string $imageFrameBase64 // Only consider happy words if there are no strong negations of happiness if (empty($foundNegations)) { foreach ($happyTriggers as $word) { - if (preg_match('/\b' . preg_quote($word, '/') . '\b/i', $lowerText) || str_contains($lowerText, $word)) { + if (preg_match('/\b' . preg_quote($word, '/') . '\b/i', $lowerText)) { $detectedHappyWords[] = $word; } } @@ -551,21 +556,21 @@ protected function generateFallbackMoM(string $combinedContent, string $transcri $lower = strtolower($combinedContent); $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) => str_contains($lower, $w))); + $foundNegations = array_values(array_filter($negatedHappyPhrases, fn($w) => (bool)preg_match('/\b' . preg_quote($w, '/') . '\b/i', $lower))); $angryWords = ['angry', 'furious', 'unacceptable', 'ridiculous', 'terrible', 'waste of time', 'delay', 'delays', 'delayed', 'broken', 'disaster', 'unhappy', 'frustrated', 'mad', 'annoyed', 'horrible', 'refund', 'cancel', 'fail', 'incompetent', 'unprofessional', 'missed deadline']; $sadWords = ['sad', 'disappointed', 'worried', 'concerned', 'nervous', 'scared', 'afraid', 'regret', 'doubt', 'hesitant', 'uncertain', 'at risk']; $happyWords = ['great', 'excellent', 'fantastic', 'awesome', 'good job', 'love it', 'happy', 'pleased', 'perfect', 'superb', 'wonderful', 'thank you', 'appreciate', 'impressed', 'kudos', 'brilliant', 'delighted']; $foundAngry = array_values(array_unique(array_merge( - array_filter($angryWords, fn($w) => str_contains($lower, $w)), + array_filter($angryWords, fn($w) => (bool)preg_match('/\b' . preg_quote($w, '/') . '\b/i', $lower)), $foundNegations ))); - $foundSad = array_values(array_filter($sadWords, fn($w) => str_contains($lower, $w))); + $foundSad = array_values(array_filter($sadWords, fn($w) => (bool)preg_match('/\b' . preg_quote($w, '/') . '\b/i', $lower))); $foundHappy = []; if (empty($foundNegations)) { - $foundHappy = array_values(array_filter($happyWords, fn($w) => str_contains($lower, $w))); + $foundHappy = array_values(array_filter($happyWords, fn($w) => (bool)preg_match('/\b' . preg_quote($w, '/') . '\b/i', $lower))); } $sentiment = 'neutral'; diff --git a/tests/Feature/ClientCallFeatureTest.php b/tests/Feature/ClientCallFeatureTest.php index c81da8e..d1563fc 100644 --- a/tests/Feature/ClientCallFeatureTest.php +++ b/tests/Feature/ClientCallFeatureTest.php @@ -782,4 +782,181 @@ public function test_client_hub_and_logs_pages_render_recording_actions_when_pre ->assertSee('Play Video') ->assertSee('Download Video Recording', false); } + + public function test_ai_emotion_detection_all_scenarios(): void + { + $client = Client::create([ + 'client_name' => 'Emotion Test Client', + 'client_email' => 'emotion@test.com', + 'project_name' => 'Emotion AI Testing', + 'responsible_user_id' => $this->pm->id, + 'created_by' => $this->admin->id, + 'status' => 'active', + ]); + + $meeting = ClientMeeting::create([ + 'client_id' => $client->id, + 'title' => 'Emotion Scenarios Test', + 'meeting_code' => 'meet-emotion-scenarios', + 'host_user_id' => $this->pm->id, + 'status' => 'in_progress', + ]); + + // Scenario 1: Frustrated speech -> Angry + $resAngry = $this->postJson(route('client-calls.detect-emotion', $meeting->meeting_code), [ + 'speech_text' => 'This continuous delay is unacceptable and we missed deadline.', + ]); + $resAngry->assertStatus(200)->assertJson(['emotion' => 'angry']); + + // Scenario 2: Negated happy phrase -> Angry + $resNegated = $this->postJson(route('client-calls.detect-emotion', $meeting->meeting_code), [ + 'speech_text' => 'We are not happy and not satisfied with the progress.', + ]); + $resNegated->assertStatus(200)->assertJson(['emotion' => 'angry']); + + // Scenario 3: Concern / hesitation -> Sad + $resSad = $this->postJson(route('client-calls.detect-emotion', $meeting->meeting_code), [ + 'speech_text' => 'We are worried and concerned about the upcoming launch date.', + ]); + $resSad->assertStatus(200)->assertJson(['emotion' => 'sad']); + + // Scenario 4: Praise and delight -> Happy + $resHappy = $this->postJson(route('client-calls.detect-emotion', $meeting->meeting_code), [ + 'speech_text' => 'The sprint demo looks fantastic and we love the new dashboard.', + ]); + $resHappy->assertStatus(200)->assertJson(['emotion' => 'happy']); + + // Scenario 5: Dialogue with words like "made" and "message" (formerly false positive triggers) -> Neutral + $resNeutral = $this->postJson(route('client-calls.detect-emotion', $meeting->meeting_code), [ + 'speech_text' => 'We made a roadmap plan and I will send you a message shortly.', + ]); + $resNeutral->assertStatus(200)->assertJson(['emotion' => 'neutral']); + + // Scenario 6: Empty speech -> Neutral + $resEmpty = $this->postJson(route('client-calls.detect-emotion', $meeting->meeting_code), [ + 'speech_text' => '', + ]); + $resEmpty->assertStatus(200)->assertJson(['emotion' => 'neutral']); + } + + public function test_ai_service_mom_generation_and_fallback_engine(): void + { + $aiService = app(\App\Services\AiClientCallService::class); + + // Test happy conversation MoM + $happyMoM = $aiService->generateMoM( + 'Client: The release looks great, wonderful job team!', + 'Reviewed sprint deliverables demo.', + 'Payment Gateway', + 'Fintech Corp' + ); + $this->assertEquals('happy', $happyMoM['detected_sentiment']); + $this->assertNotEmpty($happyMoM['summary']); + $this->assertNotEmpty($happyMoM['todo_items']); + $this->assertNotEmpty($happyMoM['emotion_details']); + + // Test angry conversation MoM + $angryMoM = $aiService->generateMoM( + 'Client: This broken feature and missed deadline is unacceptable!', + 'Client escalated timeline delay.', + 'Payment Gateway', + 'Fintech Corp' + ); + $this->assertEquals('angry', $angryMoM['detected_sentiment']); + $this->assertTrue($angryMoM['angry_analysis']['is_angry']); + $this->assertNotEmpty($angryMoM['angry_analysis']['trigger_words']); + + // Test sad conversation MoM + $sadMoM = $aiService->generateMoM( + 'Client: We are disappointed and worried about budget allocations.', + 'Discussion on risk factors.', + 'Payment Gateway', + 'Fintech Corp' + ); + $this->assertEquals('sad', $sadMoM['detected_sentiment']); + + // Test neutral conversation MoM + $neutralMoM = $aiService->generateMoM( + 'Client: We made notes and will review the API contracts next week.', + 'Standard sync.', + 'Payment Gateway', + 'Fintech Corp' + ); + $this->assertEquals('neutral', $neutralMoM['detected_sentiment']); + } + + public function test_resend_mom_email_endpoint(): void + { + Mail::fake(); + + $client = Client::create([ + 'client_name' => 'Email Test Client', + 'client_email' => 'emailclient@test.com', + 'project_name' => 'Email Dispatches', + 'responsible_user_id' => $this->pm->id, + 'created_by' => $this->admin->id, + 'status' => 'active', + ]); + + $meeting = ClientMeeting::create([ + 'client_id' => $client->id, + 'title' => 'MoM Dispatch Sync', + 'meeting_code' => 'meet-dispatch-789', + 'host_user_id' => $this->pm->id, + 'status' => 'completed', + ]); + + $note = ClientCallNote::create([ + 'client_meeting_id' => $meeting->id, + 'client_id' => $client->id, + 'created_by' => $this->pm->id, + 'summary' => 'MoM dispatch testing', + 'mom_content' => 'Full MoM details here.', + 'detected_sentiment' => 'happy', + ]); + + $response = $this->actingAs($this->pm) + ->postJson(route('client-calls.resend-email', $note->id)); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + ]); + + Mail::assertSent(ClientCallMomMail::class); + } + + public function test_signaling_heartbeat_and_chat_messages(): void + { + $client = Client::create([ + 'client_name' => 'Signal Client', + 'client_email' => 'signal@test.com', + 'project_name' => 'Signal Project', + 'responsible_user_id' => $this->pm->id, + 'created_by' => $this->admin->id, + 'status' => 'active', + ]); + + $meeting = ClientMeeting::create([ + 'client_id' => $client->id, + 'title' => 'WebRTC Signal Test', + 'meeting_code' => 'meet-signal-test-321', + 'host_user_id' => $this->pm->id, + 'status' => 'in_progress', + ]); + + $response = $this->postJson(route('client-calls.heartbeat', $meeting->meeting_code), [ + 'peer_id' => 'peer_test_1', + 'peer_name' => 'Alice Tester', + 'chat_message' => ['text' => 'Hello everyone!'], + ]); + + $response->assertStatus(200) + ->assertJsonStructure(['active_peers', 'signals', 'chat_messages']); + + $meeting->refresh(); + $this->assertNotEmpty($meeting->active_peers); + $this->assertNotEmpty($meeting->chat_messages); + $this->assertEquals('Hello everyone!', $meeting->chat_messages[0]['text']); + } }