Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
84.87% |
303 / 357 |
|
50.00% |
7 / 14 |
CRAP | |
0.00% |
0 / 1 |
| ProcessRolePlaySessionAsyncJob | |
84.87% |
303 / 357 |
|
50.00% |
7 / 14 |
99.06 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| handle | |
93.48% |
43 / 46 |
|
0.00% |
0 / 1 |
7.01 | |||
| evaluateTranscript | |
93.68% |
89 / 95 |
|
0.00% |
0 / 1 |
19.09 | |||
| backoff | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| fetchVapiResponse | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
3.02 | |||
| fetchByCallId | |
0.00% |
0 / 36 |
|
0.00% |
0 / 1 |
56 | |||
| fetchByListSearch | |
89.47% |
17 / 19 |
|
0.00% |
0 / 1 |
9.09 | |||
| chatCompletion | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
3 | |||
| calculateDurationAdherence | |
100.00% |
29 / 29 |
|
100.00% |
1 / 1 |
9 | |||
| appendDurationAdherence | |
100.00% |
25 / 25 |
|
100.00% |
1 / 1 |
3 | |||
| generateFeedback | |
97.50% |
39 / 40 |
|
0.00% |
0 / 1 |
6 | |||
| calculateTotalScore | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
7 | |||
| calculateTimeDifferenceInSeconds | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| updateUserProgression | |
72.22% |
13 / 18 |
|
0.00% |
0 / 1 |
2.09 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace App\Jobs; |
| 4 | |
| 5 | use App\Http\Models\AIPrompts; |
| 6 | use App\Http\Models\Parameter; |
| 7 | use App\Http\Models\RolePlayConversations; |
| 8 | use App\Http\Services\NodeJsAIBridgeService; |
| 9 | use App\Traits\ObjectMapper; |
| 10 | use Illuminate\Bus\Queueable; |
| 11 | use Illuminate\Contracts\Queue\ShouldQueue; |
| 12 | use Illuminate\Foundation\Bus\Dispatchable; |
| 13 | use Illuminate\Queue\InteractsWithQueue; |
| 14 | use Illuminate\Queue\SerializesModels; |
| 15 | use Illuminate\Support\Facades\Http; |
| 16 | use 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 | */ |
| 25 | class 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 | public function handle(): void |
| 56 | { |
| 57 | $vapi = $this->fetchVapiResponse($this->identifier); |
| 58 | |
| 59 | if (! $vapi) { |
| 60 | $baseUrl = rtrim(config('services.vapi.url', 'https://api.vapi.ai'), '/'); |
| 61 | $token = config('services.vapi.token'); |
| 62 | $lastResponse = null; |
| 63 | $url = "{$baseUrl}/call/{$this->vapiCallId}"; |
| 64 | if ($this->vapiCallId && $token) { |
| 65 | try { |
| 66 | $lastResponse = Http::withToken($token)->acceptJson()->get($url)->json(); |
| 67 | } catch (\Exception $e) { |
| 68 | $lastResponse = ['fetch_error' => $e->getMessage()]; |
| 69 | } |
| 70 | } |
| 71 | $this->conversation->update([ |
| 72 | 'status' => 'failed', |
| 73 | 'feedback' => [ |
| 74 | 'error' => 'Failed to fetch VAPI response', |
| 75 | 'identifier' => $this->identifier, |
| 76 | 'vapi_call_id' => $this->vapiCallId, |
| 77 | 'vapi_token_set' => ! empty($token), |
| 78 | 'vapi_last_response' => $lastResponse ? array_intersect_key($lastResponse, array_flip(['status', 'endedReason', 'error', 'fetch_error'])) : null, |
| 79 | 'attempts' => $this->attempts(), |
| 80 | 'failed_at' => now()->toISOString(), |
| 81 | 'url' => $url, |
| 82 | ], |
| 83 | ]); |
| 84 | Log::error('Failed to fetch VAPI response for identifier.', ['identifier' => $this->identifier, 'vapi_call_id' => $this->vapiCallId]); |
| 85 | |
| 86 | return; |
| 87 | } |
| 88 | |
| 89 | try { |
| 90 | $timeDifference = $this->calculateTimeDifferenceInSeconds($vapi['endedAt'], $vapi['startedAt']); |
| 91 | |
| 92 | $this->evaluateTranscript( |
| 93 | transcript: $vapi['transcript'], |
| 94 | duration: $timeDifference, |
| 95 | vapiResponse: $vapi |
| 96 | ); |
| 97 | } catch (\Exception $error) { |
| 98 | Log::error('Error processing role play session: '.$error->getMessage(), [ |
| 99 | 'conversation_id' => $this->conversation->id, |
| 100 | 'trace' => $error->getTraceAsString(), |
| 101 | ]); |
| 102 | $this->conversation->update([ |
| 103 | 'status' => 'failed', |
| 104 | 'feedback' => [ |
| 105 | 'error' => $error->getMessage(), |
| 106 | 'stage' => 'evaluation', |
| 107 | 'identifier' => $this->identifier, |
| 108 | 'vapi_call_id' => $this->vapiCallId, |
| 109 | 'failed_at' => now()->toISOString(), |
| 110 | ], |
| 111 | ]); |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | /** |
| 116 | * Run the AI evaluation against a known transcript and persist the results. |
| 117 | * |
| 118 | * Called by handle() after a successful VAPI fetch, and by the |
| 119 | * `roleplay:reevaluate` artisan command using the transcript already |
| 120 | * stored on the conversation (no VAPI round-trip). Pass the full |
| 121 | * `$vapiResponse` blob when available so the raw recording/metadata are |
| 122 | * preserved on the conversation; pass `null` when re-evaluating an |
| 123 | * existing record to leave `vapi_response` untouched. |
| 124 | * |
| 125 | * @param string $transcript The full call transcript |
| 126 | * @param int|null $duration Call duration in seconds |
| 127 | * @param array|null $vapiResponse The raw VAPI payload to persist, or null to leave unchanged |
| 128 | */ |
| 129 | public function evaluateTranscript(string $transcript, ?int $duration, ?array $vapiResponse = null): void |
| 130 | { |
| 131 | $this->conversation->load('project'); |
| 132 | |
| 133 | $type = $this->conversation->project->type; |
| 134 | |
| 135 | // Resolve scorecard via the centralized resolver (company → user → system) |
| 136 | $resolver = app(\App\Http\Services\ScorecardResolverService::class); |
| 137 | $resolved = $resolver->resolve($this->conversation->user_id, $type); |
| 138 | $scorecard_config = $resolved['scorecard']; |
| 139 | |
| 140 | // Backward compat: fall back to project-level scorecard if resolver returned empty |
| 141 | if (empty($scorecard_config)) { |
| 142 | $scorecard_config = $this->conversation->project->scorecard_config ?? []; |
| 143 | } |
| 144 | |
| 145 | $scoreCardString = ''; |
| 146 | foreach ($scorecard_config as $index => $config) { |
| 147 | $number = $index + 1; |
| 148 | $scoreCardString .= "### {$number}. ".($config['name'] ?? 'Unnamed Category')."\n"; |
| 149 | foreach ($config['criteria'] ?? [] as $criteria) { |
| 150 | $scoreCardString .= '- '.($criteria['name'] ?? 'Unnamed Criteria').': '.($criteria['description'] ?? '')."\n"; |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | // Try loading evaluation prompt from AIPrompts first, fallback to Parameter |
| 155 | $aiPrompt = AIPrompts::where('product', 'roleplay_evaluation') |
| 156 | ->orderBy('version', 'desc') |
| 157 | ->first(); |
| 158 | |
| 159 | if ($aiPrompt && ! empty($aiPrompt->mission)) { |
| 160 | $scoringPromptTemplate = $aiPrompt->mission; |
| 161 | } else { |
| 162 | $evaluationPromptParam = Parameter::where('name', 'role_play_evaluation_prompt')->first(); |
| 163 | $scoringPromptTemplate = $evaluationPromptParam->value ?? ''; |
| 164 | } |
| 165 | |
| 166 | $promptModel = $aiPrompt->model ?? null; |
| 167 | $promptMaxTokens = isset($aiPrompt->tokens) ? (int) $aiPrompt->tokens : null; |
| 168 | $promptTemperature = isset($aiPrompt->temperature) ? (float) $aiPrompt->temperature : null; |
| 169 | $promptTopP = isset($aiPrompt->top_p) ? (float) $aiPrompt->top_p : null; |
| 170 | |
| 171 | // Build ICP context for evaluation |
| 172 | $icpData = is_string($this->conversation->icp) |
| 173 | ? json_decode($this->conversation->icp, true) |
| 174 | : (array) ($this->conversation->icp ?? []); |
| 175 | |
| 176 | $icpContext = ''; |
| 177 | if (! empty($icpData)) { |
| 178 | $personalityName = is_array($icpData['personality'] ?? null) |
| 179 | ? ($icpData['personality']['name'] ?? '') |
| 180 | : ($icpData['personality'] ?? ''); |
| 181 | $personalityDesc = is_array($icpData['personality'] ?? null) |
| 182 | ? ($icpData['personality']['description'] ?? '') |
| 183 | : ''; |
| 184 | |
| 185 | $icpContext = '- Name: '.($icpData['name'] ?? 'Unknown')."\n"; |
| 186 | $icpContext .= '- Company: '.($icpData['company_name'] ?? '')."\n"; |
| 187 | $icpContext .= '- Company Size: '.($icpData['company_size'] ?? '')."\n"; |
| 188 | $icpContext .= '- Job Title: '.($icpData['target_job_title'] ?? '')."\n"; |
| 189 | $icpContext .= "- Personality: {$personalityName} — {$personalityDesc}\n"; |
| 190 | $icpContext .= '- Pain Points: '.($icpData['pain_points'] ?? '')."\n"; |
| 191 | $icpContext .= '- Communication Style: '.($icpData['communication_style'] ?? '')."\n"; |
| 192 | } |
| 193 | |
| 194 | // Build Project context for evaluation. The project (aka persona |
| 195 | // template) captures what the rep is actually selling — product |
| 196 | // name, description, key features, target industries — which the |
| 197 | // judge needs in order to score accuracy and alignment. |
| 198 | $project = $this->conversation->project; |
| 199 | $projectContext = ''; |
| 200 | if ($project) { |
| 201 | $industry = is_array($project->industry ?? null) |
| 202 | ? implode(', ', $project->industry) |
| 203 | : (string) ($project->industry ?? ''); |
| 204 | $keyFeatures = is_array($project->key_features ?? null) |
| 205 | ? implode(', ', $project->key_features) |
| 206 | : (string) ($project->key_features ?? ''); |
| 207 | |
| 208 | $projectContext = '- Call Type: '.($project->type ?? '')."\n"; |
| 209 | $projectContext .= '- Product/Persona Name: '.($project->name ?? '')."\n"; |
| 210 | $projectContext .= '- Product Description: '.($project->description ?? '')."\n"; |
| 211 | $projectContext .= "- Key Features: {$keyFeatures}\n"; |
| 212 | $projectContext .= "- Target Industries: {$industry}\n"; |
| 213 | } |
| 214 | |
| 215 | $prompt = str_replace( |
| 216 | ['{type}', '{scoring}', '{transcript}', '{icp_context}', '{project_context}'], |
| 217 | [$type, $scoreCardString, $transcript, $icpContext, $projectContext], |
| 218 | $scoringPromptTemplate |
| 219 | ); |
| 220 | |
| 221 | $rawCompletion = $this->chatCompletion( |
| 222 | $prompt, |
| 223 | $promptModel, |
| 224 | $promptMaxTokens, |
| 225 | $promptTemperature, |
| 226 | $promptTopP |
| 227 | ); |
| 228 | $parsed_gpt_evaluation_score = json_decode(trim(str_replace(['```json', '```'], '', $rawCompletion)), true); |
| 229 | |
| 230 | if (json_last_error() !== JSON_ERROR_NONE) { |
| 231 | throw new \Exception('Failed to parse Gemini JSON response. '.json_last_error_msg()); |
| 232 | } |
| 233 | |
| 234 | $totalScore = $this->calculateTotalScore($parsed_gpt_evaluation_score['scores'] ?? [], $scorecard_config); |
| 235 | $feedback = $this->generateFeedback($parsed_gpt_evaluation_score, $scorecard_config); |
| 236 | |
| 237 | // Inject the mechanical "Call Duration Adherence" section into the |
| 238 | // feedback (and total score) based on the target duration that was in |
| 239 | // effect when the user started the call. Skipped silently when the |
| 240 | // conversation lacks a target snapshot — e.g. legacy records. |
| 241 | [$feedback, $totalScore] = $this->appendDurationAdherence($feedback, $totalScore, $duration); |
| 242 | |
| 243 | Log::info('Generate Score', [ |
| 244 | 'conversation_id' => (string) $this->conversation->_id, |
| 245 | 'total_score' => $totalScore, |
| 246 | ]); |
| 247 | |
| 248 | $updatePayload = [ |
| 249 | 'transcript' => $transcript, |
| 250 | 'status' => 'done', |
| 251 | 'score_llm' => $parsed_gpt_evaluation_score, |
| 252 | 'feedback' => $feedback, |
| 253 | 'score' => $totalScore, |
| 254 | ]; |
| 255 | if ($duration !== null) { |
| 256 | $updatePayload['duration'] = $duration; |
| 257 | } |
| 258 | if ($vapiResponse !== null) { |
| 259 | $updatePayload['vapi_response'] = $vapiResponse; |
| 260 | } |
| 261 | |
| 262 | $this->conversation->update($updatePayload); |
| 263 | |
| 264 | $this->conversation->project->calculateProgression($feedback); |
| 265 | |
| 266 | $this->updateUserProgression($this->conversation, [ |
| 267 | 'total_score' => $totalScore, |
| 268 | 'sections' => $feedback['scores'] ?? [], |
| 269 | ]); |
| 270 | } |
| 271 | |
| 272 | /** |
| 273 | * Calculate the number of seconds to wait before retrying the job. |
| 274 | * |
| 275 | * @return array<int> |
| 276 | */ |
| 277 | public function backoff(): array |
| 278 | { |
| 279 | return [10, 30, 60, 120, 300]; |
| 280 | } |
| 281 | |
| 282 | /** |
| 283 | * Fetch the VAPI call response by polling the API. |
| 284 | * |
| 285 | * Uses direct call ID fetch when available (preferred), falls back to list search. |
| 286 | * |
| 287 | * @param string $identifier The VAPI metadata call identifier |
| 288 | * @return array|null The VAPI response data, or null if not found |
| 289 | */ |
| 290 | private function fetchVapiResponse(string $identifier): ?array |
| 291 | { |
| 292 | $baseUrl = rtrim(config('services.vapi.url', 'https://api.vapi.ai'), '/'); |
| 293 | $token = config('services.vapi.token'); |
| 294 | |
| 295 | if (! $token) { |
| 296 | Log::error('VAPI API token is not configured.'); |
| 297 | |
| 298 | return null; |
| 299 | } |
| 300 | |
| 301 | if ($this->vapiCallId) { |
| 302 | return $this->fetchByCallId($baseUrl, $token, $this->vapiCallId); |
| 303 | } |
| 304 | |
| 305 | return $this->fetchByListSearch($baseUrl, $token, $identifier); |
| 306 | } |
| 307 | |
| 308 | /** |
| 309 | * Fetch a VAPI call directly by its ID, polling until it reaches 'ended' status. |
| 310 | * |
| 311 | * @param string $baseUrl The VAPI API base URL |
| 312 | * @param string $token The VAPI API token |
| 313 | * @param string $callId The Vapi call ID |
| 314 | * @return array|null The call data, or null if not found/not ended |
| 315 | */ |
| 316 | private function fetchByCallId(string $baseUrl, string $token, string $callId): ?array |
| 317 | { |
| 318 | $maxAttempts = 10; |
| 319 | $delayTime = 5; |
| 320 | |
| 321 | for ($attempts = 0; $attempts < $maxAttempts; $attempts++) { |
| 322 | try { |
| 323 | $response = Http::withToken($token) |
| 324 | ->acceptJson() |
| 325 | ->get("{$baseUrl}/call/{$callId}"); |
| 326 | |
| 327 | Log::info('VAPI fetch attempt', [ |
| 328 | 'attempt' => $attempts + 1, |
| 329 | 'callId' => $callId, |
| 330 | 'httpStatus' => $response->status(), |
| 331 | 'vapiStatus' => $response->json('status'), |
| 332 | 'endedReason' => $response->json('endedReason'), |
| 333 | 'hasTranscript' => ! empty($response->json('transcript')), |
| 334 | ]); |
| 335 | |
| 336 | if ($response->successful()) { |
| 337 | $call = $response->json(); |
| 338 | if (($call['status'] ?? '') === 'ended') { |
| 339 | return $call; |
| 340 | } |
| 341 | } elseif ($response->status() === 404) { |
| 342 | Log::error('VAPI call not found (404). Call may not exist.', ['callId' => $callId]); |
| 343 | |
| 344 | return null; // Fail fast — no point retrying |
| 345 | } else { |
| 346 | Log::warning('VAPI API returned non-success', [ |
| 347 | 'callId' => $callId, |
| 348 | 'status' => $response->status(), |
| 349 | 'body' => substr($response->body(), 0, 500), |
| 350 | ]); |
| 351 | } |
| 352 | } catch (\Exception $e) { |
| 353 | Log::warning('VAPI API fetch by ID attempt failed.', [ |
| 354 | 'attempt' => $attempts + 1, |
| 355 | 'callId' => $callId, |
| 356 | 'error' => $e->getMessage(), |
| 357 | ]); |
| 358 | } |
| 359 | |
| 360 | if ($attempts < $maxAttempts - 1) { |
| 361 | sleep($delayTime); |
| 362 | } |
| 363 | } |
| 364 | |
| 365 | Log::error('VAPI call did not reach ended status after max attempts.', ['callId' => $callId]); |
| 366 | |
| 367 | return null; |
| 368 | } |
| 369 | |
| 370 | /** |
| 371 | * Fallback: search for a VAPI call by metadata identifier in the call list. |
| 372 | * |
| 373 | * @param string $baseUrl The VAPI API base URL |
| 374 | * @param string $token The VAPI API token |
| 375 | * @param string $identifier The metadata callId to match |
| 376 | * @return array|null The call data, or null if not found |
| 377 | */ |
| 378 | private function fetchByListSearch(string $baseUrl, string $token, string $identifier): ?array |
| 379 | { |
| 380 | $maxAttempts = 10; |
| 381 | $delayTime = 5; |
| 382 | |
| 383 | for ($attempts = 0; $attempts < $maxAttempts; $attempts++) { |
| 384 | try { |
| 385 | $response = Http::withToken($token) |
| 386 | ->acceptJson() |
| 387 | ->get("{$baseUrl}/call"); |
| 388 | |
| 389 | if ($response->successful()) { |
| 390 | $vapiResponseList = $response->json(); |
| 391 | if (is_array($vapiResponseList)) { |
| 392 | foreach (array_slice($vapiResponseList, 0, 10) as $item) { |
| 393 | if ( |
| 394 | ($item['assistant']['metadata']['callId'] ?? null) === $identifier && |
| 395 | ($item['status'] ?? '') === 'ended' |
| 396 | ) { |
| 397 | return $item; |
| 398 | } |
| 399 | } |
| 400 | } |
| 401 | } |
| 402 | } catch (\Exception $error) { |
| 403 | Log::warning('VAPI API list search attempt failed.', ['attempt' => $attempts + 1, 'error' => $error->getMessage()]); |
| 404 | } |
| 405 | |
| 406 | if ($attempts < $maxAttempts - 1) { |
| 407 | sleep($delayTime); |
| 408 | } |
| 409 | } |
| 410 | |
| 411 | Log::error('Could not find ended VAPI call after max attempts.', ['identifier' => $identifier]); |
| 412 | |
| 413 | return null; |
| 414 | } |
| 415 | |
| 416 | /** |
| 417 | * Send a prompt to the AI bridge for completion. |
| 418 | * |
| 419 | * @param string $prompt The prompt text to send |
| 420 | * @return string The AI-generated response text |
| 421 | */ |
| 422 | private function chatCompletion( |
| 423 | string $prompt, |
| 424 | ?string $model = null, |
| 425 | ?int $maxOutputTokens = null, |
| 426 | ?float $temperature = null, |
| 427 | ?float $topP = null |
| 428 | ): string { |
| 429 | $bridgeService = app(NodeJsAIBridgeService::class); |
| 430 | |
| 431 | $userId = $this->conversation->user_id; |
| 432 | $companyId = $this->conversation->company_id ?? null; |
| 433 | |
| 434 | return $bridgeService->generate( |
| 435 | payload: [ |
| 436 | 'provider' => 'vertex', |
| 437 | 'model' => $model ?: 'gemini-3.1-flash-lite-preview', |
| 438 | 'prompt' => $prompt, |
| 439 | 'config' => [ |
| 440 | 'maxOutputTokens' => $maxOutputTokens ?: 12000, |
| 441 | 'temperature' => $temperature ?? 0.2, |
| 442 | 'topP' => $topP ?? 0.9, |
| 443 | 'thinkingBudget' => 0, |
| 444 | ], |
| 445 | ], |
| 446 | metadata: [ |
| 447 | 'user_id' => $userId, |
| 448 | 'company_id' => $companyId, |
| 449 | 'feature' => 'roleplay_evaluation', |
| 450 | ] |
| 451 | ); |
| 452 | } |
| 453 | |
| 454 | /** |
| 455 | * Weight applied to the "Call Duration Adherence" fixed scorecard |
| 456 | * section. Kept intentionally small (10%) so the LLM-scored sections |
| 457 | * still dominate the total score; this section is meant to nudge users |
| 458 | * toward the target duration, not dominate the result. |
| 459 | */ |
| 460 | public const DURATION_ADHERENCE_WEIGHT = 10; |
| 461 | |
| 462 | /** |
| 463 | * Build the "Call Duration Adherence" feedback section and add its |
| 464 | * weighted contribution to the total score. |
| 465 | * |
| 466 | * Scoring is mechanical (no LLM call) and based on the snapshot target |
| 467 | * recorded on the conversation at session start: |
| 468 | * |
| 469 | * - within ±20% of target → 100 (full credit) |
| 470 | * - within ±50% of target → 60 (partial credit) |
| 471 | * - else → 20 (completion credit) |
| 472 | * |
| 473 | * Returns a two-element tuple `[$feedback, $totalScore]` so the caller |
| 474 | * can assign both in one statement. When the conversation has no |
| 475 | * stamped target or the actual duration is missing, the method is a |
| 476 | * no-op and returns the inputs unchanged. |
| 477 | * |
| 478 | * @param array $feedback The feedback array from generateFeedback() |
| 479 | * @param float $totalScore The running total score |
| 480 | * @param int|null $duration Actual call duration in seconds |
| 481 | * @return array{0: array, 1: float} |
| 482 | */ |
| 483 | /** |
| 484 | * Pure, side-effect-free calculation of the duration-adherence score |
| 485 | * given a target and an actual duration in seconds. Returns `null` |
| 486 | * when the inputs aren't sufficient to compute a score (missing |
| 487 | * target or non-positive actual). Exposed publicly so unit tests can |
| 488 | * pin the bucketing without mocking the whole job pipeline. |
| 489 | * |
| 490 | * Buckets: |
| 491 | * - within ±20% of target → 100 (full credit) |
| 492 | * - within ±50% of target → 60 (partial credit) |
| 493 | * - else → 20 (completion credit) |
| 494 | * |
| 495 | * @return array{score: float, feedback: string, improve: string}|null |
| 496 | */ |
| 497 | public static function calculateDurationAdherence(?int $targetSeconds, ?int $actualSeconds): ?array |
| 498 | { |
| 499 | if (! $targetSeconds || ! $actualSeconds || $actualSeconds <= 0) { |
| 500 | return null; |
| 501 | } |
| 502 | |
| 503 | $target = (int) $targetSeconds; |
| 504 | $actual = (int) $actualSeconds; |
| 505 | $deviation = abs($actual - $target) / max($target, 1); |
| 506 | |
| 507 | if ($deviation <= 0.20) { |
| 508 | $score = 100.0; |
| 509 | $tone = 'Excellent pacing — you landed within 20% of the target.'; |
| 510 | } elseif ($deviation <= 0.50) { |
| 511 | $score = 60.0; |
| 512 | $tone = 'Reasonable pacing, but a bit off the target. Try to stay within 20% either way.'; |
| 513 | } else { |
| 514 | $score = 20.0; |
| 515 | $tone = 'Call duration was far from the target. Plan your pacing so the conversation fits the expected length.'; |
| 516 | } |
| 517 | |
| 518 | $targetMin = round($target / 60, 1); |
| 519 | $actualMin = round($actual / 60, 1); |
| 520 | $direction = $actual > $target ? 'over' : ($actual < $target ? 'under' : 'at'); |
| 521 | $improve = $actual === $target |
| 522 | ? 'You hit the target exactly — keep it up.' |
| 523 | : sprintf( |
| 524 | 'Aim for %s minutes; this call was %s minutes (%s target).', |
| 525 | $targetMin, |
| 526 | $actualMin, |
| 527 | $direction, |
| 528 | ); |
| 529 | |
| 530 | return [ |
| 531 | 'score' => $score, |
| 532 | 'feedback' => $tone, |
| 533 | 'improve' => $improve, |
| 534 | ]; |
| 535 | } |
| 536 | |
| 537 | private function appendDurationAdherence(array $feedback, float $totalScore, ?int $duration): array |
| 538 | { |
| 539 | $target = $this->conversation->target_duration_seconds_at_call |
| 540 | ? (int) $this->conversation->target_duration_seconds_at_call |
| 541 | : null; |
| 542 | $adherence = self::calculateDurationAdherence($target, $duration); |
| 543 | if ($adherence === null) { |
| 544 | return [$feedback, $totalScore]; |
| 545 | } |
| 546 | |
| 547 | $targetMin = round(((int) $target) / 60, 1); |
| 548 | $actualMin = round(((int) $duration) / 60, 1); |
| 549 | $weightedContribution = $adherence['score'] * (self::DURATION_ADHERENCE_WEIGHT / 100); |
| 550 | |
| 551 | $feedback['scores'][] = [ |
| 552 | 'name' => 'Call Duration Adherence', |
| 553 | 'score' => $weightedContribution, |
| 554 | 'weight' => self::DURATION_ADHERENCE_WEIGHT, |
| 555 | 'feedback' => $adherence['feedback'], |
| 556 | 'improve' => $adherence['improve'], |
| 557 | 'criteria' => [ |
| 558 | [ |
| 559 | 'name' => 'Target duration', |
| 560 | // Criterion score is the raw 0-100 adherence bucket |
| 561 | // (weight 100 = full criterion). The section's weighted |
| 562 | // contribution to the total lives on the parent row — |
| 563 | // stamping it here too made the criterion render as |
| 564 | // 1/10 even when the user was within the no-penalty |
| 565 | // ±20% window and the parent row said 10/10. |
| 566 | 'score' => $adherence['score'], |
| 567 | 'weight' => 100, |
| 568 | 'feedback' => sprintf('Target: %s min. Actual: %s min.', $targetMin, $actualMin), |
| 569 | ], |
| 570 | ], |
| 571 | ]; |
| 572 | |
| 573 | return [$feedback, $totalScore + $weightedContribution]; |
| 574 | } |
| 575 | |
| 576 | /** |
| 577 | * Generate structured feedback from the parsed AI evaluation response. |
| 578 | * |
| 579 | * @param array $parsedGptResponse The parsed AI evaluation scores and feedback |
| 580 | * @param array $scorecardConfig The scorecard configuration from the project |
| 581 | * @return array The structured feedback with positive, constructive, negative items and scored criteria |
| 582 | */ |
| 583 | private function generateFeedback(array $parsedGptResponse, array $scorecardConfig): array |
| 584 | { |
| 585 | $feedback = [ |
| 586 | 'positive' => $parsedGptResponse['positive'] ?? [], |
| 587 | 'constructive' => $parsedGptResponse['constructive'] ?? [], |
| 588 | 'negative' => $parsedGptResponse['negative'] ?? [], |
| 589 | 'scores' => [], |
| 590 | ]; |
| 591 | $scoresArray = $parsedGptResponse['scores'] ?? []; |
| 592 | |
| 593 | foreach ($scoresArray as $details) { |
| 594 | if (! isset($details['name'])) { |
| 595 | continue; |
| 596 | } |
| 597 | |
| 598 | $scorecardItem = collect($scorecardConfig)->first(function ($item) use ($details) { |
| 599 | return ($item['name'] ?? '') === ($details['name'] ?? ''); |
| 600 | }); |
| 601 | |
| 602 | $totalScore = 0; |
| 603 | $criteriaList = []; |
| 604 | |
| 605 | $criterias = $details['criteria'] ?? []; |
| 606 | foreach ($criterias as $criterionDetails) { |
| 607 | $scorecardCriteriaItem = collect($scorecardItem['criteria'] ?? [])->first(function ($item) use ($criterionDetails) { |
| 608 | return ($item['name'] ?? '') === ($criterionDetails['name'] ?? ''); |
| 609 | }); |
| 610 | |
| 611 | if (isset($criterionDetails['score']) && is_numeric($criterionDetails['score'])) { |
| 612 | $score = (float) $criterionDetails['score']; |
| 613 | $score = max(($score - 1) * (100 / 9) * (($scorecardCriteriaItem['weight'] ?? 1) / 100), 0); |
| 614 | |
| 615 | $criteriaList[] = [ |
| 616 | 'name' => $criterionDetails['name'] ?? 'N/A', |
| 617 | 'score' => $score, |
| 618 | 'weight' => $scorecardCriteriaItem['weight'] ?? null, |
| 619 | 'feedback' => $criterionDetails['feedback'] ?? '', |
| 620 | ]; |
| 621 | $totalScore += $score; |
| 622 | } |
| 623 | } |
| 624 | |
| 625 | $totalScore = max($totalScore * (($scorecardItem['weight'] ?? 1) / 100), 0); |
| 626 | |
| 627 | $feedback['scores'][] = [ |
| 628 | 'name' => $details['name'], |
| 629 | 'score' => $totalScore, |
| 630 | 'weight' => $scorecardItem['weight'] ?? null, |
| 631 | 'feedback' => $details['feedback'] ?? '', |
| 632 | 'improve' => $details['improve'] ?? '', |
| 633 | 'criteria' => $criteriaList, |
| 634 | ]; |
| 635 | } |
| 636 | |
| 637 | return $feedback; |
| 638 | } |
| 639 | |
| 640 | /** |
| 641 | * Calculate the total weighted score from AI evaluation scores. |
| 642 | * |
| 643 | * @param array $scores The AI-generated scores per category |
| 644 | * @param array $scorecardConfig The scorecard configuration with weights |
| 645 | * @return float The total weighted score (0 or above) |
| 646 | */ |
| 647 | private function calculateTotalScore(array $scores, array $scorecardConfig): float |
| 648 | { |
| 649 | $totalScore = 0; |
| 650 | |
| 651 | foreach ($scores as $details) { |
| 652 | $scorecardScore = 0; |
| 653 | |
| 654 | $scorecardItem = collect($scorecardConfig)->first(function ($item) use ($details) { |
| 655 | return ($item['name'] ?? '') === ($details['name'] ?? ''); |
| 656 | }); |
| 657 | |
| 658 | if (isset($details['criteria']) && is_array($details['criteria'])) { |
| 659 | foreach ($details['criteria'] as $criteria) { |
| 660 | $scorecardCriteriaItem = collect($scorecardItem['criteria'] ?? [])->first(function ($item) use ($criteria) { |
| 661 | return ($item['name'] ?? '') === ($criteria['name'] ?? ''); |
| 662 | }); |
| 663 | |
| 664 | if (isset($criteria['score']) && is_numeric($criteria['score'])) { |
| 665 | $weight = $scorecardCriteriaItem['weight'] ?? 1; |
| 666 | |
| 667 | $scorecardScore += max(((float) $criteria['score'] - 1) * (100 / 9) * ($weight / 100), 0); |
| 668 | } |
| 669 | } |
| 670 | |
| 671 | $scorecardScore = max($scorecardScore * (($scorecardItem['weight'] ?? 1) / 100), 0); |
| 672 | } |
| 673 | |
| 674 | $totalScore += max($scorecardScore, 0); |
| 675 | } |
| 676 | |
| 677 | return max($totalScore, 0); |
| 678 | } |
| 679 | |
| 680 | /** |
| 681 | * Calculate the absolute time difference between two timestamps in seconds. |
| 682 | * |
| 683 | * @param string $time1 The first timestamp string |
| 684 | * @param string $time2 The second timestamp string |
| 685 | * @return int The absolute difference in seconds |
| 686 | */ |
| 687 | private function calculateTimeDifferenceInSeconds(string $time1, string $time2): int |
| 688 | { |
| 689 | return abs(strtotime($time1) - strtotime($time2)); |
| 690 | } |
| 691 | |
| 692 | /** |
| 693 | * Update the user's cross-project progression for this call type. |
| 694 | * |
| 695 | * Creates or updates a UserRolePlayProgression record, appending the session |
| 696 | * entry and recalculating the running EMA averages. |
| 697 | * |
| 698 | * @param RolePlayConversations $conversation The completed conversation |
| 699 | * @param array $scoreResult Array with 'total_score' and 'sections' keys |
| 700 | */ |
| 701 | private function updateUserProgression(RolePlayConversations $conversation, array $scoreResult): void |
| 702 | { |
| 703 | $project = \App\Http\Models\RolePlayProjects::find($conversation->project_id); |
| 704 | |
| 705 | if (! $project) { |
| 706 | \Log::warning('Skipping progression update — project not found', [ |
| 707 | 'conversation_id' => (string) $conversation->_id, |
| 708 | 'project_id' => (string) $conversation->project_id, |
| 709 | ]); |
| 710 | |
| 711 | return; |
| 712 | } |
| 713 | |
| 714 | $callType = $project->type; |
| 715 | |
| 716 | $progression = \App\Http\Models\UserRolePlayProgression::firstOrCreate( |
| 717 | ['user_id' => $conversation->user_id, 'call_type' => $callType], |
| 718 | ['entries' => [], 'current_averages' => [], 'session_count' => 0] |
| 719 | ); |
| 720 | |
| 721 | $progression->appendEntry([ |
| 722 | 'session_id' => (string) $conversation->_id, |
| 723 | 'project_id' => (string) $conversation->project_id, |
| 724 | 'overall_score' => $scoreResult['total_score'], |
| 725 | 'sections' => $scoreResult['sections'], |
| 726 | ]); |
| 727 | } |
| 728 | } |