Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.35% covered (success)
97.35%
110 / 113
33.33% covered (danger)
33.33%
1 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
CompanyRolePlayService
97.35% covered (success)
97.35%
110 / 113
33.33% covered (danger)
33.33%
1 / 3
33
0.00% covered (danger)
0.00%
0 / 1
 buildIcpPrompts
96.00% covered (success)
96.00%
48 / 50
0.00% covered (danger)
0.00%
0 / 1
9
 assignPersonalityAndAvatar
98.31% covered (success)
98.31%
58 / 59
0.00% covered (danger)
0.00%
0 / 1
23
 parseGeminiResponse
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace App\Http\Services;
4
5use App\Http\Models\AIPrompts;
6use App\Http\Models\Parameter;
7use App\Http\Models\RolePlayConfig;
8
9/**
10 * Service layer for company-level RolePlay project operations.
11 *
12 * Owns the business logic previously embedded in CompanyRolePlayController:
13 * prompt assembly per ICP, AI-generated-ICP enrichment (personality, avatar,
14 * voice, gender, naming), and Gemini response parsing. Keeping this logic in
15 * a service lets it be reused (e.g. by user-level persona flows) and unit-
16 * tested in isolation.
17 */
18class CompanyRolePlayService
19{
20    /**
21     * Build call prompts for each ICP on a project.
22     *
23     * Each ICP is the agent itself (personality, image, voice, gender, name)
24     * and receives its own `prompt` key — built from the project's type
25     * (cold-call vs discovery-call), industry, objections, and the ICP's own
26     * business attributes. Prompt templates are resolved from the active
27     * `AIPrompts` record first, then fall back to the legacy `Parameter` row.
28     *
29     * @param  array<string, mixed>  $persona  Project data containing customer_profiles, type, industry, objections.
30     * @return array<int, array<string, mixed>> Customer_profiles with per-ICP `prompt`.
31     */
32    public function buildIcpPrompts(array $persona): array
33    {
34        $coldCallPromptRecord = AIPrompts::where('product', 'roleplay_cold_call')
35            ->where('status', 'active')
36            ->first();
37        $discoveryCallPromptRecord = AIPrompts::where('product', 'roleplay_discovery_call')
38            ->where('status', 'active')
39            ->first();
40
41        $coldCallPromptValue = $coldCallPromptRecord
42            ? $coldCallPromptRecord->context
43            : (Parameter::where('name', 'role_play_cold_call_prompt')->first()?->value ?? '');
44        $discoveryCallPromptValue = $discoveryCallPromptRecord
45            ? $discoveryCallPromptRecord->context
46            : (Parameter::where('name', 'role_play_discovery_call_prompt')->first()?->value ?? '');
47
48        $promptTemplate = ($persona['type'] === 'cold-call')
49            ? $coldCallPromptValue
50            : $discoveryCallPromptValue;
51
52        $icps = $persona['customer_profiles'] ?? [];
53
54        $allObjections = array_map(function ($objection) {
55            $options = is_array($objection['options']) ? $objection['options'] : [];
56            $optionsString = implode("\n", array_map(
57                fn ($opt) => '- '.(is_array($opt) ? ($opt['text'] ?? '') : (string) $opt),
58                $options,
59            ));
60
61            return "{$objection['category']}:\n{$optionsString}";
62        }, $persona['objections'] ?? []);
63        $allObjectionsString = implode("\n\n", $allObjections);
64
65        return array_map(function ($icp) use ($persona, $allObjectionsString, $promptTemplate) {
66            $icpArray = is_array($icp) ? $icp : (array) $icp;
67
68            $personalityDescription = is_array($icpArray['personality'])
69                ? ($icpArray['personality']['description'] ?? '')
70                : $icpArray['personality'];
71
72            $person_description = "\n• Company Name: {$icpArray['company_name']}";
73            $person_description .= "\n• Company Size: {$icpArray['company_size']}";
74            $person_description .= "\n• Current Pain Points: {$icpArray['pain_points']}";
75            $person_description .= "\n• Decision-Making Authority: {$icpArray['decision_making']}";
76            $person_description .= "\n• Current Solution: {$icpArray['current_solution']}";
77            $person_description .= "\n• Budget: {$icpArray['budget']}";
78            $person_description .= "\n• Urgency Level: {$icpArray['urgency_level']}";
79            $person_description .= "\n• Openness to New Solutions: {$icpArray['openess_to_new_solutions']}";
80            $person_description .= "\n• Communication Style: {$icpArray['communication_style']}";
81            $person_description .= "\n• Personality: {$personalityDescription}\n";
82
83            $placeholders = ['{name}', '{targetIndustry}', '{persona_prompt}', '{all_objections}'];
84            $replacements = [
85                $icpArray['name'] ?? 'Prospect',
86                is_array($persona['industry'] ?? '') ? implode(', ', $persona['industry']) : ($persona['industry'] ?? ''),
87                trim($person_description),
88                $allObjectionsString,
89            ];
90
91            $icpArray['prompt'] = str_replace($placeholders, $replacements, $promptTemplate);
92
93            return $icpArray;
94        }, $icps);
95    }
96
97    /**
98     * Enrich AI-generated ICPs with personality, gender, avatar, voice and name.
99     *
100     * The generator returns business attributes only; this method:
101     *  - pads the list so every configured personality is represented,
102     *  - picks a personality round-robin,
103     *  - honours any gender the AI already suggested (and otherwise alternates),
104     *  - assigns a matching name/image/voice triple,
105     *  - migrates legacy `job_title` to `target_job_title`.
106     *
107     * Accepts either a flat `array<int, array>` of ICPs or the wrapped
108     * `{"icps": [...]}` shape that some prompts return.
109     *
110     * @param  array<mixed>  $icps
111     * @return array<mixed>
112     */
113    public function assignPersonalityAndAvatar(array $icps): array
114    {
115        $hasWrapper = isset($icps['icps']) && is_array($icps['icps']);
116        $actualIcps = $hasWrapper ? $icps['icps'] : $icps;
117
118        $config = RolePlayConfig::getGlobal();
119
120        if ($config) {
121            $maleNames = $config->names['male'] ?? [];
122            $maleImages = $config->images['male'] ?? [];
123            $maleVoices = $config->voices['male'] ?? [];
124            $femaleNames = $config->names['female'] ?? [];
125            $femaleImages = $config->images['female'] ?? [];
126            $femaleVoices = $config->voices['female'] ?? [];
127            $personalities = $config->personalities ?? [];
128        } else {
129            $maleNames = Parameter::where('name', 'role_play_male_names')->first()?->value ?? [];
130            $maleImages = Parameter::where('name', 'role_play_male_images')->first()?->value ?? [];
131            $maleVoices = Parameter::where('name', 'role_play_male_voices')->first()?->value ?? [];
132            $femaleNames = Parameter::where('name', 'role_play_female_names')->first()?->value ?? [];
133            $femaleImages = Parameter::where('name', 'role_play_female_images')->first()?->value ?? [];
134            $femaleVoices = Parameter::where('name', 'role_play_female_voices')->first()?->value ?? [];
135            $personalities = Parameter::where('name', 'role_play_personalities')->first()?->value ?? [];
136        }
137
138        if (empty($personalities)) {
139            return $icps;
140        }
141
142        $personalityCount = count($personalities);
143        while (count($actualIcps) < $personalityCount && ! empty($actualIcps)) {
144            $sourceIcp = $actualIcps[array_rand($actualIcps)];
145            $actualIcps[] = $sourceIcp;
146        }
147
148        shuffle($maleImages);
149        shuffle($femaleImages);
150        shuffle($personalities);
151
152        $maleNameIndex = 0;
153        $femaleNameIndex = 0;
154
155        foreach ($actualIcps as $index => &$icp) {
156            $icpArray = is_array($icp) ? $icp : (array) $icp;
157
158            $icpArray['personality'] = $personalities[$index % $personalityCount];
159
160            $aiGender = strtolower($icpArray['gender'] ?? '');
161            $icpArray['gender'] = in_array($aiGender, ['male', 'female'], true)
162                ? $aiGender
163                : (($index % 2 === 0) ? 'male' : 'female');
164
165            if ($icpArray['gender'] === 'male') {
166                if (empty($icpArray['name'])) {
167                    $icpArray['name'] = $maleNames[$maleNameIndex % count($maleNames)] ?? 'John';
168                }
169                $icpArray['image'] = ! empty($maleImages) ? $maleImages[$maleNameIndex % count($maleImages)] : '';
170                $icpArray['voice'] = ! empty($maleVoices) ? $maleVoices[array_rand($maleVoices)] : '';
171                $maleNameIndex++;
172            } else {
173                if (empty($icpArray['name'])) {
174                    $icpArray['name'] = $femaleNames[$femaleNameIndex % count($femaleNames)] ?? 'Emma';
175                }
176                $icpArray['image'] = ! empty($femaleImages) ? $femaleImages[$femaleNameIndex % count($femaleImages)] : '';
177                $icpArray['voice'] = ! empty($femaleVoices) ? $femaleVoices[array_rand($femaleVoices)] : '';
178                $femaleNameIndex++;
179            }
180
181            if (empty($icpArray['target_job_title']) && ! empty($icpArray['job_title'])) {
182                $icpArray['target_job_title'] = $icpArray['job_title'];
183                unset($icpArray['job_title']);
184            }
185            if (empty($icpArray['target_job_title'])) {
186                $icpArray['target_job_title'] = 'Decision Maker';
187            }
188
189            if (empty($icpArray['id'])) {
190                $icpArray['id'] = $index + 1;
191            }
192            $icp = $icpArray;
193        }
194
195        if ($hasWrapper) {
196            $icps['icps'] = array_values($actualIcps);
197
198            return $icps;
199        }
200
201        return array_values($actualIcps);
202    }
203
204    /**
205     * Strip Gemini's markdown fences and decode the JSON body.
206     *
207     * Gemini frequently wraps JSON output in ```json ... ``` blocks; the
208     * caller wants a decoded array (or scalar) regardless.
209     */
210    public function parseGeminiResponse(string $text): mixed
211    {
212        $completion = str_replace('```json', '', $text);
213        $completion = str_replace('```', '', $completion);
214        $completion = trim($completion);
215
216        return json_decode($completion, true);
217    }
218}