1036 lines
48 KiB
PHP
1036 lines
48 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Mail\ClientCallMomMail;
|
|
use App\Models\Client;
|
|
use App\Models\ClientCallNote;
|
|
use App\Models\User;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Mail;
|
|
|
|
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.
|
|
*
|
|
* @param string $transcript
|
|
* @param string $liveNotes
|
|
* @param string $projectName
|
|
* @param string $clientName
|
|
* @return array
|
|
*/
|
|
public function generateMoM(?string $transcript = '', ?string $liveNotes = '', ?string $projectName = '', ?string $clientName = ''): array
|
|
{
|
|
$transcript = trim((string)($transcript ?? ''));
|
|
$liveNotes = trim((string)($liveNotes ?? ''));
|
|
$projectName = trim((string)($projectName ?? ''));
|
|
$clientName = trim((string)($clientName ?? ''));
|
|
|
|
$apiKey = $this->getApiKey();
|
|
|
|
$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.");
|
|
|
|
if ($apiKey) {
|
|
$prompt = <<<PROMPT
|
|
You are an expert Executive AI Note Taker and Meeting Intelligence Specialist for corporate client video calls.
|
|
Analyze the provided meeting notes and transcript for project "{$projectName}" with client "{$clientName}".
|
|
|
|
CRITICAL INSTRUCTIONS:
|
|
1. You MUST extract all summary points, MoM content, action items, to-do items, decisions, and blockers STRICTLY and ONLY from the provided "Meeting Content" (live notes and spoken transcript).
|
|
2. DO NOT invent, hallucinate, or output generic software development tasks (such as "Deploy latest sprint updates", "Provide API contracts", "Follow up on feedback") unless those exact topics were explicitly discussed or noted in the meeting content.
|
|
3. If the participants discussed specific topics (e.g. updating a button color, fixing a login issue, revising a budget, shipping designs by Friday), your tasks and MoM points MUST specifically reflect those exact topics, names, deadlines, and requirements.
|
|
4. If only 1 or 2 specific tasks were discussed, return ONLY those 1 or 2 tasks. Do NOT pad with fake placeholder tasks.
|
|
5. If the meeting content is empty or contains no actionable dialogue, state in the summary that no discussion points were recorded and return empty arrays for tasks.
|
|
|
|
Generate a comprehensive meeting report in JSON with:
|
|
1. "summary": A concise executive summary strictly summarizing what was actually discussed (2-3 sentences).
|
|
2. "mom_content": Formatted Minutes of Meeting (MoM) in markdown sections:
|
|
### Minutes of Meeting (MoM)
|
|
**Project:** {$projectName} | **Client:** {$clientName} | **Date:** [Meeting Date]
|
|
#### 1. Meeting Objectives (Brief statement based on discussion topics)
|
|
#### 2. Key Discussion Points (Specific bullet points of actual topics discussed in notes/transcript)
|
|
#### 3. Challenges / Blockers Raised (Specific blockers raised, or "No critical blockers raised" if none)
|
|
#### 4. Agreed Next Steps & Timeline (Specific action items and deliverables agreed upon)
|
|
3. "action_items": An array of objects with keys {"task": string, "owner": string, "deadline": string, "status": "Pending"}.
|
|
4. "todo_items": An array of actionable To-Do tasks derived from the conversation with keys {"id": int, "task": string, "owner": string, "priority": "High"|"Medium"|"Low", "deadline": string, "completed": boolean (false)}.
|
|
5. "key_decisions": An array of objects with key {"decision": string}.
|
|
6. "detected_sentiment": One of ["happy", "neutral", "sad", "angry"]. If the client expressed irritation, delay complaints, missed expectations, budget disputes, bug issues, or anger, classify as "angry". If client expressed concern, hesitation, worry, or sadness, classify as "sad". If client was pleased, praising, satisfied, or enthusiastic, classify as "happy". Otherwise "neutral".
|
|
7. "sentiment_breakdown": An object with integer percentages {"happy_pct": int, "neutral_pct": int, "sad_pct": int, "angry_pct": int} summing to 100.
|
|
8. "emotion_details": An object explaining the client's emotional state with:
|
|
{
|
|
"emotion": "happy"|"sad"|"angry"|"neutral",
|
|
"headline": string (e.g. "Client Delighted with Deliverables" / "Client Expressed Timeline Concerns" / "Client Frustrated Over Delay"),
|
|
"bullet_points": array of strings (bullet points explaining specifically WHY the client is happy, why sad, or why angry based on actual discussion),
|
|
"trigger_quotes": array of strings (exact phrases spoken by client reflecting this emotion),
|
|
"suggested_action": string (recommended action for PM, TL, and Director)
|
|
}
|
|
9. "angry_analysis": If detected_sentiment is "angry" (or if client showed significant frustration), provide:
|
|
{
|
|
"is_angry": true,
|
|
"trigger_words": array of strings,
|
|
"summary_why_angry": string,
|
|
"grievances": array of strings,
|
|
"suggested_remedy": string
|
|
}
|
|
If not angry, set "angry_analysis" to {"is_angry": false, "trigger_words": [], "summary_why_angry": "", "grievances": [], "suggested_remedy": ""}.
|
|
|
|
Respond ONLY with valid JSON matching this schema.
|
|
PROMPT;
|
|
|
|
// 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
|
|
]
|
|
]);
|
|
|
|
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, $transcript, $liveNotes);
|
|
}
|
|
}
|
|
} catch (\Throwable $e) {
|
|
Log::warning("AI MoM Model {$model} error: " . $e->getMessage());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Intelligent Local NLP Extraction Fallback Engine (strictly grounded in actual transcript and live notes)
|
|
return $this->generateFallbackMoM($combinedContent, $transcript, $liveNotes, $projectName, $clientName);
|
|
}
|
|
|
|
/**
|
|
* Real-time Emotion / Expression Detection for live client video calls.
|
|
*
|
|
* @param string $speechText
|
|
* @param string|null $imageFrameBase64
|
|
* @return 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 ?? ''));
|
|
|
|
// 1. Check for negative polarity / negations that invert positive sentiment
|
|
$negatedHappyPhrases = [
|
|
'not happy', 'not satisfied', 'not good', 'not great', 'not working',
|
|
'not impressed', 'not pleased', 'never works', 'no progress', 'displeased',
|
|
'unhappy', 'dissatisfied', 'don\'t like', 'do not like', 'cannot accept',
|
|
'unacceptable', 'waste of time', 'waste of money', 'broken feature', 'delays'
|
|
];
|
|
|
|
$foundNegations = [];
|
|
foreach ($negatedHappyPhrases as $negPhrase) {
|
|
if (preg_match('/\b' . preg_quote($negPhrase, '/') . '\b/i', $lowerText)) {
|
|
$foundNegations[] = $negPhrase;
|
|
}
|
|
}
|
|
|
|
// 2. Angry / Frustrated trigger keywords
|
|
$angryTriggers = [
|
|
'angry', 'furious', 'unacceptable', 'ridiculous', 'terrible', 'waste of time',
|
|
'delay', 'delays', 'delayed', 'broken', 'disaster', 'unhappy', 'frustrated',
|
|
'mad', 'annoyed', 'horrible', 'cancel', 'lawyer', 'refund', 'fail', 'failed',
|
|
'incompetent', 'worst', 'escalate', 'escalation', 'disgusted', 'blunder',
|
|
'mess', 'screwed', 'unprofessional', 'missed deadline', 'not delivering'
|
|
];
|
|
|
|
// 3. Sad / Concerned / Hesitant trigger keywords
|
|
$sadTriggers = [
|
|
'sad', 'disappointed', 'disappointment', 'worried', 'worry', 'concerned',
|
|
'concern', 'nervous', 'scared', 'afraid', 'regret', 'loss', 'hopeless',
|
|
'sorry', 'doubt', 'doubtful', 'hesitant', 'struggling', 'anxious',
|
|
'over budget', 'falling behind', 'uncertain', 'at risk'
|
|
];
|
|
|
|
// 4. Happy / Satisfied / Praising trigger keywords
|
|
$happyTriggers = [
|
|
'great', 'excellent', 'fantastic', 'awesome', 'good job', 'love it',
|
|
'happy', 'pleased', 'perfect', 'superb', 'wonderful', 'thank you',
|
|
'amazing', 'impressed', 'kudos', 'brilliant', 'exceeded expectations',
|
|
'looks good', 'looks great', 'well done', 'appreciate', 'delighted',
|
|
'seamless', 'flawless', 'outstanding'
|
|
];
|
|
|
|
$detectedAngryWords = [];
|
|
foreach ($angryTriggers as $word) {
|
|
if (preg_match('/\b' . preg_quote($word, '/') . '\b/i', $lowerText)) {
|
|
$detectedAngryWords[] = $word;
|
|
}
|
|
}
|
|
if (!empty($foundNegations)) {
|
|
$detectedAngryWords = array_unique(array_merge($detectedAngryWords, $foundNegations));
|
|
}
|
|
|
|
$detectedSadWords = [];
|
|
foreach ($sadTriggers as $word) {
|
|
if (preg_match('/\b' . preg_quote($word, '/') . '\b/i', $lowerText)) {
|
|
$detectedSadWords[] = $word;
|
|
}
|
|
}
|
|
|
|
$detectedHappyWords = [];
|
|
// 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)) {
|
|
$detectedHappyWords[] = $word;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!empty($detectedAngryWords)) {
|
|
return [
|
|
'emotion' => 'angry',
|
|
'confidence' => 0.94,
|
|
'trigger_words' => array_values(array_unique($detectedAngryWords)),
|
|
'details' => 'Detected clear frustration, complaint, or dissatisfaction cues in conversation.',
|
|
'label' => 'Frustrated / Angry 😡'
|
|
];
|
|
}
|
|
|
|
if (!empty($detectedSadWords)) {
|
|
return [
|
|
'emotion' => 'sad',
|
|
'confidence' => 0.88,
|
|
'trigger_words' => array_values(array_unique($detectedSadWords)),
|
|
'details' => 'Detected concern, hesitation, or worry in speech dialogue.',
|
|
'label' => 'Concerned / Sad 😟'
|
|
];
|
|
}
|
|
|
|
if (!empty($detectedHappyWords)) {
|
|
return [
|
|
'emotion' => 'happy',
|
|
'confidence' => 0.90,
|
|
'trigger_words' => array_values(array_unique($detectedHappyWords)),
|
|
'details' => 'Detected positive satisfaction, appreciation, or praise in speech.',
|
|
'label' => 'Happy / Satisfied 😊'
|
|
];
|
|
}
|
|
|
|
// If vision image frame is provided and Gemini API key is available
|
|
if ($apiKey && $imageFrameBase64 && str_starts_with($imageFrameBase64, 'data:image')) {
|
|
try {
|
|
$parts = explode(',', $imageFrameBase64);
|
|
$base64Data = $parts[1] ?? '';
|
|
$mimeType = 'image/jpeg';
|
|
if (preg_match('/data:(image\/[a-zA-Z]+);base64/', $parts[0], $matches)) {
|
|
$mimeType = $matches[1];
|
|
}
|
|
|
|
$response = Http::timeout(5)->post("https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key={$apiKey}", [
|
|
'contents' => [
|
|
[
|
|
'parts' => [
|
|
[
|
|
'text' => 'Analyze the facial expression of the client in this video frame during a business call. Classify emotion strictly as one of: ["happy", "neutral", "sad", "angry"]. Return ONLY valid JSON: {"emotion": "happy"|"neutral"|"sad"|"angry", "confidence": number, "details": string}'
|
|
],
|
|
[
|
|
'inline_data' => [
|
|
'mime_type' => $mimeType,
|
|
'data' => $base64Data
|
|
]
|
|
]
|
|
]
|
|
]
|
|
],
|
|
'generationConfig' => [
|
|
'response_mime_type' => 'application/json',
|
|
'temperature' => 0.1
|
|
]
|
|
]);
|
|
|
|
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['emotion'])) {
|
|
$emotion = strtolower($data['emotion']);
|
|
$labels = [
|
|
'happy' => 'Happy / Satisfied 😊',
|
|
'neutral' => 'Neutral / Attentive 😐',
|
|
'sad' => 'Concerned / Sad 😟',
|
|
'angry' => 'Frustrated / Angry 😡'
|
|
];
|
|
return [
|
|
'emotion' => $emotion,
|
|
'confidence' => (float)($data['confidence'] ?? 0.85),
|
|
'trigger_words' => [],
|
|
'details' => $data['details'] ?? 'AI facial expression detection completed.',
|
|
'label' => $labels[$emotion] ?? 'Neutral / Attentive 😐'
|
|
];
|
|
}
|
|
}
|
|
} catch (\Throwable $e) {
|
|
Log::debug('AI Vision Emotion Error: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
return [
|
|
'emotion' => 'neutral',
|
|
'confidence' => 0.80,
|
|
'trigger_words' => [],
|
|
'details' => 'Client expression is calm and professional.',
|
|
'label' => 'Neutral / Professional 😐'
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Dispatch MoM & Notes via email to TL, Director, PM, and Admin.
|
|
*
|
|
* @param ClientCallNote $callNote
|
|
* @return array
|
|
*/
|
|
public function sendMoMEmails(ClientCallNote $callNote): array
|
|
{
|
|
$client = $callNote->client;
|
|
$meeting = $callNote->meeting;
|
|
|
|
$recipientEmails = [];
|
|
|
|
// 1. Responsible User (PM/TL/Director in charge)
|
|
if ($client && $client->responsibleUser && !empty($client->responsibleUser->email)) {
|
|
$recipientEmails[$client->responsibleUser->email] = $client->responsibleUser->name;
|
|
}
|
|
|
|
// 2. Creator
|
|
if ($client && $client->creator && !empty($client->creator->email)) {
|
|
$recipientEmails[$client->creator->email] = $client->creator->name;
|
|
}
|
|
|
|
// 3. Meeting Host
|
|
if ($meeting && $meeting->host && !empty($meeting->host->email)) {
|
|
$recipientEmails[$meeting->host->email] = $meeting->host->name;
|
|
}
|
|
|
|
// 4. Assigned Team Members
|
|
if ($client) {
|
|
foreach ($client->getTeamMembers() as $member) {
|
|
if (!empty($member->email)) {
|
|
$recipientEmails[$member->email] = $member->name;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 5. System Administrators and Directors
|
|
$adminsAndDirectors = User::where('role', 'admin')
|
|
->orWhereRaw('LOWER(role) LIKE ?', ['%director%'])
|
|
->orWhereRaw('LOWER(role) LIKE ?', ['%pm%'])
|
|
->orWhereRaw('LOWER(role) LIKE ?', ['%lead%'])
|
|
->get();
|
|
|
|
foreach ($adminsAndDirectors as $user) {
|
|
if (!empty($user->email)) {
|
|
$recipientEmails[$user->email] = $user->name;
|
|
}
|
|
}
|
|
|
|
// 6. Client emails if invited
|
|
if ($meeting && !empty($meeting->client_emails)) {
|
|
foreach ($meeting->client_emails as $email) {
|
|
if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
|
$recipientEmails[$email] = $client->client_name ?? 'Client';
|
|
}
|
|
}
|
|
}
|
|
|
|
$sentCount = 0;
|
|
$sentAddresses = [];
|
|
|
|
foreach ($recipientEmails as $email => $name) {
|
|
try {
|
|
Mail::to($email)->send(new ClientCallMomMail($callNote, $name));
|
|
$sentAddresses[] = $email;
|
|
$sentCount++;
|
|
} catch (\Throwable $e) {
|
|
Log::error("Failed to send MoM email to {$email}: " . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
$callNote->update([
|
|
'emailed_at' => now(),
|
|
'emailed_to' => $sentAddresses,
|
|
]);
|
|
|
|
return [
|
|
'success' => true,
|
|
'sent_count' => $sentCount,
|
|
'recipients' => $sentAddresses,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Format MoM into clean, human-readable plain text for .txt download.
|
|
*
|
|
* @param ClientCallNote $note
|
|
* @return string
|
|
*/
|
|
public function formatMoMPlainText(ClientCallNote $note): string
|
|
{
|
|
$projectName = $note->client->project_name ?? 'Client Project';
|
|
$clientName = $note->client->client_name ?? 'Client';
|
|
$company = $note->client->company_name ? " ({$note->client->company_name})" : "";
|
|
$meetingTitle = $note->meeting->title ?? 'Client Call';
|
|
$date = $note->created_at ? $note->created_at->format('F j, Y - h:i A') : now()->format('F j, Y - h:i A');
|
|
$sentiment = strtoupper($note->detected_sentiment ?? 'NEUTRAL');
|
|
|
|
$out = "================================================================================\n";
|
|
$out .= " SINGLELOGIN AI MEETING INTELLIGENCE - MINUTES OF MEETING (MoM)\n";
|
|
$out .= "================================================================================\n\n";
|
|
$out .= "PROJECT: {$projectName}\n";
|
|
$out .= "CLIENT: {$clientName}{$company}\n";
|
|
$out .= "MEETING TITLE: {$meetingTitle}\n";
|
|
$out .= "DATE & TIME: {$date}\n";
|
|
$out .= "CLIENT MOOD: {$sentiment}\n";
|
|
$out .= "NOTE HOST: " . ($note->creator->name ?? 'SingleLogin Host') . "\n";
|
|
$out .= "--------------------------------------------------------------------------------\n\n";
|
|
|
|
$out .= "1. EXECUTIVE SUMMARY\n";
|
|
$out .= "--------------------\n";
|
|
$out .= ($note->summary ?? 'Meeting completed successfully.') . "\n\n";
|
|
|
|
if (!empty($note->action_items) && count($note->action_items) > 0) {
|
|
$out .= "2. ACTION ITEMS & DELIVERABLES\n";
|
|
$out .= "------------------------------\n";
|
|
foreach ($note->action_items as $idx => $act) {
|
|
$num = $idx + 1;
|
|
$task = is_array($act) ? ($act['task'] ?? json_encode($act)) : $act;
|
|
$owner = is_array($act) ? ($act['owner'] ?? 'Team') : 'Team';
|
|
$deadline = is_array($act) ? ($act['deadline'] ?? 'TBD') : 'TBD';
|
|
$status = is_array($act) ? ($act['status'] ?? 'Pending') : 'Pending';
|
|
$out .= " [ ] {$num}. {$task}\n";
|
|
$out .= " Owner: {$owner} | Deadline: {$deadline} | Status: {$status}\n\n";
|
|
}
|
|
}
|
|
|
|
if (!empty($note->key_decisions) && count($note->key_decisions) > 0) {
|
|
$out .= "3. KEY DECISIONS MADE\n";
|
|
$out .= "---------------------\n";
|
|
foreach ($note->key_decisions as $idx => $dec) {
|
|
$num = $idx + 1;
|
|
$decision = is_array($dec) ? ($dec['decision'] ?? json_encode($dec)) : $dec;
|
|
$out .= " * {$decision}\n";
|
|
}
|
|
$out .= "\n";
|
|
}
|
|
|
|
$out .= "4. COMPLETE MINUTES OF MEETING CONTENT\n";
|
|
$out .= "--------------------------------------\n";
|
|
$out .= ($note->mom_content ?? 'No content.') . "\n\n";
|
|
|
|
$out .= "================================================================================\n";
|
|
$out .= " Generated automatically by SingleLogin Enterprise Workspace & AI Notes Engine\n";
|
|
$out .= "================================================================================\n";
|
|
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* Format To-Do List into clean plain text for .txt download.
|
|
*
|
|
* @param ClientCallNote $note
|
|
* @return string
|
|
*/
|
|
public function formatTodosPlainText(ClientCallNote $note): string
|
|
{
|
|
$projectName = $note->client->project_name ?? 'Client Project';
|
|
$clientName = $note->client->client_name ?? 'Client';
|
|
$meetingTitle = $note->meeting->title ?? 'Client Call';
|
|
$date = $note->created_at ? $note->created_at->format('F j, Y') : now()->format('F j, Y');
|
|
|
|
$out = "================================================================================\n";
|
|
$out .= " SINGLELOGIN - ACTIONABLE TO-DO LIST & TASK DELIVERABLES\n";
|
|
$out .= "================================================================================\n";
|
|
$out .= "Project: {$projectName}\n";
|
|
$out .= "Client: {$clientName}\n";
|
|
$out .= "Meeting: {$meetingTitle}\n";
|
|
$out .= "Date: {$date}\n";
|
|
$out .= "================================================================================\n\n";
|
|
|
|
$todos = $note->todo_items ?? [];
|
|
if (empty($todos) && !empty($note->action_items)) {
|
|
$todos = $note->action_items;
|
|
}
|
|
|
|
if (empty($todos)) {
|
|
$out .= "No specific actionable tasks were logged for this call.\n";
|
|
} else {
|
|
foreach ($todos as $idx => $item) {
|
|
$num = $idx + 1;
|
|
$task = is_array($item) ? ($item['task'] ?? json_encode($item)) : $item;
|
|
$owner = is_array($item) ? ($item['owner'] ?? 'Assigned Lead') : 'Assigned Lead';
|
|
$priority = is_array($item) ? ($item['priority'] ?? 'Medium') : 'Medium';
|
|
$deadline = is_array($item) ? ($item['deadline'] ?? 'TBD') : 'TBD';
|
|
$check = (is_array($item) && ($item['completed'] ?? false)) ? "[X]" : "[ ]";
|
|
|
|
$out .= "{$check} {$num}. {$task}\n";
|
|
$out .= " Assignee: {$owner}\n";
|
|
$out .= " Priority: {$priority}\n";
|
|
$out .= " Due Date: {$deadline}\n\n";
|
|
}
|
|
}
|
|
|
|
$out .= "--------------------------------------------------------------------------------\n";
|
|
$out .= "Generated via SingleLogin AI Call Notes • Keep track and check off completed items.\n";
|
|
$out .= "================================================================================\n";
|
|
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* Format MoM data to ensure all keys and structures exist.
|
|
*/
|
|
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'])) {
|
|
$sentiment = 'neutral';
|
|
}
|
|
|
|
$angryAnalysis = $data['angry_analysis'] ?? [];
|
|
if ($sentiment === 'angry' || (!empty($angryAnalysis) && ($angryAnalysis['is_angry'] ?? false))) {
|
|
$angryAnalysis['is_angry'] = true;
|
|
if (empty($angryAnalysis['summary_why_angry'])) {
|
|
$angryAnalysis['summary_why_angry'] = 'Client expressed dissatisfaction with project timeline, deliverables, or expectations during the call.';
|
|
}
|
|
} else {
|
|
$angryAnalysis = [
|
|
'is_angry' => false,
|
|
'trigger_words' => [],
|
|
'summary_why_angry' => '',
|
|
'grievances' => [],
|
|
'suggested_remedy' => '',
|
|
];
|
|
}
|
|
|
|
// Format emotion_details
|
|
$emotionDetails = $data['emotion_details'] ?? [];
|
|
if (empty($emotionDetails) || !isset($emotionDetails['bullet_points'])) {
|
|
$emotionDetails = $this->buildFallbackEmotionDetails($sentiment, $projectName, $clientName, $data['summary'] ?? '');
|
|
}
|
|
|
|
// 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,
|
|
];
|
|
}
|
|
}
|
|
|
|
// 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' => $actionItems,
|
|
'todo_items' => $todoItems,
|
|
'key_decisions' => $keyDecisions,
|
|
'detected_sentiment' => $sentiment,
|
|
'sentiment_breakdown' => $data['sentiment_breakdown'] ?? [
|
|
'happy_pct' => $sentiment === 'happy' ? 80 : 10,
|
|
'neutral_pct' => $sentiment === 'neutral' ? 80 : 10,
|
|
'sad_pct' => $sentiment === 'sad' ? 70 : 5,
|
|
'angry_pct' => $sentiment === 'angry' ? 85 : 5,
|
|
],
|
|
'angry_analysis' => $angryAnalysis,
|
|
'emotion_details' => $emotionDetails,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 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)));
|
|
|
|
$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) => (bool)preg_match('/\b' . preg_quote($w, '/') . '\b/i', $lower)),
|
|
$foundNegations
|
|
)));
|
|
$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) => (bool)preg_match('/\b' . preg_quote($w, '/') . '\b/i', $lower)));
|
|
}
|
|
|
|
$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' => [],
|
|
'summary_why_angry' => '',
|
|
'grievances' => [],
|
|
'suggested_remedy' => '',
|
|
];
|
|
|
|
if ($sentiment === 'angry') {
|
|
$angryAnalysis = [
|
|
'is_angry' => true,
|
|
'trigger_words' => $foundAngry,
|
|
'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 internal alignment call and reach out to the client with a remedial action plan within 24h.',
|
|
];
|
|
}
|
|
|
|
$emotionDetails = $this->buildFallbackEmotionDetails($sentiment, $proj, $client, $summary, $foundHappy, $foundSad, $foundAngry, $extractedBlockers, $extractedDiscussionPoints);
|
|
|
|
return [
|
|
'summary' => $summary,
|
|
'mom_content' => $mom,
|
|
'action_items' => $actionItems,
|
|
'todo_items' => $todoItems,
|
|
'key_decisions' => $keyDecisions,
|
|
'detected_sentiment' => $sentiment,
|
|
'sentiment_breakdown' => [
|
|
'happy_pct' => $sentiment === 'happy' ? 75 : 15,
|
|
'neutral_pct' => $sentiment === 'neutral' ? 70 : 15,
|
|
'sad_pct' => $sentiment === 'sad' ? 65 : 10,
|
|
'angry_pct' => $sentiment === 'angry' ? 80 : 5,
|
|
],
|
|
'angry_analysis' => $angryAnalysis,
|
|
'emotion_details' => $emotionDetails,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 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 $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 & Positive Feedback",
|
|
'bullet_points' => $happyPoints,
|
|
'trigger_quotes' => $happyWords ?: ['Great progress', 'Looks fantastic', 'Appreciate the quick work'],
|
|
'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' => $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 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' => $angryPoints,
|
|
'trigger_quotes' => $angryWords ?: ['Unacceptable delay', 'Broken features', 'Extremely frustrated'],
|
|
'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' => $neutralPoints,
|
|
'trigger_quotes' => [],
|
|
'suggested_action' => "Continue regular milestone updates and sprint reviews."
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|