Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
92.49% |
160 / 173 |
|
60.00% |
6 / 10 |
CRAP | |
0.00% |
0 / 1 |
ProcessRolePlaySessionAsyncJob | |
92.49% |
160 / 173 |
|
60.00% |
6 / 10 |
39.65 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
handle | |
100.00% |
57 / 57 |
|
100.00% |
1 / 1 |
6 | |||
backoff | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
fetchVapiResponse | |
83.33% |
20 / 24 |
|
0.00% |
0 / 1 |
10.46 | |||
chatCompletion | |
100.00% |
27 / 27 |
|
100.00% |
1 / 1 |
1 | |||
parseGemini15Response | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
30 | |||
parseGemini15Response2 | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
generateFeedback | |
100.00% |
37 / 37 |
|
100.00% |
1 / 1 |
6 | |||
calculateTotalScore | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
7 | |||
calculateTimeDifferenceInSeconds | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | |
3 | namespace App\Jobs; |
4 | |
5 | use App\Traits\ObjectMapper; |
6 | use Illuminate\Bus\Queueable; |
7 | use Illuminate\Contracts\Queue\ShouldQueue; |
8 | use Illuminate\Foundation\Bus\Dispatchable; |
9 | use Illuminate\Queue\InteractsWithQueue; |
10 | use Illuminate\Queue\SerializesModels; |
11 | use App\Http\Models\Parameter; |
12 | use App\Http\Models\RolePlayConversations; |
13 | use App\Services\FlyMsgAI\GeminiAPI; |
14 | use Illuminate\Support\Facades\Http; |
15 | use Illuminate\Support\Facades\Log; |
16 | |
17 | class 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 | } |