Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.78% covered (success)
92.78%
167 / 180
60.00% covered (warning)
60.00%
6 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
ProcessRolePlaySessionAsyncJob
92.78% covered (success)
92.78%
167 / 180
60.00% covered (warning)
60.00%
6 / 10
39.57
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
6
 backoff
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 fetchVapiResponse
83.33% covered (warning)
83.33%
20 / 24
0.00% covered (danger)
0.00%
0 / 1
10.46
 chatCompletion
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
1
 parseGemini15Response
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 parseGemini15Response2
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 generateFeedback
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 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\Traits\ObjectMapper;
6use Illuminate\Bus\Queueable;
7use Illuminate\Contracts\Queue\ShouldQueue;
8use Illuminate\Foundation\Bus\Dispatchable;
9use Illuminate\Queue\InteractsWithQueue;
10use Illuminate\Queue\SerializesModels;
11use App\Http\Models\Parameter;
12use App\Http\Models\RolePlayConversations;
13use App\Services\FlyMsgAI\GeminiAPI;
14use Illuminate\Support\Facades\Http;
15use Illuminate\Support\Facades\Log;
16
17class ProcessRolePlaySessionAsyncJob implements ShouldQueue
18{
19    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels, ObjectMapper;
20
21    public $tries = 5;
22
23    public function __construct(
24        public RolePlayConversations $conversation,
25        public string $identifier
26    ) {}
27
28    public function handle(): void
29    {
30        $vapi = $this->fetchVapiResponse($this->identifier);
31
32        if (!$vapi) {
33            $this->conversation->status = 'failed';
34            $this->conversation->save();
35            Log::error('Failed to fetch VAPI response for identifier.', ['identifier' => $this->identifier]);
36            return;
37        }
38
39        try {
40            $this->conversation->load('project');
41
42            $type = $this->conversation->project->type;
43            $scorecard_config = $this->conversation->project->scorecard_config ?? [];
44
45            $scoreCardString = "";
46            foreach ($scorecard_config as $index => $config) {
47                $number = $index + 1;
48                $scoreCardString .= "### {$number}" . ($config['name'] ?? 'Unnamed Category') . "\n";
49                foreach ($config['criteria'] ?? [] as $criteria) {
50                    $scoreCardString .= "- " . ($criteria['name'] ?? 'Unnamed Criteria') . ": " . ($criteria['description'] ?? '') . "\n";
51                }
52            }
53
54            $evaluationPromptParam = Parameter::where('name', 'role_play_evaluation_prompt')->first();
55            $scoringPromptTemplate = $evaluationPromptParam->value ?? '';
56
57            $placeholders = [
58                '{type}',
59                '{scoring}',
60                '{transcript}',
61            ];
62
63            $replacements = [
64                $type,
65                $scoreCardString,
66                $vapi['transcript'],
67            ];
68
69            $prompt = str_replace($placeholders, $replacements, $scoringPromptTemplate);
70
71            $timeDifference = $this->calculateTimeDifferenceInSeconds($vapi['endedAt'], $vapi['startedAt']);
72
73            $this->conversation->update([
74                'transcript' => $vapi['transcript'],
75                'duration' => $timeDifference,
76                'vapi_response' => $vapi
77            ]);
78
79            $rawCompletion = $this->chatCompletion($prompt);
80            $parsed_gpt_evaluation_score = json_decode(trim(str_replace(['```json', '```'], '', $rawCompletion)), true);
81
82            if (json_last_error() !== JSON_ERROR_NONE) {
83                throw new \Exception("Failed to parse Gemini JSON response. " . json_last_error_msg());
84            }
85
86            $totalScore = $this->calculateTotalScore($parsed_gpt_evaluation_score["scores"] ?? [], $scorecard_config);
87            $feedback = $this->generateFeedback($parsed_gpt_evaluation_score, $scorecard_config);
88
89            Log::info('Generate Score', [
90                'prompt' => $prompt,
91                'completion' => $rawCompletion,
92                'parsed_completion' => $parsed_gpt_evaluation_score,
93                'total_score' => $totalScore,
94                'feedback' => $feedback
95            ]);
96
97            $this->conversation->update([
98                'transcript' => $vapi['transcript'],
99                'duration' => $timeDifference,
100                'status' => 'done',
101                'vapi_response' => $vapi,
102                'score_llm' => $parsed_gpt_evaluation_score,
103                'feedback' => $feedback,
104                'score' => $totalScore
105            ]);
106
107            $this->conversation->project->calculateProgression($feedback);
108        } catch (\Exception $error) {
109            Log::error('Error processing role play session: ' . $error->getMessage(), [
110                'conversation_id' => $this->conversation->id,
111                'trace' => $error->getTraceAsString()
112            ]);
113            $this->conversation->status = 'failed';
114            $this->conversation->save();
115        }
116    }
117
118    public function backoff(): array
119    {
120        return [10, 30, 60, 120, 300];
121    }
122
123    private function fetchVapiResponse(string $identifier): ?array
124    {
125        $maxAttempts = 10;
126        $delayTime = 5;
127
128        $url = config('services.vapi.url', 'https://api.vapi.ai/call');
129        $token = "059e9138-d883-498b-862c-e9ab8f31b298";
130
131        if (!$token) {
132            Log::error('VAPI API token is not configured.');
133            return null;
134        }
135
136        for ($attempts = 0; $attempts < $maxAttempts; $attempts++) {
137            try {
138                $response = Http::withToken($token)
139                    ->acceptJson()
140                    ->get($url);
141
142                if ($response->successful()) {
143                    $vapiResponseList = $response->json();
144                    if (is_array($vapiResponseList)) {
145                        foreach (array_slice($vapiResponseList, 0, 10) as $item) {
146                            if (
147                                ($item['assistant']['metadata']['callId'] ?? null) === $identifier &&
148                                ($item['status'] ?? '') === 'ended'
149                            ) {
150                                return $item;
151                            }
152                        }
153                    }
154                }
155            } catch (\Exception $error) {
156                Log::warning('VAPI API poll attempt failed.', ['attempt' => $attempts + 1, 'error' => $error->getMessage()]);
157            }
158
159            if ($attempts < $maxAttempts - 1) {
160                sleep($delayTime);
161            }
162        }
163
164        Log::error('Could not find ended VAPI call after max attempts.', ['identifier' => $identifier]);
165        return null;
166    }
167
168    private function chatCompletion(string $prompt): string
169    {
170        $model = "gemini-2.5-flash:generateContent";
171        $access_token = GeminiAPI::getAIAccessToken();
172
173        $generationConfig = [
174            "maxOutputTokens" => 2500,
175            "temperature" => 1,
176            "topP" => 1,
177            "thinkingConfig" => [
178                "thinkingBudget" => 0,
179            ],
180            // "topK" => 1
181        ];
182
183        $geminiAPI = new GeminiAPI($access_token);
184
185        $data = [
186            "contents" => [
187                "role" => "user",
188                "parts" => [
189                    [
190                        "text" => $prompt,
191                    ]
192                ]
193            ],
194            "generationConfig" => $generationConfig,
195        ];
196
197        $response = $geminiAPI->postCompletions($data, $model);
198
199        $rawCompletion = $response->getBody();
200        $responseData = json_decode($rawCompletion, true);
201
202        return $responseData['candidates'][0]['content']['parts'][0]['text'] ?? '';
203    }
204
205    private function parseGemini15Response($response)
206    {
207        $extractedText = '';
208
209        foreach ($response as $message) {
210            foreach ($message['candidates'] as $candidate) {
211                if (isset($candidate['content'])) {
212                    foreach ($candidate['content']['parts'] as $part) {
213                        $extractedText .= $part['text'];
214                    }
215                }
216            }
217        }
218
219        return $extractedText;
220    }
221
222    private function parseGemini15Response2(array $response): string
223    {
224        return $response['choices'][0]['message']['content'] ?? '';
225    }
226
227    private function generateFeedback(array $parsedGptResponse, array $scorecardConfig): array
228    {
229        $feedback = [
230            'positive' => $parsedGptResponse['positive'] ?? [],
231            'constructive' => $parsedGptResponse['constructive'] ?? [],
232            'negative' => $parsedGptResponse['negative'] ?? [],
233            'scores' => []
234        ];
235        $scoresArray = $parsedGptResponse['scores'] ?? [];
236
237        foreach ($scoresArray as $details) {
238            if (!isset($details['name'])) continue;
239
240            $scorecardItem = collect($scorecardConfig)->first(function ($item) use ($details) {
241                return ($item['name'] ?? '') === ($details['name'] ?? '');
242            });
243
244            $totalScore = 0;
245            $criteriaList = [];
246
247            $criterias = $details['criteria'] ?? [];
248            foreach ($criterias as $criterionDetails) {
249                $scorecardCriteriaItem = collect($scorecardItem['criteria'] ?? [])->first(function ($item) use ($criterionDetails) {
250                    return ($item['name'] ?? '') === ($criterionDetails['name'] ?? '');
251                });
252
253                if (isset($criterionDetails['score']) && is_numeric($criterionDetails['score'])) {
254                    $score = (float)$criterionDetails['score'];
255                    $score = max(($score - 1) * 25 * (($scorecardCriteriaItem['weight'] ?? 1) / 100), 0);
256
257                    $criteriaList[] = [
258                        'name' => $criterionDetails['name'] ?? 'N/A',
259                        'score' => $score,
260                        'feedback' => $criterionDetails['feedback'] ?? ''
261                    ];
262                    $totalScore += $score;
263                }
264            }
265
266            $totalScore = max($totalScore * (($scorecardItem['weight'] ?? 1) / 100), 0);
267
268            $feedback['scores'][] = [
269                'name' => $details['name'],
270                'score' => $totalScore,
271                'feedback' => $details['feedback'] ?? '',
272                'improve' => $details['improve'] ?? '',
273                'criteria' => $criteriaList
274            ];
275        }
276        return $feedback;
277    }
278
279    private function calculateTotalScore(array $scores, array $scorecardConfig): float
280    {
281        $totalScore = 0;
282
283        foreach ($scores as $details) {
284            $scorecardScore = 0;
285
286            $scorecardItem = collect($scorecardConfig)->first(function ($item) use ($details) {
287                return ($item['name'] ?? '') === ($details['name'] ?? '');
288            });
289
290            if (isset($details['criteria']) && is_array($details['criteria'])) {
291                foreach ($details['criteria'] as $criteria) {
292                    $scorecardCriteriaItem = collect($scorecardItem['criteria'] ?? [])->first(function ($item) use ($criteria) {
293                        return ($item['name'] ?? '') === ($criteria['name'] ?? '');
294                    });
295
296                    if (isset($criteria['score']) && is_numeric($criteria['score'])) {
297                        $weight = $scorecardCriteriaItem['weight'] ?? 1;
298
299                        $scorecardScore += max(((float)$criteria['score'] - 1) * 25 * ($weight / 100), 0);
300                    }
301                }
302
303                $scorecardScore = max($scorecardScore * (($scorecardItem['weight'] ?? 1) / 100), 0);
304            }
305
306            $totalScore += max($scorecardScore, 0);
307        }
308
309        return max($totalScore, 0);
310    }
311
312    private function calculateTimeDifferenceInSeconds(string $time1, string $time2): int
313    {
314        return abs(strtotime($time1) - strtotime($time2));
315    }
316}