Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
86 / 86
100.00% covered (success)
100.00%
7 / 7
CRAP
100.00% covered (success)
100.00%
1 / 1
RolePlayPromptBuilderService
100.00% covered (success)
100.00%
86 / 86
100.00% covered (success)
100.00%
7 / 7
21
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 buildPromptForIcp
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
2
 buildPromptsForAllIcps
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 mergeObjections
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
6
 getPromptTemplate
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 formatObjections
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 buildPersonDescription
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace App\Http\Services;
4
5use App\Http\Models\AIPrompts;
6use App\Http\Models\Parameter;
7use App\Http\Models\RolePlayProjects;
8use App\Http\Repositories\RolePlayObjectionsRepository;
9use App\Http\Services\RolePlay\ObjectionNormalizer;
10
11class RolePlayPromptBuilderService
12{
13    public function __construct(
14        private readonly ?RolePlayObjectionsRepository $objectionsRepository = null,
15    ) {}
16
17    /**
18     * Build a VAPI system prompt for a single ICP using the latest prompt template and objections.
19     *
20     * Objections injected into {all_objections} are the merge of:
21     *   1. The persona's user-managed objections (persona.objections[]).
22     *   2. The seeded {@see \App\Http\Models\RolePlayObjections} defaults
23     *      that match the persona's call type AND the selected ICP's
24     *      company size (or "all"), MINUS any default ids the user has
25     *      removed from this persona via removed_default_objection_ids.
26     *
27     * Size resolution: ICP.company_size is a freeform display label
28     * ("Small (10-99 employees)"); we map it to a short key with
29     * {@see RolePlayProjects::companySizeKey()}.
30     *
31     * @param  array  $persona  The persona/project data (must include 'type', 'objections', 'industry', 'description', 'name', 'key_features')
32     * @param  array  $icp  The ICP data (must include 'name', 'company_name', 'company_size', etc.)
33     * @return string The fully-resolved prompt ready for VAPI
34     */
35    public function buildPromptForIcp(array $persona, array $icp): string
36    {
37        $promptTemplate = $this->getPromptTemplate($persona['type'] ?? 'cold-call');
38
39        $allObjections = $this->mergeObjections($persona, $icp);
40        $allObjectionsString = $this->formatObjections($allObjections);
41
42        $personDescription = $this->buildPersonDescription($icp);
43
44        $placeholders = [
45            '{name}',
46            '{targetIndustry}',
47            '{persona_prompt}',
48            '{all_objections}',
49            '{product_description}',
50            '{key_features}',
51            '{product_name}',
52        ];
53
54        $replacements = [
55            $icp['name'] ?? 'Prospect',
56            is_array($persona['industry'] ?? '') ? implode(', ', $persona['industry']) : ($persona['industry'] ?? ''),
57            trim($personDescription),
58            $allObjectionsString,
59            $persona['description'] ?? '',
60            implode(', ', $persona['key_features'] ?? []),
61            $persona['name'] ?? 'our solution',
62        ];
63
64        return str_replace($placeholders, $replacements, $promptTemplate);
65    }
66
67    /**
68     * Build prompts for all ICPs in a persona (batch version).
69     *
70     * @param  array  $persona  The persona/project data
71     * @return array The ICPs with 'prompt' field populated
72     */
73    public function buildPromptsForAllIcps(array $persona): array
74    {
75        $icps = $persona['customer_profiles'] ?? [];
76
77        return array_map(function ($icp) use ($persona) {
78            $icpArray = is_array($icp) ? $icp : (array) $icp;
79            $icpArray['prompt'] = $this->buildPromptForIcp($persona, $icpArray);
80
81            return $icpArray;
82        }, $icps);
83    }
84
85    /**
86     * Merge persona-level user objections with seeded defaults applicable
87     * to the chosen ICP's company size and the persona's call type.
88     *
89     * Both sources are passed through {@see ObjectionNormalizer::normalize()}
90     * to tolerate legacy string options, then filtered by the ICP's
91     * company size so the AI only sees objections that realistically
92     * apply to that prospect.
93     *
94     * @return array<int, array{category: string, options: array<int, array{text: string, company_sizes: array<int, string>}>}>
95     */
96    private function mergeObjections(array $persona, array $icp): array
97    {
98        $userObjections = is_array($persona['objections'] ?? null) ? $persona['objections'] : [];
99
100        $repo = $this->objectionsRepository ?? new RolePlayObjectionsRepository;
101
102        $callType = (string) ($persona['type'] ?? 'cold-call');
103        $sizeKey = RolePlayProjects::companySizeKey($icp['company_size'] ?? '');
104        $excluded = is_array($persona['removed_default_objection_ids'] ?? null)
105            ? array_map('strval', $persona['removed_default_objection_ids'])
106            : [];
107
108        $defaultsRaw = $repo->defaultsForCompanySize($callType, $sizeKey, $excluded)
109            ->map(function ($row) {
110                // Each seed row already targets a single company size (or 'all').
111                // We tag every option with that size so the normalizer keeps it.
112                $seedSize = strtolower((string) ($row->company_size ?? 'all'));
113                $sizesForRow = $seedSize === 'all'
114                    ? RolePlayProjects::COMPANY_SIZE_KEYS
115                    : (in_array($seedSize, RolePlayProjects::COMPANY_SIZE_KEYS, true) ? [$seedSize] : RolePlayProjects::COMPANY_SIZE_KEYS);
116
117                $options = is_array($row->options) ? $row->options : [];
118
119                return [
120                    'category' => (string) $row->category,
121                    'options' => array_map(
122                        fn ($text) => ['text' => (string) $text, 'company_sizes' => $sizesForRow],
123                        $options,
124                    ),
125                ];
126            })
127            ->all();
128
129        $merged = ObjectionNormalizer::normalize(array_merge($defaultsRaw, $userObjections));
130
131        return ObjectionNormalizer::filterByCompanySize($merged, $sizeKey);
132    }
133
134    /**
135     * Load the prompt template for a given call type.
136     * Tries AIPrompts first, falls back to Parameter table.
137     */
138    private function getPromptTemplate(string $callType): string
139    {
140        $product = ($callType === 'cold-call') ? 'roleplay_cold_call' : 'roleplay_discovery_call';
141        $parameterName = ($callType === 'cold-call') ? 'role_play_cold_call_prompt' : 'role_play_discovery_call_prompt';
142
143        $aiPrompt = AIPrompts::where('product', $product)
144            ->where('status', 'active')
145            ->first();
146
147        if ($aiPrompt && ! empty($aiPrompt->context)) {
148            return $aiPrompt->context;
149        }
150
151        return Parameter::where('name', $parameterName)->first()?->value ?? '';
152    }
153
154    /**
155     * Format a normalized objections array into a string for prompt
156     * insertion. Each option carries `{text, company_sizes}` — only the
157     * text is surfaced to the model.
158     */
159    private function formatObjections(array $objections): string
160    {
161        $formatted = array_map(function ($objection) {
162            $options = is_array($objection['options'] ?? null) ? $objection['options'] : [];
163            $optionsString = implode("\n", array_map(
164                fn ($opt) => '- '.(is_array($opt) ? ($opt['text'] ?? '') : (string) $opt),
165                $options,
166            ));
167
168            return "{$objection['category']}:\n{$optionsString}";
169        }, $objections);
170
171        return implode("\n\n", $formatted);
172    }
173
174    /**
175     * Build the person description string from ICP fields.
176     */
177    private function buildPersonDescription(array $icp): string
178    {
179        $personalityDescription = is_array($icp['personality'] ?? null)
180            ? ($icp['personality']['description'] ?? '')
181            : ($icp['personality'] ?? '');
182
183        $description = "\n• Company Name: ".($icp['company_name'] ?? '');
184        $description .= "\n• Company Size: ".($icp['company_size'] ?? '');
185        $description .= "\n• Current Pain Points: ".($icp['pain_points'] ?? '');
186        $description .= "\n• Decision-Making Authority: ".($icp['decision_making'] ?? '');
187        $description .= "\n• Current Solution: ".($icp['current_solution'] ?? '');
188        $description .= "\n• Budget: ".($icp['budget'] ?? '');
189        $description .= "\n• Urgency Level: ".($icp['urgency_level'] ?? '');
190        $description .= "\n• Openness to New Solutions: ".($icp['openess_to_new_solutions'] ?? '');
191        $description .= "\n• Communication Style: ".($icp['communication_style'] ?? '');
192        $description .= "\n• Personality: {$personalityDescription}\n";
193
194        return $description;
195    }
196}