Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
86 / 86 |
|
100.00% |
7 / 7 |
CRAP | |
100.00% |
1 / 1 |
| RolePlayPromptBuilderService | |
100.00% |
86 / 86 |
|
100.00% |
7 / 7 |
21 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| buildPromptForIcp | |
100.00% |
23 / 23 |
|
100.00% |
1 / 1 |
2 | |||
| buildPromptsForAllIcps | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| mergeObjections | |
100.00% |
25 / 25 |
|
100.00% |
1 / 1 |
6 | |||
| getPromptTemplate | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
5 | |||
| formatObjections | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
3 | |||
| buildPersonDescription | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
2 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace App\Http\Services; |
| 4 | |
| 5 | use App\Http\Models\AIPrompts; |
| 6 | use App\Http\Models\Parameter; |
| 7 | use App\Http\Models\RolePlayProjects; |
| 8 | use App\Http\Repositories\RolePlayObjectionsRepository; |
| 9 | use App\Http\Services\RolePlay\ObjectionNormalizer; |
| 10 | |
| 11 | class 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 | } |