Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.12% covered (warning)
85.12%
532 / 625
60.00% covered (warning)
60.00%
9 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
FlyMsgAIService
85.12% covered (warning)
85.12%
532 / 625
60.00% covered (warning)
60.00%
9 / 15
280.22
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
 stripHtmlForPrompt
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 rewrite
100.00% covered (success)
100.00%
79 / 79
100.00% covered (success)
100.00%
1 / 1
19
 checkSentences
100.00% covered (success)
100.00%
72 / 72
100.00% covered (success)
100.00%
1 / 1
12
 convertStringToJson
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 engageGenerate
98.91% covered (success)
98.91%
182 / 184
0.00% covered (danger)
0.00%
0 / 1
58
 stripDetectedLanguageTag
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 postGenerate
82.66% covered (warning)
82.66%
143 / 173
0.00% covered (danger)
0.00%
0 / 1
92.11
 watchVideo
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
12
 extractYouTubeId
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 readBlogUrl
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 parseHtmlContent
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 generateViaBridge
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 transform
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 getTrackingResponse
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace App\Services\FlyMsgAI;
4
5use App\Filters\AIResponseFilter\Remove8Spaces;
6use App\Filters\AIResponseFilter\RemoveBackSlash;
7use App\Filters\AIResponseFilter\RemoveHorizontalRules;
8use App\Filters\AIResponseFilter\RemoveMarkdownBold;
9use App\Filters\AIResponseFilter\RemoveMarkdownHeaders;
10use App\Filters\AIResponseFilter\RemoveMarkdownLinks;
11use App\Filters\AIResponseFilter\RemoveMarkdownUnderlines;
12use App\Filters\AIResponseFilter\RemoveOutputPrefix;
13use App\Http\Models\FlyGrammarLanguage;
14use App\Http\Models\FlyMsgAI\FlyMsgAITracking;
15use App\Http\Models\PromptCompanyNewUpdate;
16use App\Http\Models\PromptLanguage;
17use App\Http\Models\PromptLengthOfPost;
18use App\Http\Models\PromptPersonalMilestone;
19use App\Http\Models\Prompts\CustomPrompts;
20use App\Http\Models\Prompts\PromptModel;
21use App\Http\Models\Prompts\PromptSetting;
22use App\Http\Models\Prompts\PromptType;
23use App\Http\Models\Prompts\YoutubeVideos;
24use App\Http\Models\PromptTone;
25use App\Http\Models\UserPersona;
26use App\Http\Services\AIPromptService;
27use App\Http\Services\NodeJsAIBridgeService;
28use App\Traits\Prompts\PromptTypeTrait;
29use Exception;
30use GuzzleHttp\Client;
31use Illuminate\Http\Response;
32use Illuminate\Pipeline\Pipeline;
33use Illuminate\Support\Facades\Cache;
34use Illuminate\Support\Facades\Log;
35use Illuminate\Support\Str;
36use Symfony\Component\DomCrawler\Crawler;
37
38class FlyMsgAIService
39{
40    use PromptTypeTrait;
41
42    /**
43     * Tail-of-prompt directive that tells Gemini to reply in the language
44     * of the user-supplied input (inside the <input> block), regardless of
45     * the fact that the surrounding instructions/examples are in English.
46     *
47     * Placement matters: LLMs weight the end of the prompt heavily, so this
48     * must be appended AFTER the <input> block and BEFORE the terminal
49     * "Output:" cue. Moving it earlier reintroduces the "prompt is
50     * English-dominant → Gemini defaults to English" failure mode.
51     *
52     * This directive is the "tail bookend". It is paired with
53     * {@see LANGUAGE_DETECTION_PREAMBLE} at the top of the prompt — the
54     * bookend pattern reinforces the rule against both the few-shot anchor
55     * (a non-English example biasing the output) and the English-dominant
56     * surrounding markup. Do not remove one without removing the other.
57     */
58    private const LANGUAGE_MIRROR_DIRECTIVE = <<<'DIRECTIVE'
59
60
61CRITICAL OUTPUT LANGUAGE RULE:
62Detect the language of the user-supplied text inside the <post> tag (or the equivalent user-content block) above.
63Your entire response MUST be written in that exact same language.
64Do NOT translate the input — write your reply natively in the input's language.
65DIRECTIVE;
66
67    /**
68     * Top-of-prompt preamble — the "head bookend" that pairs with
69     * {@see LANGUAGE_MIRROR_DIRECTIVE}. Sits immediately before the
70     * <persona> block so it carries prompt-head weight.
71     *
72     * Forces the model to emit a `[Detected Language: X]` tag as the first
73     * line of its response. The emission is not decorative: it makes the
74     * model actively evaluate the <post> content before drafting, and it
75     * gives ops a grep-able signal in logs for diagnosing language drift.
76     * The tag is stripped from the user-visible response by
77     * {@see stripDetectedLanguageTag}.
78     *
79     * Also explicitly addresses the few-shot anchor failure: a single
80     * non-English example (e.g. Spanish) in the <examples> block should not
81     * override the actual <post> language.
82     */
83    private const LANGUAGE_DETECTION_PREAMBLE = <<<'DIRECTIVE'
84CRITICAL OUTPUT LANGUAGE RULE — READ THIS FIRST:
851. Before generating anything else, detect the language of the text inside the <post>...</post> tag in the <input> block below.
862. The VERY FIRST LINE of your response MUST be exactly: [Detected Language: <language name in English, e.g. English, Spanish, Portuguese, French>]
873. Then emit one blank line, then the reply.
884. Write the entire reply natively in the detected language — do NOT translate, and do NOT default to English because the instructions and examples around you are in English.
895. A single example being in a different language (e.g. Spanish) does NOT mean the output should be in that language. The output language is decided ONLY by the <post> content inside <input>.
90DIRECTIVE;
91
92    /**
93     * Regex capturing the `[Detected Language: X]` tag the model is
94     * instructed to emit as the first line of its response. Matches an
95     * optional leading whitespace, the bracketed tag, and any trailing
96     * blank line. Used by {@see stripDetectedLanguageTag}.
97     */
98    private const DETECTED_LANGUAGE_PATTERN = '/^\s*\[Detected Language:\s*([^\]\n]+)\]\s*\r?\n\r?\n?/i';
99
100    public function __construct(
101        private AIPromptService $aiPromptService,
102        private NodeJsAIBridgeService $bridge
103    ) {}
104
105    /**
106     * Strip HTML markup + decode entities so the language signal inside the
107     * <input> block is the user's actual text, not LinkedIn's English-heavy
108     * markup (`<div class="…">`, `aria-label="…"`, etc.).
109     */
110    private function stripHtmlForPrompt(?string $html): string
111    {
112        if ($html === null || $html === '') {
113            return '';
114        }
115
116        $text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
117
118        // Collapse runs of whitespace so Gemini sees clean text, not
119        // leftover layout whitespace from the stripped markup.
120        return trim(preg_replace('/\s+/u', ' ', $text));
121    }
122
123    public function rewrite($data, ?string $userId = null, ?string $companyId = null)
124    {
125        $product = $data['product'];
126        $action = $data['action'];
127        $fullText = $data['context'] ?? '';
128        $input = $data['input'];
129
130        if (empty($input) || empty($action)) {
131            throw new Exception('Input and action are required', Response::HTTP_BAD_REQUEST);
132        }
133
134        $aiPrompt = $this->aiPromptService->getLatestByProductAndName($product, $action);
135
136        $prompt = "<intro>{$aiPrompt->context}</intro>\n";
137        $prompt .= "<mission>{$aiPrompt->mission}</mission>\n";
138        $prompt .= '<instructions>';
139        foreach ($aiPrompt->instructions as $index => $instruction) {
140            if ($index === 0) {
141                $prompt .= "{$instruction}\n";
142            } else {
143                $number = $index;
144                $prompt .= "{$number}{$instruction}\n";
145            }
146        }
147        $prompt .= "</instructions>\n";
148        $prompt .= '<constraints>';
149        foreach ($aiPrompt->constraints as $index => $constraint) {
150            if ($index === 0) {
151                $prompt .= "{$constraint}\n";
152            } else {
153                $number = $index;
154                $prompt .= "{$number}{$constraint}\n";
155            }
156        }
157        $prompt .= "</constraints>\n";
158        $prompt .= '<examples>';
159        foreach ($aiPrompt->examples as $index => $example) {
160            $number = $index + 1;
161            $exampleInput = $example['input'];
162            $exampleOutput = $example['output'];
163            $prompt .= "{Example {$number}}\n***Input: ***{$exampleInput}\n***Output: ***{$exampleOutput}\n";
164        }
165        $prompt .= "</examples>\n";
166
167        if (! empty($fullText)) {
168            // $prompt .= "<context>{$fullText}</context>\n";
169        }
170
171        switch ($action) {
172            case 'change-tone':
173                $tone = $data['tone'];
174                $tonePrompt = PromptTone::where('id', $tone)->first();
175                $prompt .= "<input>{$input}</input><tone>{$tonePrompt->name} - {$tonePrompt->prompt}</tone>\n";
176                break;
177            case 'translate':
178                $language = $data['language'];
179                $languagePrompt = FlyGrammarLanguage::where('id', $language)->first();
180                $prompt .= "<input>{$input}</input><language>{$languagePrompt->description} - {$languagePrompt->value}</language>\n";
181                break;
182            case 'humanize':
183            case 'make-shorter':
184            case 'make-longer':
185            case 'simplify':
186            case 'continue-writing':
187            case 'improve-writing':
188            default:
189                $prompt .= "<input>{$input}</input>\n";
190                break;
191        }
192
193        $prompt .= '***Output: ***';
194
195        $response = $this->generateViaBridge(
196            $prompt,
197            $aiPrompt->tokens,
198            $aiPrompt->temperature,
199            $aiPrompt->model,
200            $aiPrompt->top_p,
201            false,
202            null,
203            0,
204            ['feature' => 'rewrite', 'user_id' => $userId, 'company_id' => $companyId, 'context' => $data['context'] ?? null]
205        );
206
207        Log::info("FLYWRITE {$aiPrompt->product} - {$aiPrompt->name} - {$aiPrompt->version}", [
208            'input' => $data,
209            'output' => $response,
210            'prompt' => [
211                'prompt' => $prompt,
212                'input' => $input,
213            ],
214            'config' => $aiPrompt,
215        ]);
216
217        $output = $this->convertStringToJson(trim(str_replace('OUTPUT:', ' ', $response)));
218
219        if (is_string($output)) {
220            $output = [
221                'suggestion' => $output,
222            ];
223        }
224
225        return [
226            'prompt' => $prompt,
227            'output' => $output,
228        ];
229    }
230
231    public function checkSentences($sentences, $force = false)
232    {
233        $cachedSentences = [];
234        $newSentences = [];
235        foreach ($sentences as $sentence) {
236            $md5 = md5($sentence);
237
238            if ($force) {
239                Cache::forget("flywrite_sentence_{$md5}");
240            }
241
242            if (Cache::has("flywrite_sentence_{$md5}")) {
243                $cachedSentences[] = Cache::get("flywrite_sentence_{$md5}");
244            } else {
245                $newSentences[] = $sentence;
246            }
247        }
248
249        $aiPrompt = $this->aiPromptService->getLatestByProductAndName('sentence_rewrite', 'score');
250
251        if (empty($newSentences)) {
252            return [
253                'sentences' => $cachedSentences,
254                'threshold' => $aiPrompt->threshold,
255            ];
256        }
257
258        $prompt = "<mission>{$aiPrompt->mission}</mission>\n";
259        $prompt .= "<context>{$aiPrompt->context}</context>\n";
260        $prompt .= '<instructions>';
261        foreach ($aiPrompt->instructions as $index => $instruction) {
262            if ($index === 0) {
263                $prompt .= "{$instruction}\n";
264            } else {
265                $number = $index + 1;
266                $prompt .= "{$number}{$instruction}\n";
267            }
268        }
269        $prompt .= "</instructions>\n";
270        $prompt .= '<constraints>';
271        foreach ($aiPrompt->constraints as $index => $constraint) {
272            if ($index === 0) {
273                $prompt .= "{$constraint}\n";
274            } else {
275                $number = $index + 1;
276                $prompt .= "{$number}{$constraint}\n";
277            }
278        }
279        $prompt .= "</constraints>\n";
280        $prompt .= '<examples>';
281        foreach ($aiPrompt->examples as $index => $example) {
282            $number = $index + 1;
283            $exampleInput = $example['input'];
284            $exampleOutput = $example['output'];
285            $prompt .= "{Example {$number}}\n***Input: ***{$exampleInput}\n***Output: ***{$exampleOutput}\n";
286        }
287        $prompt .= "</examples>\n";
288
289        $newSentences = array_map(function ($sentence, $index) {
290            $number = $index + 1;
291
292            return "{$number}{$sentence}";
293        }, $newSentences, array_keys($newSentences));
294        $newSentences = implode("\n", $newSentences);
295        $prompt .= "<input>\n{$newSentences}\n</input>";
296        $prompt .= '***Output: ***';
297
298        $response = $this->generateViaBridge(
299            $prompt,
300            $aiPrompt->tokens,
301            $aiPrompt->temperature,
302            $aiPrompt->model,
303            $aiPrompt->top_p,
304            false,
305            null,
306            0,
307            ['feature' => 'check_sentences']
308        );
309
310        Log::info("FLYWRITE {$aiPrompt->product} - {$aiPrompt->name} - {$aiPrompt->version}", [
311            'input' => $newSentences,
312            'output' => $response,
313            'prompt' => $prompt,
314            'config' => $aiPrompt,
315        ]);
316
317        $result = trim($response);
318
319        $validatedSentences = $this->convertStringToJson($result);
320
321        $validatedSentences = is_array($validatedSentences) ? $validatedSentences : [];
322        foreach ($validatedSentences as $sentence) {
323            // cache sentences
324            $md5 = md5($sentence['sentence']);
325            Cache::put("flywrite_sentence_{$md5}", $sentence, 60 * 60 * 10); // cache for 10 hours
326        }
327
328        return [
329            'sentences' => array_merge($cachedSentences, $validatedSentences),
330            'threshold' => $aiPrompt->threshold,
331        ];
332    }
333
334    private function convertStringToJson(string $input)
335    {
336        $cleanedInput = Str::of($input)
337            ->replaceMatches('/^```json/m', '')
338            ->replaceMatches('/```$/m', '')
339            ->trim();
340
341        try {
342            return json_decode($cleanedInput, true, 512, JSON_THROW_ON_ERROR);
343        } catch (\Exception $e) {
344            return $input;
345        }
346    }
347
348    /**
349     * Generate an engage response using AI.
350     *
351     * @param  string  $promptId  The AIPrompts ID
352     * @param  array  $context  The post context data
353     * @param  string  $uniqueId  Unique identifier for tracking
354     * @param  mixed  $user  The authenticated user
355     * @param  bool  $useHashtags  Whether to include hashtags
356     * @param  bool  $useEmojis  Whether to include emojis
357     * @param  string|null  $personaId  UserPersona ID
358     * @param  string|null  $toneId  PromptTone ID
359     * @param  string|null  $additionalMission  Additional instructions
360     * @param  string|null  $customPromptId  CustomPrompts ID
361     * @param  bool  $is_a_regenerate_request  Whether this is a regeneration
362     * @param  bool  $isReply  Whether this is a reply
363     * @param  bool  $dryRun  If true, return assembled prompt without calling AI API
364     * @return array{prompt: string, response: string|null, config?: array}
365     */
366    public function engageGenerate(
367        string $promptId,
368        $context,
369        $uniqueId,
370        $user,
371        bool $useHashtags,
372        bool $useEmojis,
373        ?string $personaId = null,
374        ?string $toneId = null,
375        ?string $additionalMission = null,
376        ?string $customPromptId = null,
377        $is_a_regenerate_request = false,
378        $isReply = false,
379        bool $dryRun = false
380    ) {
381        $aiPrompt = $this->aiPromptService->getById($promptId);
382        $name = strtolower($aiPrompt->name);
383
384        $customPrompt = ! empty($customPromptId) ? CustomPrompts::find($customPromptId) : null;
385        $tone = ! empty($toneId) ? PromptTone::find($toneId) : null;
386
387        $userPersona = ! empty($personaId) ? UserPersona::where('_id', $personaId)->first() : null;
388        if (! empty($userPersona) && $userPersona->prompt_tone) {
389            $tone = $userPersona->prompt_tone;
390        }
391
392        if (empty($userPersona)) {
393            $defaultUserPersona = UserPersona::where('user_id', $user->id)->where('is_default', true)->first();
394            if (! empty($defaultUserPersona)) {
395                $userPersona = $defaultUserPersona;
396            }
397        }
398
399        if (! empty($customPrompt) && ! empty($customPrompt->template_prompt_id)) {
400            $aiPrompt = $this->aiPromptService->getById($customPrompt->template_prompt_id);
401        }
402
403        $prompt = $aiPrompt->context;
404
405        if (! empty($customPrompt) && ! empty($customPrompt->persona_id) && empty($userPersona)) {
406            $userPersona = UserPersona::where('_id', $customPrompt->persona_id)->first();
407        }
408
409        $persona = ! empty($userPersona) ? $userPersona->ai_emulation : $aiPrompt->persona;
410
411        if (empty($additionalMission) && ! empty($customPrompt)) {
412            $additionalMission = $customPrompt->additional_instructions ?? '';
413        }
414
415        // Head bookend: language rule + [Detected Language: X] emission
416        // instruction, placed BEFORE <persona> so it carries prompt-head
417        // weight. Paired with the tail LANGUAGE_MIRROR_DIRECTIVE appended
418        // after </input>. See constant docblocks for the rationale.
419        $prompt = self::LANGUAGE_DETECTION_PREAMBLE."\n\n{$prompt}\n<persona>{$persona}</persona>";
420        $mission = $aiPrompt->mission;
421        if ($name === 'custom') {
422            $mission = str_replace('{{CUSTOM_PROMPT}}', $mission, $additionalMission);
423        }
424        if (! empty($additionalMission) && $name !== 'custom') {
425            $mission = "{$mission}\n{$additionalMission}";
426        }
427
428        $tracking_responses = $this->getTrackingResponse($user->id, $name, 'flyengage', $uniqueId);
429
430        $existentContent = $is_a_regenerate_request && count($tracking_responses) > 0 ? $aiPrompt->existent_content_instructions : '';
431
432        if (! empty($existentContent)) {
433            $mission = "{$mission}\n**Regenerate**: {$existentContent}";
434        }
435
436        $prompt = "{$prompt}\n<mission>{$mission}</mission>";
437
438        if (! empty($customPrompt) && ! empty($customPrompt->prompt_tone_id) && empty($tone)) {
439            $tone = PromptTone::find($customPrompt->prompt_tone_id);
440        }
441
442        if (! empty($tone)) {
443            $prompt = "{$prompt}\n<tone_of_voice>Write in the following tone of voice:{$tone->prompt}</tone_of_voice>";
444        }
445
446        $instructions = '';
447        $instructionsCount = 0;
448        foreach ($aiPrompt->instructions as $instruction) {
449            if ($instructionsCount === 0) {
450                $instructions .= "{$instruction}\n";
451            } else {
452                $instructions .= "{$instructionsCount}{$instruction}\n";
453            }
454            $instructionsCount++;
455        }
456
457        $constraints = '';
458        $constraintsCount = 0;
459        foreach ($aiPrompt->constraints as $constraint) {
460            if ($constraintsCount === 0) {
461                $constraints .= "{$constraint}\n";
462            } else {
463                $constraints .= "{$constraintsCount}{$constraint}\n";
464            }
465            $constraintsCount++;
466        }
467
468        if ($useHashtags) {
469            $instructions = "{$instructions}\n{$instructionsCount}{$aiPrompt->include_hashtags_prompt}";
470            $instructionsCount++;
471        } else {
472            $constraints = "{$constraints}\n{$constraintsCount}{$aiPrompt->exclude_hashtags_prompt}";
473            $constraintsCount++;
474        }
475
476        if ($useEmojis) {
477            $instructions = "{$instructions}\n{$instructionsCount}{$aiPrompt->include_emojis_prompt}";
478            $instructionsCount++;
479        } else {
480            $constraints = "{$constraints}\n{$constraintsCount}{$aiPrompt->exclude_emojis_prompt}";
481            $constraintsCount++;
482        }
483
484        if ($is_a_regenerate_request && count($tracking_responses) > 0) {
485            foreach ($aiPrompt->existent_content_constraints as $constraint) {
486                $constraints = "{$constraints}\n{$constraintsCount}{$constraint}";
487                $constraintsCount++;
488            }
489        }
490
491        if ($isReply) {
492            $instructions = "{$instructions}\n{$instructionsCount}. Reply to this specific comment or reply provided.";
493        }
494
495        $prompt = "{$prompt}\n<instructions>{$instructions}</instructions>";
496        $prompt = "{$prompt}\n<constraints>{$constraints}</constraints>";
497
498        $existentContentCount = 1;
499        if (! empty($existentContent)) {
500            foreach ($tracking_responses as $response) {
501                $existentContent = "{$existentContent}\n{$existentContentCount}.\n<existent_content>{$response}</existent_content>\n";
502                $existentContentCount++;
503            }
504
505            $prompt = "{$prompt}\n<existent_contents>{$existentContent}</existent_contents>";
506        }
507
508        // examples
509        if (! empty($aiPrompt->examples)) {
510            $prompt .= '<examples>';
511            $exampleCount = 1;
512            foreach ($aiPrompt->examples as $example) {
513                $exampleInput = $example['input'];
514                $exampleOutput = $example['output'];
515                if (! empty($exampleInput) || ! empty($exampleOutput)) {
516                    $prompt .= "{Example {$exampleCount}}\n***Input: ***{$exampleInput}\n***Output: ***{$exampleOutput}\n";
517                    $exampleCount++;
518                }
519            }
520            $prompt .= "</examples>\n";
521        }
522
523        $author = $context['post']['postedBy'] ?? null;
524        $authorDescription = $context['post']['description'] ?? null;
525        $postedTime = $context['post']['postedTime'] ?? null;
526        $postContent = $this->stripHtmlForPrompt($context['post']['content'] ?? null);
527
528        $prompt = "{$prompt}\n<input>";
529        if (! empty($author)) {
530            $prompt = "{$prompt}\n**Author**: {$author}";
531        }
532        if (! empty($authorDescription)) {
533            $prompt = "{$prompt}\n**Author Description**: {$authorDescription}";
534        }
535        if (! empty($postedTime)) {
536            $prompt = "{$prompt}\n**Posted Time**: {$postedTime}";
537        }
538        $prompt = "{$prompt}\n**Post**: <post>{$postContent}</post>";
539
540        if ($isReply) {
541            $comments = $context['comments'] ?? [];
542            $comments = array_filter($comments, function ($comment) {
543                return $comment['isReplyingTo'] ?? false;
544            });
545            if (count($comments) > 0) {
546                $prompt = "{$prompt}\n**Comment**:";
547                $comments = array_map(function ($comment) {
548                    $output = '**Comment**:';
549                    $replies = $comment['replies'] ?? [];
550                    $repliesTrue = array_filter($replies, function ($reply) {
551                        return $reply['isReplyingTo'] ?? false;
552                    });
553                    if (count($replies) == 0 || count($repliesTrue) == 0) {
554                        $output = "{$output}\nReply to this specific comment.";
555                    }
556
557                    $author = $comment['commenterName'] ?? '';
558                    $authorUrl = $comment['commenterUrl'] ?? '';
559                    $commentText = $comment['commentText'] ?? '';
560
561                    if (! empty($author)) {
562                        $output = "{$output}\nAuthor: {$author}";
563                    }
564                    if (! empty($authorUrl)) {
565                        $output = "{$output}\nAuthor URL: {$authorUrl}";
566                    }
567                    if (! empty($commentText)) {
568                        $output = "{$output}\nComment: {$commentText}";
569                    }
570
571                    if (count($replies) > 0 && count($repliesTrue) > 0) {
572                        $output = "{$output}\nReplies:";
573
574                        $repliesTrue = array_filter($replies, function ($reply) {
575                            return $reply['isReplyingTo'] ?? false;
576                        });
577
578                        $repliesTrue = array_map(function ($reply, $index) {
579                            $author = $reply['commenterName'] ?? '';
580                            $authorUrl = $reply['commenterUrl'] ?? '';
581                            $commentText = $reply['commentText'] ?? '';
582
583                            $result = ($index + 1).'. ';
584                            if (! empty($author)) {
585                                $result = "{$result}\nAuthor: {$author}";
586                            }
587                            if (! empty($authorUrl)) {
588                                $result = "{$result}\nAuthor URL: {$authorUrl}";
589                            }
590                            if (! empty($commentText)) {
591                                $result = "{$result}\nComment: {$commentText}";
592                            }
593
594                            $result = "{$result} Reply to this specific reply.";
595
596                            return $result;
597                        }, $repliesTrue, array_keys($repliesTrue));
598
599                        $repliesTrue = implode("\n", $repliesTrue);
600                        $output = "{$output}\n{$repliesTrue}";
601                    }
602
603                    return $output;
604                }, $comments);
605
606                $prompt = "{$prompt}\n".implode("\n", $comments);
607            }
608        }
609
610        $prompt = "{$prompt}\n</input>";
611
612        $prompt = "{$prompt}\n".self::LANGUAGE_MIRROR_DIRECTIVE;
613
614        $prompt = "{$prompt}\nOutput:";
615
616        if ($dryRun) {
617            return [
618                'prompt' => $prompt,
619                'response' => null,
620                'config' => [
621                    'model' => $aiPrompt->model,
622                    'temperature' => $aiPrompt->temperature,
623                    'max_tokens' => $aiPrompt->tokens,
624                    'top_p' => $aiPrompt->top_p,
625                ],
626            ];
627        }
628
629        $response = $this->generateViaBridge(
630            $prompt,
631            $aiPrompt->tokens,
632            $aiPrompt->temperature,
633            $aiPrompt->model,
634            $aiPrompt->top_p,
635            $aiPrompt->is_grounding,
636            null,
637            0,
638            [
639                'feature' => 'engage_generate',
640                'user_id' => $user->id ?? null,
641                'company_id' => $user->company_id ?? null,
642                'prompt_id' => (string) $aiPrompt->_id,
643            ]
644        );
645
646        [$responseWithoutTag, $detectedLanguage] = $this->stripDetectedLanguageTag($response);
647        $cleanResponse = $this->transform($responseWithoutTag);
648
649        Log::info('FlyMSG AI: ', [
650            'prompt' => $prompt,
651            'response' => $cleanResponse,
652            'detected_language' => $detectedLanguage,
653            'ai_prompt' => $aiPrompt,
654        ]);
655
656        return [
657            'prompt' => $prompt,
658            'response' => $cleanResponse,
659        ];
660    }
661
662    /**
663     * Remove the `[Detected Language: X]` tag the model is instructed to
664     * emit as the first line (see {@see LANGUAGE_DETECTION_PREAMBLE}) and
665     * return the captured language alongside the stripped body.
666     *
667     * Kept separate from the transform() pipeline on purpose: the pipeline
668     * is shared with flows that don't use the language preamble, and we
669     * want the captured language available for logging regardless of what
670     * transform() does to the rest of the body.
671     *
672     * @return array{0: string, 1: string|null} [responseWithoutTag, detectedLanguage]
673     */
674    private function stripDetectedLanguageTag(string $response): array
675    {
676        if (preg_match(self::DETECTED_LANGUAGE_PATTERN, $response, $matches)) {
677            $stripped = (string) preg_replace(self::DETECTED_LANGUAGE_PATTERN, '', $response, 1);
678
679            return [ltrim($stripped), trim($matches[1])];
680        }
681
682        return [$response, null];
683    }
684
685    /**
686     * Generate a post response using AI.
687     *
688     * @param  string  $promptId  The AIPrompts ID
689     * @param  string|null  $youtube_url  YouTube URL for video-based posts
690     * @param  string|null  $blog_url  Blog URL for article-based posts
691     * @param  string  $uniqueId  Unique identifier for tracking
692     * @param  mixed  $user  The authenticated user
693     * @param  bool  $useHashtags  Whether to include hashtags
694     * @param  bool  $useEmojis  Whether to include emojis
695     * @param  string  $promptLanguageId  PromptLanguage ID
696     * @param  string  $lengthOfPostId  PromptLengthOfPost ID
697     * @param  string|null  $topic  Topic for thought leadership posts
698     * @param  string|null  $insert_role  Role for hiring posts
699     * @param  string|null  $promptCompanyNewUpdateId  PromptCompanyNewUpdate ID
700     * @param  string|null  $promptPersonalMilestoneId  PromptPersonalMilestone ID
701     * @param  string|null  $personaId  UserPersona ID
702     * @param  string|null  $toneId  PromptTone ID
703     * @param  string|null  $additionalMission  Additional instructions
704     * @param  string|null  $customPromptId  CustomPrompts ID
705     * @param  bool  $is_a_regenerate_request  Whether this is a regeneration
706     * @param  bool  $dryRun  If true, return assembled prompt without calling AI API
707     * @return array{prompt: string, response: string|null, config?: array}
708     */
709    public function postGenerate(
710        string $promptId,
711        $youtube_url,
712        $blog_url,
713        $uniqueId,
714        $user,
715        bool $useHashtags,
716        bool $useEmojis,
717        string $promptLanguageId,
718        string $lengthOfPostId,
719        ?string $topic,
720        ?string $insert_role,
721        ?string $promptCompanyNewUpdateId = null,
722        ?string $promptPersonalMilestoneId = null,
723        ?string $personaId = null,
724        ?string $toneId = null,
725        ?string $additionalMission = null,
726        ?string $customPromptId = null,
727        $is_a_regenerate_request = false,
728        bool $dryRun = false
729    ) {
730        $aiPrompt = $this->aiPromptService->getById($promptId);
731        $name = strtolower($aiPrompt->name);
732
733        $language = PromptLanguage::find($promptLanguageId);
734        $lengthOfPost = PromptLengthOfPost::find($lengthOfPostId);
735        $customPrompt = ! empty($customPromptId) ? CustomPrompts::find($customPromptId) : null;
736        $tone = ! empty($toneId) ? PromptTone::find($toneId) : null;
737        $promptCompanyNewUpdate = ! empty($promptCompanyNewUpdateId) ? PromptCompanyNewUpdate::find($promptCompanyNewUpdateId) : null;
738        $promptPersonalMilestone = ! empty($promptPersonalMilestoneId) ? PromptPersonalMilestone::find($promptPersonalMilestoneId) : null;
739
740        $userPersona = ! empty($personaId) ? UserPersona::where('_id', $personaId)->first() : null;
741        if (! empty($userPersona) && $userPersona->prompt_tone) {
742            $tone = $userPersona->prompt_tone;
743        }
744
745        if (empty($userPersona)) {
746            $defaultUserPersona = UserPersona::where('user_id', $user->id)->where('is_default', true)->first();
747            if (! empty($defaultUserPersona)) {
748                $userPersona = $defaultUserPersona;
749            }
750        }
751
752        if (! empty($customPrompt) && ! empty($customPrompt->template_prompt_id)) {
753            $aiPrompt = $this->aiPromptService->getById($customPrompt->template_prompt_id);
754        }
755
756        $prompt = $aiPrompt->context;
757
758        if (! empty($youtube_url) || ! empty($blog_url)) {
759            $prompt = "{$prompt}\nYou will receive a URL and detailed instructions on crafting the perfect post.";
760        }
761
762        if (! empty($customPrompt) && ! empty($customPrompt->persona_id) && empty($userPersona)) {
763            $userPersona = UserPersona::where('_id', $customPrompt->persona_id)->first();
764        }
765
766        $persona = ! empty($userPersona) ? $userPersona->ai_emulation : $aiPrompt->persona;
767        $prompt = "{$prompt}\n<persona>{$persona}</persona>";
768
769        $mission = $aiPrompt->mission;
770
771        if ($name == 'thought leadership') {
772            $mission = str_replace('{{topic}}', $topic, $mission);
773        } elseif ($name == 'company news') {
774            $mission = str_replace('{{promptCompanyNewUpdate}}', $promptCompanyNewUpdate->prompt, $mission);
775        } elseif ($name == 'celebrate something') {
776            $mission = str_replace('{{promptPersonalMilestone}}', $promptPersonalMilestone->prompt, $mission);
777        } elseif ($name == 'hiring') {
778            $mission = str_replace('{{insert_role}}', $insert_role, $mission);
779        } elseif ($name === 'custom') {
780            $mission = str_replace('{{CUSTOM_PROMPT}}', $mission, $additionalMission);
781        }
782
783        if (! empty($youtube_url)) {
784            $mission = $aiPrompt->youtube_url_mission;
785        } elseif (! empty($blog_url)) {
786            $mission = $aiPrompt->blog_url_mission;
787        }
788
789        if (! empty($additionalMission) && $name !== 'custom') {
790            $mission = "{$mission}\n{$additionalMission}";
791        }
792
793        if (! empty($youtube_url)) {
794            $uniqueId = $this->extractYouTubeId($youtube_url);
795        }
796
797        if (! empty($blog_url)) {
798            $uniqueId = $blog_url;
799        }
800
801        $tracking_responses = $this->getTrackingResponse($user->id, $name, 'flypost', $uniqueId);
802
803        $existentContent = $is_a_regenerate_request && count($tracking_responses) > 0 ? $aiPrompt->existent_content_instructions : '';
804
805        if (! empty($existentContent)) {
806            $mission = "{$mission}\n**Regenerate**: {$existentContent}";
807        }
808
809        $prompt = "{$prompt}\n<mission>{$mission}</mission>";
810
811        if (! empty($youtube_url)) {
812            try {
813                $youtubeVideo = $this->watchVideo($youtube_url, $name);
814                if (empty($youtubeVideo)) {
815                    throw new Exception('It was not possible to read the content of the video. Please, provide a valid URL.');
816                }
817                $youtubeSummary = $youtubeVideo->result;
818            } catch (Exception $e) {
819                $youtubeSummary = 'It was not possible to read the content of the video. Please, provide a valid URL.';
820            }
821
822            $prompt = "{$prompt}\n**Youtube URL: {$youtube_url}**\n<youtube_video_summary>{$youtubeSummary}</youtube_video_summary>";
823        }
824
825        if (! empty($blog_url)) {
826            try {
827                $article = $this->readBlogUrl($blog_url);
828            } catch (Exception $e) {
829                $article = 'It was not possible to read the content of the blog. Please, provide a valid URL.';
830            }
831
832            $prompt = "{$prompt}\n**Blog URL: {$blog_url}**\n<scraped_content>{$article}</scraped_content>";
833        }
834
835        if (! empty($customPrompt) && ! empty($customPrompt->prompt_tone_id) && empty($tone)) {
836            $tone = PromptTone::find($customPrompt->prompt_tone_id);
837        }
838
839        if (! empty($tone)) {
840            $prompt = "{$prompt}\n<tone_of_voice>Write in the following tone of voice:{$tone->prompt}</tone_of_voice>";
841        }
842
843        $instructions = '';
844        $instructionsCount = 0;
845        foreach ($aiPrompt->instructions as $instruction) {
846            if ($instructionsCount === 0) {
847                $instructions .= "{$instruction}\n";
848            } else {
849                $instructions .= "{$instructionsCount}{$instruction}\n";
850            }
851            $instructionsCount++;
852        }
853
854        if (! empty($youtube_url)) {
855            foreach ($aiPrompt->youtube_url_instructions as $instruction) {
856                $instructions = "{$instructions}\n{$instructionsCount}{$instruction}";
857                $instructionsCount++;
858            }
859        }
860
861        foreach ($aiPrompt->instructions as $instruction) {
862            $instructions = "{$instructions}\n{$instructionsCount}{$instruction}";
863            $instructionsCount++;
864        }
865
866        if (! empty($blog_url)) {
867            foreach ($aiPrompt->blog_url_instructions as $instruction) {
868                $instructions = "{$instructions}\n{$instructionsCount}{$instruction}";
869                $instructionsCount++;
870            }
871        }
872
873        $constraints = 'Here are some constraints to consider when creating a social media post:';
874        $constraintsCount = 0;
875        foreach ($aiPrompt->constraints as $constraint) {
876            if ($constraintsCount === 0) {
877                $constraints .= "{$constraint}\n";
878            } else {
879                $constraints .= "{$constraintsCount}{$constraint}\n";
880            }
881            $constraintsCount++;
882        }
883
884        if ($useHashtags) {
885            $instructions = "{$instructions}\n{$instructionsCount}{$aiPrompt->include_hashtags_prompt}";
886            $instructionsCount++;
887        } else {
888            $constraints = "{$constraints}\n{$constraintsCount}{$aiPrompt->exclude_hashtags_prompt}";
889            $constraintsCount++;
890        }
891
892        if ($useEmojis) {
893            $instructions = "{$instructions}\n{$instructionsCount}{$aiPrompt->include_emojis_prompt}";
894            $instructionsCount++;
895        } else {
896            $constraints = "{$constraints}\n{$constraintsCount}{$aiPrompt->exclude_emojis_prompt}";
897            $constraintsCount++;
898        }
899
900        if ($is_a_regenerate_request && count($tracking_responses) > 0) {
901            foreach ($aiPrompt->existent_content_constraints as $constraint) {
902                $constraints = "{$constraints}\n{$constraintsCount}{$constraint}";
903                $constraintsCount++;
904            }
905        }
906
907        $prompt = "{$prompt}\n<instructions>{$instructions}</instructions>";
908        $prompt = "{$prompt}\n<constraints>{$constraints}</constraints>";
909
910        $existentContentCount = 1;
911        if (! empty($existentContent)) {
912            foreach ($tracking_responses as $response) {
913                $existentContent = "{$existentContent}\n{$existentContentCount}.\n<existent_content>{$response}</existent_content>\n";
914                $existentContentCount++;
915            }
916
917            $prompt = "{$prompt}\n<existent_contents>{$existentContent}</existent_contents>";
918        }
919
920        $prompt .= '<examples>';
921        $exampleCount = 1;
922        $lenghtPostExamples = array_filter($aiPrompt->examples, function ($example) use ($lengthOfPost) {
923            return $example['length'] == $lengthOfPost->name;
924        });
925
926        foreach ($lenghtPostExamples as $example) {
927            $exampleHasHashtags = $example['hashtags'] ?? false;
928            $exampleHasEmojis = $example['emojis'] ?? false;
929            $exampleLength = $example['length'] ?? 'Medium (191 – 300 words)';
930            if ($lengthOfPost->name !== $exampleLength) {
931                continue;
932            }
933
934            if (($useHashtags && $useEmojis && ! $exampleHasHashtags && ! $exampleHasEmojis) || (! $useHashtags && ! $useEmojis && $exampleHasHashtags && $exampleHasEmojis)) {
935                continue;
936            }
937
938            $exampleInput = $example['input'];
939            $exampleOutput = $example['output'];
940            $prompt .= "{Example {$exampleCount}}\n***Input: ***{$exampleInput}\n***Output: ***{$exampleOutput}\n";
941            $exampleCount++;
942        }
943        $prompt .= "</examples>\n";
944
945        $prompt = "{$prompt}\nOutput:\n1. Output format equals plain text.";
946        $prompt = "{$prompt}\n2. The length of this social media post should be: {$lengthOfPost->prompt}";
947        $prompt = "{$prompt}\n3. Output language should be: {$language->prompt}";
948        $prompt = "{$prompt}\n4. Provide the post without additional introductory content. Meticulously follow this rule.";
949
950        if ($dryRun) {
951            return [
952                'prompt' => $prompt,
953                'response' => null,
954                'config' => [
955                    'model' => $aiPrompt->model,
956                    'temperature' => $aiPrompt->temperature,
957                    'max_tokens' => $aiPrompt->tokens,
958                    'top_p' => $aiPrompt->top_p,
959                ],
960            ];
961        }
962
963        $response = $this->generateViaBridge(
964            $prompt,
965            $aiPrompt->tokens,
966            $aiPrompt->temperature,
967            $aiPrompt->model,
968            $aiPrompt->top_p,
969            $aiPrompt->is_grounding,
970            null,
971            0,
972            [
973                'feature' => 'post_generate',
974                'user_id' => $user->id ?? null,
975                'company_id' => $user->company_id ?? null,
976                'prompt_id' => (string) $aiPrompt->_id,
977            ]
978        );
979
980        $cleanResponse = $this->transform($response);
981
982        Log::info('FlyMSG AI: ', [
983            'prompt' => $prompt,
984            'response' => $cleanResponse,
985            'ai_prompt' => $aiPrompt,
986        ]);
987
988        return [
989            'prompt' => $prompt,
990            'response' => $cleanResponse,
991        ];
992    }
993
994    public function watchVideo($youtube_url, $name)
995    {
996        $youtubeId = $this->extractYouTubeId($youtube_url);
997
998        $youtubeVideo = YoutubeVideos::where('video_id', $youtubeId)->first();
999
1000        if (! empty($youtubeVideo)) {
1001            return $youtubeVideo;
1002        }
1003
1004        // set the execution timeout to 5 minutes
1005        ini_set('max_execution_time', '300');
1006
1007        $model = PromptModel::where('is_active', true)->latest()->first();
1008        $setting = PromptSetting::where('prompt_model_id', $model->id)->where('is_active', true)->where('feature', 'flypost')->latest()->first();
1009        $promptTypeM = PromptType::where('prompt_setting_id', $setting->id)->where('is_active', true)->where('feature', 'flypost')->where('name', $name)->first();
1010
1011        $prompt = $promptTypeM->youtube_extract_mission;
1012
1013        $instructionCount = 1;
1014
1015        foreach ($promptTypeM->youtube_extract_instructions as $instruction) {
1016            $prompt = "{$prompt}\n{$instructionCount}{$instruction}";
1017            $instructionCount++;
1018        }
1019
1020        $prompt = "{$prompt}\n{$promptTypeM->youtube_extract_output}";
1021
1022        $response = $this->generateViaBridge(
1023            $prompt,
1024            $setting->output_token_limit,
1025            $promptTypeM->youtube_extract_temperature,
1026            $model->name,
1027            $promptTypeM->youtube_extract_top_p,
1028            $setting->is_grounding,
1029            $youtube_url,
1030            0,
1031            ['feature' => 'youtube_watch']
1032        );
1033
1034        Log::info('Youtube Summarization AI: ', [
1035            'prompt' => $prompt,
1036            'max_tokens' => $setting->output_token_limit,
1037            'temperature' => $promptTypeM->youtube_extract_temperature,
1038            'model' => $model->name,
1039            'top_p' => $promptTypeM->youtube_extract_top_p,
1040            'response' => $response,
1041        ]);
1042
1043        return YoutubeVideos::create([
1044            'video_id' => $youtubeId,
1045            'prompt' => $prompt,
1046            'result' => trim(str_replace('OUTPUT:', ' ', $response)),
1047        ]);
1048    }
1049
1050    private function extractYouTubeId($url)
1051    {
1052        $pattern = '%(?:youtube(?:-nocookie)?\.com/(?:[^/]+/.+/|(?:v|e(?:mbed)?)/|.*[?&]v=)|youtu\.be/)([A-Za-z0-9_-]{11})%i';
1053
1054        if (preg_match($pattern, $url, $matches)) {
1055            return $matches[1];
1056        }
1057
1058        return $url;
1059    }
1060
1061    private function readBlogUrl($url)
1062    {
1063        $client = new Client;
1064        $response = $client->request('GET', $url);
1065        $html = $response->getBody()->getContents();
1066
1067        $crawler = new Crawler($html);
1068        $parsedText = $this->parseHtmlContent($crawler->filter('body')->text());
1069
1070        Log::info('Scrapping URL: ', [
1071            'url' => $url,
1072            'text' => $parsedText,
1073        ]);
1074
1075        return $parsedText;
1076    }
1077
1078    private function parseHtmlContent($html_content)
1079    {
1080        $soup = new Crawler($html_content);
1081
1082        $soup->filter('script, style')->each(function ($node) {
1083            $node->getNode(0)->parentNode->removeChild($node->getNode(0));
1084        });
1085
1086        $texts = $soup->filter('p')->each(function ($node) {
1087            return $node->text();
1088        });
1089
1090        $cleaned_text = implode("\n", array_filter($texts));
1091
1092        $max_size = 60000;
1093        if (strlen($cleaned_text) > $max_size) {
1094            $cleaned_text = substr($cleaned_text, 0, $max_size);
1095        }
1096
1097        return $cleaned_text;
1098    }
1099
1100    /**
1101     * Generate text by routing the prompt through {@see NodeJsAIBridgeService}.
1102     *
1103     * Despite the FlyMsgAI namespace, this method does NOT call Gemini from
1104     * PHP — the provider call happens inside the Node.js sidecar. This PHP
1105     * side only assembles the payload and forwards to the bridge.
1106     *
1107     * Output language is controlled by {@see LANGUAGE_MIRROR_DIRECTIVE}
1108     * appended to the prompt by user-facing callers. We no longer detect
1109     * the input language or post-translate the response.
1110     *
1111     * @param  string  $prompt  The full assembled prompt text
1112     * @param  int  $max_tokens  Maximum output tokens
1113     * @param  float  $temperature  Generation temperature
1114     * @param  string  $model  AI model identifier
1115     * @param  float  $topP  Top-P sampling value
1116     * @param  bool  $enableGoogleSearch  Whether to enable Google Search grounding
1117     * @param  string|null  $youtubeUrl  Optional YouTube video URI
1118     * @param  int  $thinkingBudget  Thinking token budget (0 = disabled)
1119     * @param  array  $metadata  Logging metadata (feature, user_id, company_id, context)
1120     * @return string The AI-generated text (in the same language as the input, when LANGUAGE_MIRROR_DIRECTIVE is appended)
1121     *
1122     * @throws Exception
1123     */
1124    protected function generateViaBridge(
1125        $prompt,
1126        $max_tokens,
1127        $temperature,
1128        $model = 'gemini-3.1-flash-lite-preview',
1129        $topP = 1.0,
1130        $enableGoogleSearch = false,
1131        $youtubeUrl = null,
1132        $thinkingBudget = 0,
1133        array $metadata = [],
1134    ): string {
1135        // Normalise model name: strip legacy ':streamGenerateContent' suffix
1136        $normalisedModel = str_replace(':streamGenerateContent', '', $model);
1137
1138        $payload = [
1139            'provider' => 'vertex',
1140            'model' => $normalisedModel,
1141            'prompt' => $prompt,
1142            'config' => [
1143                'maxOutputTokens' => (int) $max_tokens,
1144                'temperature' => (float) $temperature,
1145                'topP' => (float) $topP,
1146                'thinkingBudget' => (int) $thinkingBudget,
1147                'enableGoogleSearch' => (bool) $enableGoogleSearch,
1148            ],
1149            'youtubeUrl' => $youtubeUrl ?: null,
1150        ];
1151
1152        return $this->bridge->generate($payload, $metadata);
1153    }
1154
1155    public function transform(string $data): string
1156    {
1157        $pipelines = [
1158            RemoveOutputPrefix::class,
1159            RemoveMarkdownBold::class,
1160            RemoveMarkdownHeaders::class,
1161            RemoveMarkdownUnderlines::class,
1162            RemoveMarkdownLinks::class,
1163            RemoveHorizontalRules::class,
1164            Remove8Spaces::class,
1165            RemoveBackSlash::class,
1166        ];
1167
1168        return app(Pipeline::class)
1169            ->send($data)
1170            ->through($pipelines)
1171            ->thenReturn();
1172    }
1173
1174    private function getTrackingResponse($userId, $name, $feature, $uniqueId)
1175    {
1176        return FlyMsgAITracking::where('user_id', $userId)
1177            ->where('feature', $feature)
1178            ->where('button', $name)
1179            ->where('unique_id', $uniqueId)
1180            ->latest()
1181            ->take(5)
1182            ->pluck('prompt_response')
1183            ->toArray();
1184    }
1185}