Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.49% covered (success)
92.49%
160 / 173
60.00% covered (warning)
60.00%
6 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
ProcessRolePlaySessionAsyncJob
92.49% covered (success)
92.49%
160 / 173
60.00% covered (warning)
60.00%
6 / 10
39.65
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%
57 / 57
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            $this->conversation->update([
90                'transcript' => $vapi['transcript'],
91                'duration' => $timeDifference,
92                'status' => 'done',
93                'vapi_response' => $vapi,
94                'score_llm' => $parsed_gpt_evaluation_score,
95                'feedback' => $feedback,
96                'score' => $totalScore
97            ]);
98
99            $this->conversation->project->calculateProgression($feedback);
100        } catch (\Exception $error) {
101            Log::error('Error processing role play session: ' . $error->getMessage(), [
102                'conversation_id' => $this->conversation->id,
103                'trace' => $error->getTraceAsString()
104            ]);
105            $this->conversation->status = 'failed';
106            $this->conversation->save();
107        }
108    }
109
110    public function backoff(): array
111    {
112        return [10, 30, 60, 120, 300];
113    }
114
115    private function fetchVapiResponse(string $identifier): ?array
116    {
117        $maxAttempts = 10;
118        $delayTime = 5;
119
120        $url = config('services.vapi.url', 'https://api.vapi.ai/call');
121        $token = "059e9138-d883-498b-862c-e9ab8f31b298";
122
123        if (!$token) {
124            Log::error('VAPI API token is not configured.');
125            return null;
126        }
127
128        for ($attempts = 0; $attempts < $maxAttempts; $attempts++) {
129            try {
130                $response = Http::withToken($token)
131                    ->acceptJson()
132                    ->get($url);
133
134                if ($response->successful()) {
135                    $vapiResponseList = $response->json();
136                    if (is_array($vapiResponseList)) {
137                        foreach (array_slice($vapiResponseList, 0, 10) as $item) {
138                            if (
139                                ($item['assistant']['metadata']['callId'] ?? null) === $identifier &&
140                                ($item['status'] ?? '') === 'ended'
141                            ) {
142                                return $item;
143                            }
144                        }
145                    }
146                }
147            } catch (\Exception $error) {
148                Log::warning('VAPI API poll attempt failed.', ['attempt' => $attempts + 1, 'error' => $error->getMessage()]);
149            }
150
151            if ($attempts < $maxAttempts - 1) {
152                sleep($delayTime);
153            }
154        }
155
156        Log::error('Could not find ended VAPI call after max attempts.', ['identifier' => $identifier]);
157        return null;
158    }
159
160    private function chatCompletion(string $prompt): string
161    {
162        $model = "gemini-2.5-flash:generateContent";
163        $access_token = GeminiAPI::getAIAccessToken();
164
165        $generationConfig = [
166            "maxOutputTokens" => 2500,
167            "temperature" => 1,
168            "topP" => 1,
169            "thinkingConfig" => [
170                "thinkingBudget" => 0,
171            ],
172            // "topK" => 1
173        ];
174
175        $geminiAPI = new GeminiAPI($access_token);
176
177        $data = [
178            "contents" => [
179                "role" => "user",
180                "parts" => [
181                    [
182                        "text" => $prompt,
183                    ]
184                ]
185            ],
186            "generationConfig" => $generationConfig,
187        ];
188
189        $response = $geminiAPI->postCompletions($data, $model);
190
191        $rawCompletion = $response->getBody();
192        $responseData = json_decode($rawCompletion, true);
193
194        return $responseData['candidates'][0]['content']['parts'][0]['text'] ?? '';
195    }
196
197    private function parseGemini15Response($response)
198    {
199        $extractedText = '';
200
201        foreach ($response as $message) {
202            foreach ($message['candidates'] as $candidate) {
203                if (isset($candidate['content'])) {
204                    foreach ($candidate['content']['parts'] as $part) {
205                        $extractedText .= $part['text'];
206                    }
207                }
208            }
209        }
210
211        return $extractedText;
212    }
213
214    private function parseGemini15Response2(array $response): string
215    {
216        return $response['choices'][0]['message']['content'] ?? '';
217    }
218
219    private function generateFeedback(array $parsedGptResponse, array $scorecardConfig): array
220    {
221        $feedback = [
222            'positive' => $parsedGptResponse['positive'] ?? [],
223            'constructive' => $parsedGptResponse['constructive'] ?? [],
224            'negative' => $parsedGptResponse['negative'] ?? [],
225            'scores' => []
226        ];
227        $scoresArray = $parsedGptResponse['scores'] ?? [];
228
229        foreach ($scoresArray as $details) {
230            if (!isset($details['name'])) continue;
231
232            $scorecardItem = collect($scorecardConfig)->first(function ($item) use ($details) {
233                return ($item['name'] ?? '') === ($details['name'] ?? '');
234            });
235
236            $totalScore = 0;
237            $criteriaList = [];
238
239            $criterias = $details['criteria'] ?? [];
240            foreach ($criterias as $criterionDetails) {
241                $scorecardCriteriaItem = collect($scorecardItem['criteria'] ?? [])->first(function ($item) use ($criterionDetails) {
242                    return ($item['name'] ?? '') === ($criterionDetails['name'] ?? '');
243                });
244
245                if (isset($criterionDetails['score']) && is_numeric($criterionDetails['score'])) {
246                    $score = (float)$criterionDetails['score'];
247                    $score = max(($score - 1) * 25 * (($scorecardCriteriaItem['weight'] ?? 1) / 100), 0);
248
249                    $criteriaList[] = [
250                        'name' => $criterionDetails['name'] ?? 'N/A',
251                        'score' => $score,
252                        'feedback' => $criterionDetails['feedback'] ?? ''
253                    ];
254                    $totalScore += $score;
255                }
256            }
257
258            $totalScore = max($totalScore * (($scorecardItem['weight'] ?? 1) / 100), 0);
259
260            $feedback['scores'][] = [
261                'name' => $details['name'],
262                'score' => $totalScore,
263                'feedback' => $details['feedback'] ?? '',
264                'improve' => $details['improve'] ?? '',
265                'criteria' => $criteriaList
266            ];
267        }
268        return $feedback;
269    }
270
271    private function calculateTotalScore(array $scores, array $scorecardConfig): float
272    {
273        $totalScore = 0;
274
275        foreach ($scores as $details) {
276            $scorecardScore = 0;
277
278            $scorecardItem = collect($scorecardConfig)->first(function ($item) use ($details) {
279                return ($item['name'] ?? '') === ($details['name'] ?? '');
280            });
281
282            if (isset($details['criteria']) && is_array($details['criteria'])) {
283                foreach ($details['criteria'] as $criteria) {
284                    $scorecardCriteriaItem = collect($scorecardItem['criteria'] ?? [])->first(function ($item) use ($criteria) {
285                        return ($item['name'] ?? '') === ($criteria['name'] ?? '');
286                    });
287
288                    if (isset($criteria['score']) && is_numeric($criteria['score'])) {
289                        $weight = $scorecardCriteriaItem['weight'] ?? 1;
290
291                        $scorecardScore += max(((float)$criteria['score'] - 1) * 25 * ($weight / 100), 0);
292                    }
293                }
294
295                $scorecardScore = max($scorecardScore * (($scorecardItem['weight'] ?? 1) / 100), 0);
296            }
297
298            $totalScore += max($scorecardScore, 0);
299        }
300
301        return max($totalScore, 0);
302    }
303
304    private function calculateTimeDifferenceInSeconds(string $time1, string $time2): int
305    {
306        return abs(strtotime($time1) - strtotime($time2));
307    }
308}