Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.60% covered (warning)
89.60%
293 / 327
36.84% covered (danger)
36.84%
7 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
RolePlayAutoPopulateService
89.60% covered (warning)
89.60%
293 / 327
36.84% covered (danger)
36.84%
7 / 19
116.16
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
 generatePersonaDetails
81.25% covered (warning)
81.25%
13 / 16
0.00% covered (danger)
0.00%
0 / 1
5.16
 generateProductDetails
65.22% covered (warning)
65.22%
15 / 23
0.00% covered (danger)
0.00%
0 / 1
7.51
 generateIcps
96.43% covered (success)
96.43%
27 / 28
0.00% covered (danger)
0.00%
0 / 1
2
 regenerateIcp
97.30% covered (success)
97.30%
36 / 37
0.00% covered (danger)
0.00%
0 / 1
2
 computeIcpInputsSignature
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 loadPromptRecord
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 callAi
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
2
 resolveConfig
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
6
 generateFromGrounding
55.56% covered (warning)
55.56%
10 / 18
0.00% covered (danger)
0.00%
0 / 1
7.19
 sanitizePersonaDetails
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 stringArray
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
6.05
 normalizeCompanySizes
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 formatBulletList
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 loadPersonalities
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 formatPersonalitiesForPrompt
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 logIncompleteIcps
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
11
 backfillIcpCompanySizes
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 assignPersonalityAndAvatar
89.06% covered (warning)
89.06%
57 / 64
0.00% covered (danger)
0.00%
0 / 1
38.79
1<?php
2
3namespace App\Http\Services;
4
5use App\Http\Models\AIPrompts;
6use App\Http\Models\Parameter;
7use App\Http\Models\RolePlayConfig;
8use App\Http\Models\RolePlayProjects;
9use App\Http\Services\RolePlay\CompanyResearchService;
10use Illuminate\Support\Facades\Log;
11
12/**
13 * Orchestrates AI-driven generation for the persona builder's three
14 * sections (Persona Details, Product Details, ICPs) and for single-ICP
15 * regeneration.
16 *
17 * Each public method is independent so the frontend can call them per-tab,
18 * letting the user refine one section without losing data in the others.
19 *
20 * The service:
21 *  - reads its prompt templates from the `ai_prompts` collection by product key
22 *  - uses {@see WebScraperService::scrapeCached()} for the 24h-cached scrape
23 *  - delegates LLM calls to {@see NodeJsAIBridgeService}
24 *  - normalizes/parses Gemini JSON responses
25 *  - hydrates ICPs with personality + avatar + voice via personality config
26 *
27 * Pure orchestration; persistence stays in the controller (which calls
28 * this service) so the controller can write the resulting `customer_profiles`
29 * and `customer_profiles_signature` to the persona record.
30 */
31class RolePlayAutoPopulateService
32{
33    public function __construct(
34        private readonly NodeJsAIBridgeService $bridge,
35        private readonly WebScraperService $scraper,
36        private readonly CompanyResearchService $companyResearch,
37    ) {}
38
39    /**
40     * Generate the Persona Details section from a website URL.
41     *
42     * @param  string  $websiteUrl  The company/product URL
43     * @param  mixed  $user  The authenticated user (used for AI request logging)
44     * @return array{
45     *     name?: string,
46     *     description?: string,
47     *     industry?: array<int, string>,
48     *     target_job_titles?: array<int, string>,
49     *     company_sizes?: array<int, string>
50     * }
51     *
52     * @throws \RuntimeException If the AI returns an unparseable response.
53     */
54    public function generatePersonaDetails(string $websiteUrl, mixed $user = null): array
55    {
56        // Attempt deep research lookup/creation
57        $research = $this->companyResearch->getOrCreateForUrl($websiteUrl, $user);
58
59        if ($research->status === 'completed' && trim($research->research ?? '') !== '') {
60            // Use deep research as primary context, scraped content as supplement
61            $content = $research->research;
62            if (trim($research->scraped_content ?? '') !== '') {
63                $content .= "\n\n--- Supplementary Website Content ---\n".$research->scraped_content;
64            }
65        } else {
66            // Graceful degradation: fall back to scrape-only
67            $content = $this->scraper->scrapeCached($websiteUrl);
68        }
69
70        if (trim($content) === '') {
71            return $this->generateFromGrounding($websiteUrl, 'persona_details', $user);
72        }
73
74        $promptRecord = $this->loadPromptRecord('roleplay_auto_populate_persona_details');
75        $prompt = str_replace(
76            ['{website_url}', '{website_content}'],
77            [$websiteUrl, $content],
78            (string) $promptRecord->context
79        );
80
81        $result = $this->callAi($prompt, $user, 'persona_auto_populate_persona_details', $promptRecord);
82
83        return $this->sanitizePersonaDetails($result ?? []);
84    }
85
86    /**
87     * Generate the Product Details section from a website URL.
88     *
89     * @param  string  $websiteUrl  The company/product URL
90     * @param  mixed  $user  The authenticated user
91     * @return array{description?: string, key_features?: array<int, string>}
92     */
93    public function generateProductDetails(string $websiteUrl, mixed $user = null): array
94    {
95        // Attempt deep research lookup/creation
96        $research = $this->companyResearch->getOrCreateForUrl($websiteUrl, $user);
97
98        if ($research->status === 'completed' && trim($research->research ?? '') !== '') {
99            $content = $research->research;
100            if (trim($research->scraped_content ?? '') !== '') {
101                $content .= "\n\n--- Supplementary Website Content ---\n".$research->scraped_content;
102            }
103        } else {
104            $content = $this->scraper->scrapeCached($websiteUrl);
105        }
106
107        if (trim($content) === '') {
108            $grounded = $this->generateFromGrounding($websiteUrl, 'product_details', $user);
109
110            return [
111                'description' => $grounded['description'] ?? '',
112                'key_features' => $grounded['key_features'] ?? [],
113            ];
114        }
115
116        $promptRecord = $this->loadPromptRecord('roleplay_auto_populate_product_details');
117        $prompt = str_replace(
118            ['{website_url}', '{website_content}'],
119            [$websiteUrl, $content],
120            (string) $promptRecord->context
121        );
122
123        $result = $this->callAi($prompt, $user, 'persona_auto_populate_product_details', $promptRecord);
124
125        return [
126            'description' => is_string($result['description'] ?? null) ? $result['description'] : '',
127            'key_features' => $this->stringArray($result['key_features'] ?? []),
128        ];
129    }
130
131    /**
132     * Generate a fresh ICP set respecting persona company sizes, difficulty,
133     * call type, target industries and product context.
134     *
135     * @param  array{
136     *     type: string,
137     *     difficulty_level: int|string,
138     *     industry: array<int, string>,
139     *     product_description: string,
140     *     key_features?: array<int, string>,
141     *     company_sizes: array<int, string>
142     * }  $persona
143     * @return array<int, array<string, mixed>> Array of ICP objects
144     */
145    public function generateIcps(array $persona, mixed $user = null): array
146    {
147        $promptRecord = $this->loadPromptRecord('roleplay_auto_populate_icps');
148
149        $personalities = $this->loadPersonalities();
150        $personalitiesStr = $this->formatPersonalitiesForPrompt($personalities);
151
152        $featuresFormatted = $this->formatBulletList($persona['key_features'] ?? []);
153        $industries = implode(', ', $persona['industry']);
154        $companySizes = implode(', ', $this->normalizeCompanySizes($persona['company_sizes']));
155
156        $prompt = str_replace(
157            ['{type}', '{productDescription}', '{key_features}', '{targetIndustry}',
158                '{difficulty_level}', '{company_sizes}', '{min_profiles}', '{personalities}'],
159            [
160                (string) $persona['type'],
161                (string) $persona['product_description'],
162                $featuresFormatted,
163                $industries,
164                (string) $persona['difficulty_level'],
165                $companySizes,
166                (string) max(count($personalities), 3),
167                $personalitiesStr,
168            ],
169            (string) $promptRecord->context
170        );
171
172        $result = $this->callAi($prompt, $user, 'persona_generate_icps', $promptRecord);
173
174        if (! is_array($result)) {
175            return [];
176        }
177
178        $icps = $this->assignPersonalityAndAvatar($result, $personalities, $industries);
179        $icps = $this->backfillIcpCompanySizes($icps, $this->normalizeCompanySizes($persona['company_sizes']));
180
181        $this->logIncompleteIcps($icps, 'persona_generate_icps', $promptRecord, $user);
182
183        return $icps;
184    }
185
186    /**
187     * Regenerate a single ICP, preserving identity fields.
188     *
189     * @param  array{
190     *     id?: int,
191     *     name: string,
192     *     gender: string,
193     *     image?: string,
194     *     personality?: array<string, mixed>|string,
195     *     company_name: string,
196     *     company_size: string,
197     *     industry: string,
198     *     target_job_title: string,
199     *     budget?: string
200     * }  $preserve  Identity fields to keep
201     * @param  array{
202     *     type: string,
203     *     difficulty_level: int|string,
204     *     industry: array<int, string>,
205     *     product_description: string,
206     *     key_features?: array<int, string>
207     * }  $persona  Persona context
208     * @return array<string, mixed> The regenerated ICP (preserved + refreshed fields)
209     */
210    public function regenerateIcp(array $preserve, array $persona, mixed $user = null): array
211    {
212        $promptRecord = $this->loadPromptRecord('roleplay_regenerate_icp');
213
214        $featuresFormatted = $this->formatBulletList($persona['key_features'] ?? []);
215        $industries = implode(', ', $persona['industry']);
216
217        $prompt = str_replace(
218            ['{type}', '{productDescription}', '{key_features}', '{targetIndustry}',
219                '{company_name}', '{company_size}', '{difficulty_level}',
220                '{name}', '{gender}', '{industry}', '{target_job_title}', '{budget}'],
221            [
222                (string) $persona['type'],
223                (string) $persona['product_description'],
224                $featuresFormatted,
225                $industries,
226                (string) $preserve['company_name'],
227                RolePlayProjects::companySizeKey($preserve['company_size']),
228                (string) $persona['difficulty_level'],
229                (string) $preserve['name'],
230                (string) $preserve['gender'],
231                (string) $preserve['industry'],
232                (string) $preserve['target_job_title'],
233                (string) ($preserve['budget'] ?? ''),
234            ],
235            (string) $promptRecord->context
236        );
237
238        $result = $this->callAi($prompt, $user, 'persona_regenerate_icp', $promptRecord);
239
240        if (! is_array($result)) {
241            return $preserve;
242        }
243
244        // The prompt returns a single-element array; unwrap.
245        $first = $result[0] ?? $result;
246
247        // Force preserved fields back in case the model drifted.
248        $first['id'] = $preserve['id'] ?? $first['id'] ?? null;
249        $first['name'] = $preserve['name'];
250        $first['gender'] = $preserve['gender'];
251        $first['image'] = $preserve['image'] ?? $first['image'] ?? '';
252        $first['personality'] = $preserve['personality'] ?? $first['personality'] ?? '';
253        $first['company_name'] = $preserve['company_name'];
254        $first['company_size'] = $preserve['company_size'];
255        $first['industry'] = $preserve['industry'];
256        $first['target_job_title'] = $preserve['target_job_title'];
257
258        return $first;
259    }
260
261    /**
262     * Compute a stable signature of the persona+product fields that influence
263     * ICP generation. Stored on the persona record so the frontend can detect
264     * stale ICPs after the user edits Persona/Product details.
265     *
266     * @param  array{
267     *     type?: string,
268     *     difficulty_level?: int|string,
269     *     industry?: array<int, string>,
270     *     company_sizes?: array<int, string>,
271     *     description?: string,
272     *     key_features?: array<int, string>
273     * }  $persona
274     */
275    public function computeIcpInputsSignature(array $persona): string
276    {
277        $payload = [
278            'type' => (string) ($persona['type'] ?? ''),
279            'difficulty_level' => (string) ($persona['difficulty_level'] ?? ''),
280            'industry' => $this->stringArray($persona['industry'] ?? []),
281            'company_sizes' => $this->normalizeCompanySizes($persona['company_sizes'] ?? []),
282            'description' => (string) ($persona['description'] ?? ''),
283            'key_features' => $this->stringArray($persona['key_features'] ?? []),
284        ];
285
286        sort($payload['industry']);
287        sort($payload['company_sizes']);
288        sort($payload['key_features']);
289
290        return sha1(json_encode($payload, JSON_UNESCAPED_UNICODE));
291    }
292
293    // â”€â”€â”€ private helpers â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
294
295    /**
296     * Fallbacks used only when a field is missing on the ai_prompts record.
297     * Kept conservative so a mis-seeded prompt still generates usable output,
298     * but the record's own value always wins when present.
299     */
300    private const CONFIG_FALLBACKS = [
301        'model' => 'gemini-3.1-flash-lite-preview',
302        'provider' => 'vertex',
303        'max_tokens' => 6000,
304        'temperature' => 0.85,
305        'top_p' => 0.95,
306        'thinking_budget' => 0,
307    ];
308
309    /**
310     * Load the full ai_prompts record by product key, throwing on missing/empty.
311     *
312     * Callers receive the record (not just the context string) so the bridge
313     * call can honor the record's model/temperature/top_p/tokens/is_grounding.
314     */
315    private function loadPromptRecord(string $product): AIPrompts
316    {
317        $record = AIPrompts::where('product', $product)
318            ->where('status', 'active')
319            ->first();
320
321        if (! $record || empty($record->context)) {
322            throw new \RuntimeException("AI prompt '{$product}' is not configured.");
323        }
324
325        return $record;
326    }
327
328    /**
329     * Call the LLM bridge using the config from the ai_prompts record
330     * (model / temperature / top_p / tokens / is_grounding), and parse the
331     * JSON response. Hardcoded defaults are only used when the field is
332     * missing from the record.
333     *
334     * @return array<mixed>|null Parsed JSON, or null on parse failure
335     */
336    private function callAi(string $prompt, mixed $user, string $feature, AIPrompts $promptRecord): ?array
337    {
338        $config = $this->resolveConfig($promptRecord);
339
340        $text = $this->bridge->generate(
341            [
342                'provider' => self::CONFIG_FALLBACKS['provider'],
343                'model' => $config['model'],
344                'prompt' => $prompt,
345                'config' => [
346                    'maxOutputTokens' => $config['maxOutputTokens'],
347                    'temperature' => $config['temperature'],
348                    'topP' => $config['topP'],
349                    'thinkingBudget' => $config['thinkingBudget'],
350                    'enableGoogleSearch' => $config['enableGoogleSearch'],
351                ],
352            ],
353            [
354                'feature' => $feature,
355                'user_id' => $user?->id,
356                'company_id' => $user?->company_id ?? null,
357                'prompt_id' => (string) $promptRecord->_id,
358            ]
359        );
360
361        $cleaned = trim(str_replace(['```json', '```'], '', (string) $text));
362        $decoded = json_decode($cleaned, true);
363
364        return is_array($decoded) ? $decoded : null;
365    }
366
367    /**
368     * Read the model/temperature/topP/tokens/grounding config from an
369     * ai_prompts record, applying conservative fallbacks only when a field
370     * is missing. Kept in one place so every caller stays honest â€” if an
371     * operator tunes the prompt in the DB, the tuning reaches the bridge.
372     *
373     * @return array{model: string, maxOutputTokens: int, temperature: float, topP: float, thinkingBudget: int, enableGoogleSearch: bool}
374     */
375    private function resolveConfig(AIPrompts $promptRecord): array
376    {
377        $model = $promptRecord->model !== null && $promptRecord->model !== ''
378            ? str_replace(':streamGenerateContent', '', (string) $promptRecord->model)
379            : self::CONFIG_FALLBACKS['model'];
380
381        return [
382            'model' => $model,
383            'maxOutputTokens' => $promptRecord->tokens !== null
384                ? (int) $promptRecord->tokens
385                : self::CONFIG_FALLBACKS['max_tokens'],
386            'temperature' => $promptRecord->temperature !== null
387                ? (float) $promptRecord->temperature
388                : self::CONFIG_FALLBACKS['temperature'],
389            'topP' => $promptRecord->top_p !== null
390                ? (float) $promptRecord->top_p
391                : self::CONFIG_FALLBACKS['top_p'],
392            'thinkingBudget' => self::CONFIG_FALLBACKS['thinking_budget'],
393            'enableGoogleSearch' => (bool) ($promptRecord->is_grounding ?? false),
394        ];
395    }
396
397    /**
398     * Grounding fallback for either persona-details or product-details.
399     *
400     * @param  string  $section  persona_details|product_details|both
401     * @return array<string, mixed>
402     */
403    private function generateFromGrounding(string $websiteUrl, string $section, mixed $user): array
404    {
405        $promptRecord = $this->loadPromptRecord('roleplay_auto_populate_grounding');
406        $prompt = str_replace(
407            ['{website_url}', '{section}'],
408            [$websiteUrl, $section],
409            (string) $promptRecord->context
410        );
411
412        $result = $this->callAi($prompt, $user, 'persona_auto_populate_grounding', $promptRecord);
413
414        if (! is_array($result)) {
415            return [];
416        }
417
418        if ($section === 'persona_details') {
419            return $this->sanitizePersonaDetails($result['persona_details'] ?? []);
420        }
421
422        if ($section === 'product_details') {
423            $pd = $result['product_details'] ?? [];
424
425            return [
426                'description' => is_string($pd['description'] ?? null) ? $pd['description'] : '',
427                'key_features' => $this->stringArray($pd['key_features'] ?? []),
428            ];
429        }
430
431        return $result;
432    }
433
434    /**
435     * Validate / coerce a persona-details payload from the AI.
436     *
437     * @param  array<string, mixed>  $raw
438     * @return array<string, mixed>
439     */
440    private function sanitizePersonaDetails(array $raw): array
441    {
442        $companySizes = $this->normalizeCompanySizes($raw['company_sizes'] ?? []);
443        if (empty($companySizes)) {
444            $companySizes = RolePlayProjects::COMPANY_SIZE_KEYS;
445        }
446
447        return [
448            'name' => (string) ($raw['name'] ?? ''),
449            'description' => (string) ($raw['description'] ?? ''),
450            'industry' => $this->stringArray($raw['industry'] ?? []),
451            'target_job_titles' => $this->stringArray($raw['target_job_titles'] ?? []),
452            'company_sizes' => $companySizes,
453        ];
454    }
455
456    /**
457     * Coerce an arbitrary value to a list of trimmed strings.
458     *
459     * @return array<int, string>
460     */
461    private function stringArray(mixed $value): array
462    {
463        if (! is_array($value)) {
464            return [];
465        }
466
467        $out = [];
468        foreach ($value as $v) {
469            if (is_string($v) || is_numeric($v)) {
470                $trimmed = trim((string) $v);
471                if ($trimmed !== '') {
472                    $out[] = $trimmed;
473                }
474            }
475        }
476
477        return $out;
478    }
479
480    /**
481     * Filter and lowercase company-size keys against the canonical set.
482     *
483     * @return array<int, string>
484     */
485    private function normalizeCompanySizes(mixed $value): array
486    {
487        if (! is_array($value)) {
488            return [];
489        }
490
491        $allowed = RolePlayProjects::COMPANY_SIZE_KEYS;
492        $out = [];
493        foreach ($value as $v) {
494            $key = strtolower(trim((string) $v));
495            if (in_array($key, $allowed, true) && ! in_array($key, $out, true)) {
496                $out[] = $key;
497            }
498        }
499
500        return $out;
501    }
502
503    /**
504     * Format an array of features as a Markdown bullet list for prompt insertion.
505     *
506     * @param  array<int, string>  $features
507     */
508    private function formatBulletList(array $features): string
509    {
510        $lines = array_map(fn ($f) => '- '.trim((string) $f), $features);
511
512        return implode("\n", $lines);
513    }
514
515    /**
516     * Load the global personalities config (with fallback to legacy parameter).
517     *
518     * @return array<int, array<string, mixed>>
519     */
520    private function loadPersonalities(): array
521    {
522        $config = RolePlayConfig::getGlobal();
523        if ($config && ! empty($config->personalities)) {
524            return $config->personalities;
525        }
526
527        return Parameter::where('name', 'role_play_personalities')->first()?->value ?? [];
528    }
529
530    /**
531     * Build the formatted personalities block for the prompt.
532     *
533     * @param  array<int, array<string, mixed>>  $personalities
534     */
535    private function formatPersonalitiesForPrompt(array $personalities): string
536    {
537        $lines = [];
538        foreach ($personalities as $i => $p) {
539            $num = $i + 1;
540            $type = $p['type'] ?? '';
541            $name = $p['name'] ?? '';
542            $description = $p['description'] ?? '';
543            $traits = implode(', ', $p['traits'] ?? []);
544            $lines[] = "{$num}. **{$type} â€“ {$name}** (Traits: {$traits})\n   {$description}";
545        }
546
547        return implode("\n\n", $lines);
548    }
549
550    /**
551     * Required ICP fields the persona validator (RolePlayProjects::getRules)
552     * expects. Used by {@see logIncompleteIcps} so missing/renamed fields are
553     * visible in logs (and can be surfaced to support via daily digest).
554     */
555    private const ICP_REQUIRED_FIELDS = [
556        'company_name',
557        'company_size',
558        'budget',
559        'decision_making',
560        'urgency_level',
561        'openess_to_new_solutions',
562        'communication_style',
563        'pain_points',
564        'current_solution',
565        'target_job_title',
566    ];
567
568    /**
569     * Warn (don't fail) when the AI returned ICPs that will not pass the
570     * persona-save validator. Never throws â€” the user can still edit/retry,
571     * and ops gets a structured signal to audit prompt drift.
572     *
573     * @param  array<int, array<string, mixed>>  $icps
574     */
575    private function logIncompleteIcps(
576        array $icps,
577        string $product,
578        AIPrompts $promptRecord,
579        mixed $user = null,
580    ): void {
581        $problems = [];
582
583        foreach ($icps as $i => $icp) {
584            $missing = [];
585            foreach (self::ICP_REQUIRED_FIELDS as $field) {
586                $value = $icp[$field] ?? null;
587                if ($value === null || $value === '' || (is_array($value) && empty($value))) {
588                    $missing[] = $field;
589                }
590            }
591
592            if (! empty($missing)) {
593                $problems[] = [
594                    'index' => $i,
595                    'personality_type' => is_array($icp['personality'] ?? null)
596                        ? ($icp['personality']['type'] ?? null)
597                        : null,
598                    'missing_fields' => $missing,
599                    'unexpected_keys' => array_values(array_diff(
600                        array_keys($icp),
601                        array_merge(self::ICP_REQUIRED_FIELDS, [
602                            'id', 'name', 'gender', 'image', 'voice', 'personality', 'industry',
603                        ])
604                    )),
605                ];
606            }
607        }
608
609        if (empty($problems)) {
610            return;
611        }
612
613        Log::warning('role_play.icp_generation.incomplete_output', [
614            'product' => $product,
615            'prompt_id' => (string) ($promptRecord->_id ?? ''),
616            'model' => (string) ($promptRecord->model ?? ''),
617            'user_id' => is_object($user) ? ($user->id ?? null) : null,
618            'total_icps' => count($icps),
619            'incomplete_count' => count($problems),
620            'problems' => $problems,
621        ]);
622    }
623
624    /**
625     * Force every generated ICP to carry a company_size that belongs to
626     * the persona's allowed set. Distributes round-robin if the AI drifted.
627     *
628     * @param  array<int, array<string, mixed>>  $icps
629     * @param  array<int, string>  $allowedSizes
630     * @return array<int, array<string, mixed>>
631     */
632    private function backfillIcpCompanySizes(array $icps, array $allowedSizes): array
633    {
634        if (empty($allowedSizes)) {
635            return $icps;
636        }
637
638        foreach ($icps as $i => $icp) {
639            $current = RolePlayProjects::companySizeKey($icp['company_size'] ?? '');
640            if (in_array($current, $allowedSizes, true)) {
641                $icps[$i]['company_size'] = RolePlayProjects::companySizeLabel($current);
642
643                continue;
644            }
645
646            $fallback = $allowedSizes[$i % count($allowedSizes)];
647            $icps[$i]['company_size'] = RolePlayProjects::companySizeLabel($fallback);
648        }
649
650        return $icps;
651    }
652
653    /**
654     * Hydrate AI-generated ICPs with personality, gender, name, image, and voice.
655     *
656     * Mirrors the legacy controller helper so the new flow produces ICPs that
657     * are drop-in compatible with the existing FE form fields.
658     *
659     * @param  array<mixed>  $icps  Raw AI response (array or {icps: [...]} wrapper)
660     * @param  array<int, array<string, mixed>>  $personalities
661     * @return array<int, array<string, mixed>>
662     */
663    private function assignPersonalityAndAvatar(array $icps, array $personalities, string $industry): array
664    {
665        // Unwrap if the AI returned {icps: [...]} for some reason
666        $hasWrapper = isset($icps['icps']) && is_array($icps['icps']);
667        $actual = $hasWrapper ? $icps['icps'] : $icps;
668
669        $config = RolePlayConfig::getGlobal();
670
671        $maleNames = ! empty($config?->names['male']) ? $config->names['male']
672            : (Parameter::where('name', 'role_play_male_names')->first()?->value ?? []);
673        $femaleNames = ! empty($config?->names['female']) ? $config->names['female']
674            : (Parameter::where('name', 'role_play_female_names')->first()?->value ?? []);
675
676        $maleImages = ! empty($config?->images['male']) ? $config->images['male']
677            : (Parameter::where('name', 'role_play_male_images')->first()?->value ?? []);
678        $femaleImages = ! empty($config?->images['female']) ? $config->images['female']
679            : (Parameter::where('name', 'role_play_female_images')->first()?->value ?? []);
680
681        $maleVoicesParam = Parameter::where('name', 'role_play_male_voices')->first()?->value ?? [];
682        $maleVoices = ! empty($maleVoicesParam) ? $maleVoicesParam : ($config?->voices['male'] ?? []);
683
684        $femaleVoicesParam = Parameter::where('name', 'role_play_female_voices')->first()?->value ?? [];
685        $femaleVoices = ! empty($femaleVoicesParam) ? $femaleVoicesParam : ($config?->voices['female'] ?? []);
686
687        $personalityMap = [];
688        foreach ($personalities as $p) {
689            $personalityMap[strtoupper($p['type'] ?? '')] = $p;
690        }
691
692        if (! empty($maleImages)) {
693            shuffle($maleImages);
694        }
695        if (! empty($femaleImages)) {
696            shuffle($femaleImages);
697        }
698
699        $maleIndex = 0;
700        $femaleIndex = 0;
701
702        foreach ($actual as $i => $icp) {
703            $row = is_array($icp) ? $icp : (array) $icp;
704
705            // Personality
706            $aiType = strtoupper((string) ($row['personality_type'] ?? ''));
707            if (isset($personalityMap[$aiType])) {
708                $row['personality'] = $personalityMap[$aiType];
709            } elseif (! is_array($row['personality'] ?? null) && ! empty($personalities)) {
710                $row['personality'] = $personalities[$i % count($personalities)];
711            }
712            unset($row['personality_type']);
713
714            // Gender
715            $aiGender = strtolower((string) ($row['gender'] ?? ''));
716            $row['gender'] = in_array($aiGender, ['male', 'female'], true)
717                ? $aiGender
718                : (($i % 2 === 0) ? 'male' : 'female');
719
720            // Name + image + voice
721            if ($row['gender'] === 'male') {
722                if (empty($row['name']) && ! empty($maleNames)) {
723                    $row['name'] = $maleNames[$maleIndex % count($maleNames)] ?? 'John';
724                }
725                $row['image'] = ! empty($maleImages) ? $maleImages[$maleIndex % count($maleImages)] : '';
726                $row['voice'] = ! empty($maleVoices) ? $maleVoices[array_rand($maleVoices)] : '';
727                $maleIndex++;
728            } else {
729                if (empty($row['name']) && ! empty($femaleNames)) {
730                    $row['name'] = $femaleNames[$femaleIndex % count($femaleNames)] ?? 'Emma';
731                }
732                $row['image'] = ! empty($femaleImages) ? $femaleImages[$femaleIndex % count($femaleImages)] : '';
733                $row['voice'] = ! empty($femaleVoices) ? $femaleVoices[array_rand($femaleVoices)] : '';
734                $femaleIndex++;
735            }
736
737            // Legacy field mapping + AI typo tolerance.
738            // Gemini occasionally drifts the key name (e.g. `decision_meaning`);
739            // catch the common variants before validation sees the payload.
740            if (empty($row['decision_making'])) {
741                foreach (['decision_maker', 'decision_meaning', 'decisionmaking', 'decisionMaking'] as $alias) {
742                    if (! empty($row[$alias])) {
743                        $row['decision_making'] = $row[$alias];
744                        unset($row[$alias]);
745                        break;
746                    }
747                }
748            }
749            if (empty($row['target_job_title']) && ! empty($row['job_title'])) {
750                $row['target_job_title'] = $row['job_title'];
751                unset($row['job_title']);
752            }
753            if (empty($row['target_job_title'])) {
754                $row['target_job_title'] = 'Decision Maker';
755            }
756            if (empty($row['industry']) && $industry !== '') {
757                $row['industry'] = $industry;
758            }
759            if (empty($row['id'])) {
760                $row['id'] = $i + 1;
761            }
762
763            $actual[$i] = $row;
764        }
765
766        return array_values($actual);
767    }
768}