Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.37% covered (warning)
87.37%
166 / 190
60.00% covered (warning)
60.00%
6 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
ProcessRolePlaySessionAsyncJob
87.37% covered (warning)
87.37%
166 / 190
60.00% covered (warning)
60.00%
6 / 10
46.73
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 handle
100.00% covered (success)
100.00%
64 / 64
100.00% covered (success)
100.00%
1 / 1
8
 backoff
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fetchVapiResponse
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 fetchByCallId
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
42
 fetchByListSearch
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
9.09
 chatCompletion
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
1
 generateFeedback
97.37% covered (success)
97.37%
37 / 38
0.00% covered (danger)
0.00%
0 / 1
6
 calculateTotalScore
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
7
 calculateTimeDifferenceInSeconds
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace App\Jobs;
4
5use App\Http\Models\AIPrompts;
6use App\Http\Models\Parameter;
7use App\Http\Models\RolePlayConversations;
8use App\Http\Services\NodeJsAIBridgeService;
9use App\Traits\ObjectMapper;
10use Illuminate\Bus\Queueable;
11use Illuminate\Contracts\Queue\ShouldQueue;
12use Illuminate\Foundation\Bus\Dispatchable;
13use Illuminate\Queue\InteractsWithQueue;
14use Illuminate\Queue\SerializesModels;
15use Illuminate\Support\Facades\Http;
16use Illuminate\Support\Facades\Log;
17
18/**
19 * Processes a roleplay session asynchronously.
20 *
21 * Fetches the VAPI call response, generates an AI evaluation of the conversation
22 * transcript, calculates scores based on the project's scorecard configuration,
23 * and updates the conversation with results and feedback.
24 */
25class ProcessRolePlaySessionAsyncJob implements ShouldQueue
26{
27    use Dispatchable, InteractsWithQueue, ObjectMapper, Queueable, SerializesModels;
28
29    /**
30     * The number of times the job may be attempted.
31     *
32     * @var int
33     */
34    public $tries = 5;
35
36    /**
37     * Create a new job instance.
38     *
39     * @param RolePlayConversations $conversation The conversation to process
40     * @param string $identifier The VAPI call identifier (metadata callId)
41     * @param string|null $vapiCallId The actual Vapi call ID returned by vapi.start()
42     */
43    public function __construct(
44        public RolePlayConversations $conversation,
45        public string $identifier,
46        public ?string $vapiCallId = null
47    ) {}
48
49    /**
50     * Execute the job.
51     *
52     * Fetches VAPI response, evaluates the transcript using AI,
53     * and updates the conversation with scores and feedback.
54     *
55     * @return void
56     */
57    public function handle(): void
58    {
59        $vapi = $this->fetchVapiResponse($this->identifier);
60
61        if (! $vapi) {
62            $this->conversation->status = 'failed';
63            $this->conversation->save();
64            Log::error('Failed to fetch VAPI response for identifier.', ['identifier' => $this->identifier]);
65
66            return;
67        }
68
69        try {
70            $this->conversation->load('project');
71
72            $type = $this->conversation->project->type;
73            $scorecard_config = $this->conversation->project->scorecard_config ?? [];
74
75            $scoreCardString = '';
76            foreach ($scorecard_config as $index => $config) {
77                $number = $index + 1;
78                $scoreCardString .= "### {$number}".($config['name'] ?? 'Unnamed Category')."\n";
79                foreach ($config['criteria'] ?? [] as $criteria) {
80                    $scoreCardString .= '- '.($criteria['name'] ?? 'Unnamed Criteria').': '.($criteria['description'] ?? '')."\n";
81                }
82            }
83
84            // Try loading evaluation prompt from AIPrompts first, fallback to Parameter
85            $aiPrompt = AIPrompts::where('product', 'roleplay_evaluation')
86                ->orderBy('version', 'desc')
87                ->first();
88
89            if ($aiPrompt && ! empty($aiPrompt->mission)) {
90                $scoringPromptTemplate = $aiPrompt->mission;
91            } else {
92                $evaluationPromptParam = Parameter::where('name', 'role_play_evaluation_prompt')->first();
93                $scoringPromptTemplate = $evaluationPromptParam->value ?? '';
94            }
95
96            $placeholders = [
97                '{type}',
98                '{scoring}',
99                '{transcript}',
100            ];
101
102            $replacements = [
103                $type,
104                $scoreCardString,
105                $vapi['transcript'],
106            ];
107
108            $prompt = str_replace($placeholders, $replacements, $scoringPromptTemplate);
109
110            $timeDifference = $this->calculateTimeDifferenceInSeconds($vapi['endedAt'], $vapi['startedAt']);
111
112            $rawCompletion = $this->chatCompletion($prompt);
113            $parsed_gpt_evaluation_score = json_decode(trim(str_replace(['```json', '```'], '', $rawCompletion)), true);
114
115            if (json_last_error() !== JSON_ERROR_NONE) {
116                throw new \Exception('Failed to parse Gemini JSON response. '.json_last_error_msg());
117            }
118
119            $totalScore = $this->calculateTotalScore($parsed_gpt_evaluation_score['scores'] ?? [], $scorecard_config);
120            $feedback = $this->generateFeedback($parsed_gpt_evaluation_score, $scorecard_config);
121
122            Log::info('Generate Score', [
123                'prompt' => $prompt,
124                'completion' => $rawCompletion,
125                'parsed_completion' => $parsed_gpt_evaluation_score,
126                'total_score' => $totalScore,
127                'feedback' => $feedback,
128            ]);
129
130            $this->conversation->update([
131                'transcript' => $vapi['transcript'],
132                'duration' => $timeDifference,
133                'status' => 'done',
134                'vapi_response' => $vapi,
135                'score_llm' => $parsed_gpt_evaluation_score,
136                'feedback' => $feedback,
137                'score' => $totalScore,
138            ]);
139
140            $this->conversation->project->calculateProgression($feedback);
141        } catch (\Exception $error) {
142            Log::error('Error processing role play session: '.$error->getMessage(), [
143                'conversation_id' => $this->conversation->id,
144                'trace' => $error->getTraceAsString(),
145            ]);
146            $this->conversation->status = 'failed';
147            $this->conversation->save();
148        }
149    }
150
151    /**
152     * Calculate the number of seconds to wait before retrying the job.
153     *
154     * @return array<int>
155     */
156    public function backoff(): array
157    {
158        return [10, 30, 60, 120, 300];
159    }
160
161    /**
162     * Fetch the VAPI call response by polling the API.
163     *
164     * Uses direct call ID fetch when available (preferred), falls back to list search.
165     *
166     * @param string $identifier The VAPI metadata call identifier
167     * @return array|null The VAPI response data, or null if not found
168     */
169    private function fetchVapiResponse(string $identifier): ?array
170    {
171        $baseUrl = rtrim(config('services.vapi.url', 'https://api.vapi.ai'), '/');
172        $token = config('services.vapi.token');
173
174        if (! $token) {
175            Log::error('VAPI API token is not configured.');
176
177            return null;
178        }
179
180        if ($this->vapiCallId) {
181            return $this->fetchByCallId($baseUrl, $token, $this->vapiCallId);
182        }
183
184        return $this->fetchByListSearch($baseUrl, $token, $identifier);
185    }
186
187    /**
188     * Fetch a VAPI call directly by its ID, polling until it reaches 'ended' status.
189     *
190     * @param string $baseUrl The VAPI API base URL
191     * @param string $token The VAPI API token
192     * @param string $callId The Vapi call ID
193     * @return array|null The call data, or null if not found/not ended
194     */
195    private function fetchByCallId(string $baseUrl, string $token, string $callId): ?array
196    {
197        $maxAttempts = 10;
198        $delayTime = 5;
199
200        for ($attempts = 0; $attempts < $maxAttempts; $attempts++) {
201            try {
202                $response = Http::withToken($token)
203                    ->acceptJson()
204                    ->get("{$baseUrl}/call/{$callId}");
205
206                if ($response->successful()) {
207                    $call = $response->json();
208                    if (($call['status'] ?? '') === 'ended') {
209                        return $call;
210                    }
211                }
212            } catch (\Exception $e) {
213                Log::warning('VAPI API fetch by ID attempt failed.', [
214                    'attempt' => $attempts + 1,
215                    'callId' => $callId,
216                    'error' => $e->getMessage(),
217                ]);
218            }
219
220            if ($attempts < $maxAttempts - 1) {
221                sleep($delayTime);
222            }
223        }
224
225        Log::error('VAPI call did not reach ended status after max attempts.', ['callId' => $callId]);
226
227        return null;
228    }
229
230    /**
231     * Fallback: search for a VAPI call by metadata identifier in the call list.
232     *
233     * @param string $baseUrl The VAPI API base URL
234     * @param string $token The VAPI API token
235     * @param string $identifier The metadata callId to match
236     * @return array|null The call data, or null if not found
237     */
238    private function fetchByListSearch(string $baseUrl, string $token, string $identifier): ?array
239    {
240        $maxAttempts = 10;
241        $delayTime = 5;
242
243        for ($attempts = 0; $attempts < $maxAttempts; $attempts++) {
244            try {
245                $response = Http::withToken($token)
246                    ->acceptJson()
247                    ->get("{$baseUrl}/call");
248
249                if ($response->successful()) {
250                    $vapiResponseList = $response->json();
251                    if (is_array($vapiResponseList)) {
252                        foreach (array_slice($vapiResponseList, 0, 10) as $item) {
253                            if (
254                                ($item['assistant']['metadata']['callId'] ?? null) === $identifier &&
255                                ($item['status'] ?? '') === 'ended'
256                            ) {
257                                return $item;
258                            }
259                        }
260                    }
261                }
262            } catch (\Exception $error) {
263                Log::warning('VAPI API list search attempt failed.', ['attempt' => $attempts + 1, 'error' => $error->getMessage()]);
264            }
265
266            if ($attempts < $maxAttempts - 1) {
267                sleep($delayTime);
268            }
269        }
270
271        Log::error('Could not find ended VAPI call after max attempts.', ['identifier' => $identifier]);
272
273        return null;
274    }
275
276    /**
277     * Send a prompt to the AI bridge for completion.
278     *
279     * @param string $prompt The prompt text to send
280     * @return string The AI-generated response text
281     */
282    private function chatCompletion(string $prompt): string
283    {
284        $bridgeService = app(NodeJsAIBridgeService::class);
285
286        $userId = $this->conversation->user_id;
287        $companyId = $this->conversation->company_id ?? null;
288
289        return $bridgeService->generate(
290            payload: [
291                'provider' => 'google',
292                'model' => 'gemini-2.5-flash',
293                'prompt' => $prompt,
294                'config' => [
295                    'maxOutputTokens' => 2500,
296                    'temperature' => 1,
297                    'topP' => 1,
298                    'thinkingBudget' => 0,
299                ],
300            ],
301            metadata: [
302                'user_id' => $userId,
303                'company_id' => $companyId,
304                'feature' => 'roleplay_evaluation',
305            ]
306        );
307    }
308
309    /**
310     * Generate structured feedback from the parsed AI evaluation response.
311     *
312     * @param array $parsedGptResponse The parsed AI evaluation scores and feedback
313     * @param array $scorecardConfig The scorecard configuration from the project
314     * @return array The structured feedback with positive, constructive, negative items and scored criteria
315     */
316    private function generateFeedback(array $parsedGptResponse, array $scorecardConfig): array
317    {
318        $feedback = [
319            'positive' => $parsedGptResponse['positive'] ?? [],
320            'constructive' => $parsedGptResponse['constructive'] ?? [],
321            'negative' => $parsedGptResponse['negative'] ?? [],
322            'scores' => [],
323        ];
324        $scoresArray = $parsedGptResponse['scores'] ?? [];
325
326        foreach ($scoresArray as $details) {
327            if (! isset($details['name'])) {
328                continue;
329            }
330
331            $scorecardItem = collect($scorecardConfig)->first(function ($item) use ($details) {
332                return ($item['name'] ?? '') === ($details['name'] ?? '');
333            });
334
335            $totalScore = 0;
336            $criteriaList = [];
337
338            $criterias = $details['criteria'] ?? [];
339            foreach ($criterias as $criterionDetails) {
340                $scorecardCriteriaItem = collect($scorecardItem['criteria'] ?? [])->first(function ($item) use ($criterionDetails) {
341                    return ($item['name'] ?? '') === ($criterionDetails['name'] ?? '');
342                });
343
344                if (isset($criterionDetails['score']) && is_numeric($criterionDetails['score'])) {
345                    $score = (float) $criterionDetails['score'];
346                    $score = max(($score - 1) * 25 * (($scorecardCriteriaItem['weight'] ?? 1) / 100), 0);
347
348                    $criteriaList[] = [
349                        'name' => $criterionDetails['name'] ?? 'N/A',
350                        'score' => $score,
351                        'feedback' => $criterionDetails['feedback'] ?? '',
352                    ];
353                    $totalScore += $score;
354                }
355            }
356
357            $totalScore = max($totalScore * (($scorecardItem['weight'] ?? 1) / 100), 0);
358
359            $feedback['scores'][] = [
360                'name' => $details['name'],
361                'score' => $totalScore,
362                'feedback' => $details['feedback'] ?? '',
363                'improve' => $details['improve'] ?? '',
364                'criteria' => $criteriaList,
365            ];
366        }
367
368        return $feedback;
369    }
370
371    /**
372     * Calculate the total weighted score from AI evaluation scores.
373     *
374     * @param array $scores The AI-generated scores per category
375     * @param array $scorecardConfig The scorecard configuration with weights
376     * @return float The total weighted score (0 or above)
377     */
378    private function calculateTotalScore(array $scores, array $scorecardConfig): float
379    {
380        $totalScore = 0;
381
382        foreach ($scores as $details) {
383            $scorecardScore = 0;
384
385            $scorecardItem = collect($scorecardConfig)->first(function ($item) use ($details) {
386                return ($item['name'] ?? '') === ($details['name'] ?? '');
387            });
388
389            if (isset($details['criteria']) && is_array($details['criteria'])) {
390                foreach ($details['criteria'] as $criteria) {
391                    $scorecardCriteriaItem = collect($scorecardItem['criteria'] ?? [])->first(function ($item) use ($criteria) {
392                        return ($item['name'] ?? '') === ($criteria['name'] ?? '');
393                    });
394
395                    if (isset($criteria['score']) && is_numeric($criteria['score'])) {
396                        $weight = $scorecardCriteriaItem['weight'] ?? 1;
397
398                        $scorecardScore += max(((float) $criteria['score'] - 1) * 25 * ($weight / 100), 0);
399                    }
400                }
401
402                $scorecardScore = max($scorecardScore * (($scorecardItem['weight'] ?? 1) / 100), 0);
403            }
404
405            $totalScore += max($scorecardScore, 0);
406        }
407
408        return max($totalScore, 0);
409    }
410
411    /**
412     * Calculate the absolute time difference between two timestamps in seconds.
413     *
414     * @param string $time1 The first timestamp string
415     * @param string $time2 The second timestamp string
416     * @return int The absolute difference in seconds
417     */
418    private function calculateTimeDifferenceInSeconds(string $time1, string $time2): int
419    {
420        return abs(strtotime($time1) - strtotime($time2));
421    }
422}