Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.87% covered (warning)
84.87%
303 / 357
50.00% covered (danger)
50.00%
7 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
ProcessRolePlaySessionAsyncJob
84.87% covered (warning)
84.87%
303 / 357
50.00% covered (danger)
50.00%
7 / 14
99.06
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
93.48% covered (success)
93.48%
43 / 46
0.00% covered (danger)
0.00%
0 / 1
7.01
 evaluateTranscript
93.68% covered (success)
93.68%
89 / 95
0.00% covered (danger)
0.00%
0 / 1
19.09
 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 / 36
0.00% covered (danger)
0.00%
0 / 1
56
 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
3
 calculateDurationAdherence
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
9
 appendDurationAdherence
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
3
 generateFeedback
97.50% covered (success)
97.50%
39 / 40
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
 updateUserProgression
72.22% covered (warning)
72.22%
13 / 18
0.00% covered (danger)
0.00%
0 / 1
2.09
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    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}