Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.26% covered (success)
99.26%
135 / 136
91.67% covered (success)
91.67%
11 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserPersonaService
99.26% covered (success)
99.26%
135 / 136
91.67% covered (success)
91.67%
11 / 12
32
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
 getAll
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 create
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 setDefault
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 update
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 delete
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 generateUserPersonaAIEmulation
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
2
 loadPromptRecord
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 buildPrompt
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
4
 assembleConditionalStep
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
7
 callAi
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
1
 resolveConfig
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
6.01
1<?php
2
3namespace App\Http\Services;
4
5use App\Events\TrackFlyMsgAIUsageEvent;
6use App\Http\Models\AIPrompts;
7use App\Http\Models\PromptTone;
8use App\Http\Models\UserPersona;
9use Illuminate\Support\Facades\Log;
10
11/**
12 * Service layer for UserPersona CRUD and AI-emulation generation.
13 *
14 * AI emulation routes every call through {@see NodeJsAIBridgeService} using
15 * config sourced from an `ai_prompts` record (product='fly_persona',
16 * name='person'|'company'). This is what lets CMC-FE operators tune model,
17 * tokens, temperature, top_p, and grounding without redeploying — and lets
18 * `ai_request_logs` audit what actually reached Gemini. See
19 * `flymsg-be/CLAUDE.md` → "AI Call Configuration Rule".
20 */
21class UserPersonaService
22{
23    /**
24     * Fallbacks used only when a field is missing on the ai_prompts record.
25     * The record's own value always wins when present.
26     */
27    private const CONFIG_FALLBACKS = [
28        'model' => 'gemini-3.1-flash-lite-preview',
29        'provider' => 'vertex',
30        'max_tokens' => 2000,
31        'temperature' => 1.0,
32        'top_p' => 0.95,
33        'thinking_budget' => 0,
34    ];
35
36    public function __construct(
37        private readonly NodeJsAIBridgeService $bridge,
38    ) {}
39
40    /**
41     * @return mixed
42     */
43    public function getAll(
44        int $itemsPerPage,
45        int $currentPage,
46        string $filter,
47        bool $disabled
48    ) {
49        $query = UserPersona::query();
50
51        if (! empty($filter)) {
52            $query = $query->where('title', 'like', '%'.$filter.'%');
53        }
54
55        if (! $disabled) {
56            $query = $query->where('disabled', false)->orWhereNull('disabled');
57        }
58
59        return $query->get();
60    }
61
62    public function create(array $data)
63    {
64        $persona = UserPersona::create($data);
65
66        if (! empty($data['is_default'])) {
67            UserPersona::where('_id', '!=', $persona->id)
68                ->where('user_id', $persona->user_id)
69                ->update(['is_default' => false]);
70        }
71
72        return $persona;
73    }
74
75    public function setDefault(string $userPersonaId, bool $value)
76    {
77        $persona = UserPersona::find($userPersonaId);
78
79        $persona->is_default = $value;
80
81        $persona->save();
82
83        UserPersona::where('_id', '!=', $userPersonaId)
84            ->where('user_id', $persona->user_id)
85            ->update(['is_default' => false]);
86
87        return $persona;
88    }
89
90    public function update(string $userPersonaId, array $data)
91    {
92        $persona = UserPersona::find($userPersonaId);
93
94        $persona->fill($data);
95
96        $persona->save();
97
98        if (! empty($data['is_default'])) {
99            UserPersona::where('_id', '!=', $userPersonaId)
100                ->where('user_id', $persona->user_id)
101                ->update(['is_default' => false]);
102        }
103
104        return $persona;
105    }
106
107    public function delete(UserPersona $userPersona)
108    {
109        return $userPersona->delete();
110    }
111
112    public function generateUserPersonaAIEmulation(array $data, $user, $regenerateCount)
113    {
114        $data['tone_of_voice'] = PromptTone::find($data['prompt_tone_id'])->prompt;
115
116        $promptRecord = $this->loadPromptRecord($data['type']);
117
118        $prompt = $this->buildPrompt($promptRecord, $data);
119
120        // Persona output is an English-formatted AI-emulation guide consumed
121        // downstream by other prompts as a <persona> tag — not rendered to
122        // the user. Keep it in English regardless of the user's input
123        // language; no translation round-trip needed.
124        $result = $this->callAi($prompt, $promptRecord, $user);
125
126        Log::info("Generate Persona Prompt for user Id: {$data['user_id']}", [
127            'input' => $data,
128            'prompt' => $prompt,
129            'result' => $result,
130        ]);
131
132        if ($regenerateCount > 0) {
133            TrackFlyMsgAIUsageEvent::dispatch(
134                $user,
135                $result,
136                'persona',
137                'ai_emulation',
138                $prompt,
139                'persona',
140                'ai_emulation',
141                [
142                    'response' => $result,
143                    'prompt' => $prompt,
144                ],
145                $data
146            );
147        }
148
149        return $result;
150    }
151
152    /**
153     * Load the fly_persona ai_prompts record for the given persona type
154     * ('person' or 'company'). Throws when the seeder hasn't run or the
155     * record was deactivated in CMC-FE.
156     */
157    private function loadPromptRecord(string $type): AIPrompts
158    {
159        $record = AIPrompts::where('product', 'fly_persona')
160            ->where('name', $type)
161            ->where('status', 'active')
162            ->first();
163
164        if (! $record) {
165            throw new \RuntimeException("AI prompt 'fly_persona/{$type}' is not configured.");
166        }
167
168        return $record;
169    }
170
171    /**
172     * Assemble the multi-step persona prompt from the ai_prompts record.
173     * Step 1 and Step 2 include an instructions block per entry only when
174     * the user supplied at least one of the referenced fields; steps with
175     * no supplied fields are dropped entirely (legacy behavior).
176     */
177    private function buildPrompt(AIPrompts $promptRecord, array $data): string
178    {
179        $placeholders = is_array($promptRecord->placeholders) ? $promptRecord->placeholders : [];
180
181        $step1 = $this->assembleConditionalStep(
182            (string) ($placeholders['step_1_instruction'] ?? ''),
183            (array) ($placeholders['step_1_steps'] ?? []),
184            $data,
185        );
186
187        $step2 = $this->assembleConditionalStep(
188            (string) ($placeholders['step_2_instruction'] ?? ''),
189            (array) ($placeholders['step_2_steps'] ?? []),
190            $data,
191        );
192
193        $step3 = (string) ($placeholders['step_3_instruction'] ?? '');
194        foreach ((array) ($placeholders['step_3_steps'] ?? []) as $step) {
195            $step3 .= "\n{$step}";
196        }
197
198        $step4 = (string) ($placeholders['step_4_instruction'] ?? '');
199        foreach ((array) ($placeholders['step_4_steps'] ?? []) as $step) {
200            $step4 .= "\n\nInput:\n".(string) ($step['input'] ?? '');
201            $step4 .= "\n\nOutput:\n".(string) ($step['output'] ?? '');
202        }
203
204        $persona = (string) ($promptRecord->persona ?? '');
205        $outputInstructions = (string) ($promptRecord->output_instructions ?? '');
206
207        // Filter empty sections so conditional steps (step_1 / step_2) that
208        // produce '' don't leave runs of four consecutive newlines in the
209        // rendered prompt — wasteful in tokens, noisier in logs.
210        return implode("\n\n", array_filter(
211            [$persona, $step1, $step2, $step3, $step4, $outputInstructions],
212            static fn (string $section): bool => $section !== '',
213        ));
214    }
215
216    /**
217     * Render step_1 / step_2: include each entry's instructions line plus
218     * whichever user fields it references, but skip entries whose fields
219     * are all empty. If no entry contributes any field, the whole step is
220     * dropped.
221     *
222     * @param  array<int, array{instructions?: string, fields?: array<int, string>}>  $steps
223     */
224    private function assembleConditionalStep(string $instruction, array $steps, array $data): string
225    {
226        $out = $instruction;
227        $hasAny = false;
228
229        foreach ($steps as $step) {
230            $line = "\n".(string) ($step['instructions'] ?? '');
231            $hasFields = false;
232
233            foreach ((array) ($step['fields'] ?? []) as $field) {
234                $value = $data[$field] ?? null;
235                if (is_array($value)) {
236                    $value = implode(', ', $value);
237                }
238
239                if (! empty($value)) {
240                    $hasFields = true;
241                    $line .= " {$value}";
242                }
243            }
244
245            if ($hasFields) {
246                $hasAny = true;
247                $out .= $line;
248            }
249        }
250
251        return $hasAny ? $out : '';
252    }
253
254    /**
255     * Forward the assembled prompt to the Node.js AI bridge, using model /
256     * tokens / temperature / top_p / is_grounding straight off the
257     * ai_prompts record. Metadata includes `prompt_id` so ai_request_logs
258     * can be audited against the source prompt.
259     */
260    private function callAi(string $prompt, AIPrompts $promptRecord, $user): string
261    {
262        $config = $this->resolveConfig($promptRecord);
263
264        return $this->bridge->generate(
265            [
266                'provider' => self::CONFIG_FALLBACKS['provider'],
267                'model' => $config['model'],
268                'prompt' => $prompt,
269                'config' => [
270                    'maxOutputTokens' => $config['maxOutputTokens'],
271                    'temperature' => $config['temperature'],
272                    'topP' => $config['topP'],
273                    'thinkingBudget' => $config['thinkingBudget'],
274                    'enableGoogleSearch' => $config['enableGoogleSearch'],
275                ],
276            ],
277            [
278                'feature' => 'persona_ai_emulation',
279                'user_id' => $user?->id,
280                'company_id' => $user?->company_id ?? null,
281                'prompt_id' => (string) $promptRecord->_id,
282            ]
283        );
284    }
285
286    /**
287     * Read the model/temperature/topP/tokens/grounding config from an
288     * ai_prompts record, applying conservative fallbacks only when a field
289     * is missing. Kept in one place so if an operator tunes the prompt in
290     * the DB, the tuning reaches the bridge.
291     *
292     * @return array{model: string, maxOutputTokens: int, temperature: float, topP: float, thinkingBudget: int, enableGoogleSearch: bool}
293     */
294    private function resolveConfig(AIPrompts $promptRecord): array
295    {
296        $model = $promptRecord->model !== null && $promptRecord->model !== ''
297            ? str_replace(':streamGenerateContent', '', (string) $promptRecord->model)
298            : self::CONFIG_FALLBACKS['model'];
299
300        return [
301            'model' => $model,
302            'maxOutputTokens' => $promptRecord->tokens !== null
303                ? (int) $promptRecord->tokens
304                : self::CONFIG_FALLBACKS['max_tokens'],
305            'temperature' => $promptRecord->temperature !== null
306                ? (float) $promptRecord->temperature
307                : self::CONFIG_FALLBACKS['temperature'],
308            'topP' => $promptRecord->top_p !== null
309                ? (float) $promptRecord->top_p
310                : self::CONFIG_FALLBACKS['top_p'],
311            'thinkingBudget' => self::CONFIG_FALLBACKS['thinking_budget'],
312            'enableGoogleSearch' => (bool) ($promptRecord->is_grounding ?? false),
313        ];
314    }
315}