Merge pull request #27 from trypostit/chore/remove-unused-platform-rules

chore: remove unused PlatformRules registry and rule classes
This commit is contained in:
Paulo Castellano 2026-05-11 21:43:32 -03:00 committed by GitHub
commit f8f5cba3d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 25 additions and 670 deletions

View file

@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Ai\Middleware;
use Closure;
use Illuminate\Support\Facades\Log;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Prompts\AgentPrompt;
use Laravel\Ai\Responses\AgentResponse;
use Laravel\Ai\Responses\StructuredAgentResponse;
class DebugGeminiRequest
{
public function handle(AgentPrompt $prompt, Closure $next)
{
Log::debug('Agent prompt debug', [
'agent' => get_class($prompt->agent),
'model' => $prompt->model,
'has_structured_output' => $prompt->agent instanceof HasStructuredOutput,
'has_tools' => $prompt->agent instanceof HasTools,
'tools_count' => $prompt->agent instanceof HasTools ? count(iterator_to_array($prompt->agent->tools())) : 0,
]);
return $next($prompt)->then(function (AgentResponse $response) {
Log::debug('Agent response debug', [
'response_class' => get_class($response),
'text_length' => strlen($response->text ?? ''),
'text_preview' => substr($response->text ?? '', 0, 200),
'is_structured' => $response instanceof StructuredAgentResponse,
'structured_data' => $response instanceof StructuredAgentResponse ? $response->structured : null,
]);
});
}
}

View file

@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Ai\PlatformRules;
use App\Enums\SocialAccount\Platform;
class BlueskyRules implements Contract
{
public function platform(): Platform
{
return Platform::Bluesky;
}
public function specs(): array
{
return [
'max_content_length' => 300,
'max_images' => 4,
'max_videos' => 1,
'text_only_allowed' => true,
];
}
public function summary(): string
{
return 'Bluesky: 300 chars max. Up to 4 images or 1 video. Similar to X but more concise.';
}
}

View file

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Ai\PlatformRules;
use App\Enums\SocialAccount\Platform;
interface Contract
{
public function platform(): Platform;
/**
* @return array<string, mixed>
*/
public function specs(): array;
public function summary(): string;
}

View file

@ -1,39 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Ai\PlatformRules;
use App\Enums\SocialAccount\Platform;
class FacebookRules implements Contract
{
public function platform(): Platform
{
return Platform::Facebook;
}
public function specs(): array
{
return [
'max_content_length' => 10000,
'max_images' => 10,
'text_only_allowed' => true,
'formats' => [
'post' => ['max_media' => 10, 'text_only_allowed' => true],
'reel' => ['aspect_ratio' => '9:16', 'video_only' => true, 'max_duration_seconds' => 90],
'story' => ['aspect_ratio' => '9:16', 'ephemeral' => true],
],
];
}
public function summary(): string
{
return <<<'TXT'
Facebook: caption up to 10k chars but short posts perform better. Text-only allowed.
- Post: flexible aspect ratio, up to 10 images/videos.
- Reel: vertical 9:16 video only, up to 90s.
- Story: vertical 9:16, single image/video, ephemeral.
TXT;
}
}

View file

@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Ai\PlatformRules;
use App\Enums\SocialAccount\Platform;
class InstagramRules implements Contract
{
public function platform(): Platform
{
return Platform::Instagram;
}
public function specs(): array
{
return [
'max_content_length' => 2200,
'max_images' => 10,
'max_videos' => 10,
'text_only_allowed' => false,
'formats' => [
'feed' => ['aspect_ratio' => '1:1 or 4:5', 'media_limit' => 10],
'carousel' => ['aspect_ratio' => '1:1 or 4:5', 'media_limit' => 10],
'reel' => ['aspect_ratio' => '9:16', 'video_only' => true, 'max_duration_seconds' => 90],
'story' => ['aspect_ratio' => '9:16', 'media_limit' => 1, 'ephemeral' => true],
],
];
}
public function summary(): string
{
return <<<'TXT'
Instagram: caption max 2200 chars.
- Feed: square (1:1) or portrait (4:5). Up to 10 images/videos.
- Carousel: 2-10 slides, all same aspect ratio. First slide is the hook.
- Reel: vertical 9:16 video only, up to 90s (30-60s performs best). Hook in first 1-3s.
- Story: vertical 9:16, single image/video, ephemeral (24h).
TXT;
}
}

View file

@ -1,39 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Ai\PlatformRules;
use App\Enums\SocialAccount\Platform;
class LinkedInRules implements Contract
{
public function platform(): Platform
{
return Platform::LinkedIn;
}
public function specs(): array
{
return [
'max_content_length' => 3000,
'max_images' => 1,
'max_videos' => 1,
'text_only_allowed' => true,
'formats' => [
'post' => ['max_media' => 1],
'carousel' => ['max_slides' => 20, 'optimal_slides' => '8-12', 'images_only' => true, 'document_based' => true],
],
];
}
public function summary(): string
{
return <<<'TXT'
LinkedIn: 3000 chars max. First 2-3 lines visible before "see more" — make them compelling.
Professional tone. Educational/how-to content performs well.
- Post: 1 image or 1 video.
- Carousel: PDF-based document, up to 20 slides (8-12 optimal), images only.
TXT;
}
}

View file

@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Ai\PlatformRules;
use App\Enums\SocialAccount\Platform;
class MastodonRules implements Contract
{
public function platform(): Platform
{
return Platform::Mastodon;
}
public function specs(): array
{
return [
'max_content_length' => 500,
'max_images' => 4,
'max_videos' => 1,
'text_only_allowed' => true,
];
}
public function summary(): string
{
return 'Mastodon: 500 chars max. Up to 4 images or 1 video. Community-focused, avoid commercial tone.';
}
}

View file

@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Ai\PlatformRules;
use App\Enums\SocialAccount\Platform;
class PinterestRules implements Contract
{
public function platform(): Platform
{
return Platform::Pinterest;
}
public function specs(): array
{
return [
'max_content_length' => 800,
'max_images' => 5,
'max_videos' => 1,
'text_only_allowed' => false,
'formats' => [
'pin' => ['aspect_ratio' => '2:3', 'media_limit' => 1, 'images_only' => true],
'video_pin' => ['aspect_ratio' => '9:16 or 2:3', 'video_only' => true, 'min_duration_seconds' => 4, 'max_duration_seconds' => 900],
'carousel' => ['aspect_ratio' => '2:3', 'max_slides' => 5, 'images_only' => true],
],
];
}
public function summary(): string
{
return <<<'TXT'
Pinterest: title max 100 chars, description max 500 chars. Text overlay on images helps discovery.
- Pin: portrait 2:3 (1000x1500px), single image.
- Video Pin: vertical 9:16 or portrait 2:3, 4s to 15 minutes.
- Carousel: portrait 2:3, up to 5 images.
TXT;
}
}

View file

@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Ai\PlatformRules;
use App\Enums\SocialAccount\Platform;
class Registry
{
/**
* @var array<string, class-string<Contract>>
*/
private static array $map = [];
public static function for(Platform $platform): ?Contract
{
$class = self::$map[$platform->value] ?? null;
return $class ? new $class : null;
}
/**
* @param array<int, Platform> $platforms
* @return array<int, Contract>
*/
public static function forMany(array $platforms): array
{
$rules = [];
foreach ($platforms as $platform) {
$rule = self::for($platform);
if ($rule) {
$rules[] = $rule;
}
}
return $rules;
}
/**
* @param class-string<Contract> $ruleClass
*/
public static function register(Platform $platform, string $ruleClass): void
{
self::$map[$platform->value] = $ruleClass;
}
public static function clear(): void
{
self::$map = [];
}
}

View file

@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Ai\PlatformRules;
use App\Enums\SocialAccount\Platform;
class ThreadsRules implements Contract
{
public function platform(): Platform
{
return Platform::Threads;
}
public function specs(): array
{
return [
'max_content_length' => 500,
'max_images' => 10,
'max_videos' => 10,
'text_only_allowed' => true,
];
}
public function summary(): string
{
return 'Threads: 500 chars max. Up to 10 images/videos. Conversational tone. Ask questions to drive replies.';
}
}

View file

@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Ai\PlatformRules;
use App\Enums\SocialAccount\Platform;
class TikTokRules implements Contract
{
public function platform(): Platform
{
return Platform::TikTok;
}
public function specs(): array
{
return [
'max_content_length' => 2200,
'max_videos' => 1,
'text_only_allowed' => false,
'aspect_ratio' => '9:16',
'video_only' => true,
];
}
public function summary(): string
{
return 'TikTok: vertical 9:16 video only. 15-60s performs best. Hook in first 1-2s. Trendy, authentic style. Caption max 2200 chars.';
}
}

View file

@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Ai\PlatformRules;
use App\Enums\SocialAccount\Platform;
class XRules implements Contract
{
public function platform(): Platform
{
return Platform::X;
}
public function specs(): array
{
return [
'max_content_length' => 280,
'max_images' => 4,
'max_videos' => 1,
'text_only_allowed' => true,
'best_aspect_ratio' => '16:9',
];
}
public function summary(): string
{
return 'X (Twitter): 280 chars max. Up to 4 images OR 1 video. 16:9 landscape performs best. Concise, punchy copy.';
}
}

View file

@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Ai\PlatformRules;
use App\Enums\SocialAccount\Platform;
class YouTubeRules implements Contract
{
public function platform(): Platform
{
return Platform::YouTube;
}
public function specs(): array
{
return [
'max_content_length' => 5000,
'max_videos' => 1,
'text_only_allowed' => false,
'aspect_ratio' => '9:16',
'video_only' => true,
'max_duration_seconds' => 60,
'requires_title' => true,
];
}
public function summary(): string
{
return 'YouTube Shorts: vertical 9:16 video only, up to 60s. Hook immediately. Loop-friendly content helps. Description max 5000 chars. Title required.';
}
}

View file

@ -1,46 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Ai\Providers;
use Laravel\Ai\Providers\GeminiProvider;
/**
* Extends the base Gemini provider with social-media-friendly aspect ratios.
*
* The stock provider only exposes 1:1, 2:3, 3:2. Social posts need 9:16
* (Reels/Stories/TikTok/Shorts) and 16:9 (X/LinkedIn/Facebook video).
* Gemini's native API supports both, but the SDK's match statement silently
* drops anything outside its three documented ratios. We re-expose them here.
*/
class ExtendedGeminiProvider extends GeminiProvider
{
/**
* @param 'low'|'medium'|'high'|'1K'|'2K'|'4K'|null $quality
*/
public function defaultImageOptions(?string $size = null, $quality = null): array
{
return array_filter([
'image_size' => match ($quality) {
'low', '1K' => '1K',
'medium', '2K' => '2K',
'high', '4K' => '4K',
default => '1K',
},
'aspect_ratio' => match ($size) {
'1:1' => '1:1',
'2:3' => '2:3',
'3:2' => '3:2',
'9:16' => '9:16',
'16:9' => '16:9',
'4:3' => '4:3',
'3:4' => '3:4',
'4:5' => '4:5',
'5:4' => '5:4',
'21:9' => '21:9',
default => null,
},
]);
}
}

View file

@ -4,19 +4,6 @@
namespace App\Providers;
use App\Ai\PlatformRules\BlueskyRules;
use App\Ai\PlatformRules\FacebookRules;
use App\Ai\PlatformRules\InstagramRules;
use App\Ai\PlatformRules\LinkedInRules;
use App\Ai\PlatformRules\MastodonRules;
use App\Ai\PlatformRules\PinterestRules;
use App\Ai\PlatformRules\Registry;
use App\Ai\PlatformRules\ThreadsRules;
use App\Ai\PlatformRules\TikTokRules;
use App\Ai\PlatformRules\XRules;
use App\Ai\PlatformRules\YouTubeRules;
use App\Ai\Providers\ExtendedGeminiProvider;
use App\Enums\SocialAccount\Platform;
use App\Listeners\StripeEventListener;
use App\Models\AccessToken;
use App\Models\Account;
@ -45,7 +32,6 @@
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Auth\Notifications\VerifyEmail;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Http\Request;
@ -57,8 +43,6 @@
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
use Laravel\Ai\Ai;
use Laravel\Ai\Gateway\Gemini\GeminiGateway;
use Laravel\Cashier\Cashier;
use Laravel\Cashier\Events\WebhookReceived;
use Laravel\Nightwatch\Facades\Nightwatch;
@ -96,8 +80,6 @@ public function boot(): void
{
$this->configureDefaults();
$this->configureMorphMap();
$this->configureAi();
$this->configurePlatformRules();
$this->configurePostHog();
$this->configureRateLimiting();
$this->configureSocialite();
@ -125,42 +107,6 @@ protected function configurePassport(): void
Passport::authorizationView('mcp.authorize');
}
protected function configureAi(): void
{
Ai::extend('gemini', function ($app, array $config) {
return new ExtendedGeminiProvider(
new GeminiGateway($app['events']),
$config,
$app->make(Dispatcher::class),
);
});
}
protected function configurePlatformRules(): void
{
$map = [
Platform::Instagram->value => InstagramRules::class,
Platform::InstagramFacebook->value => InstagramRules::class,
Platform::Facebook->value => FacebookRules::class,
Platform::X->value => XRules::class,
Platform::TikTok->value => TikTokRules::class,
Platform::YouTube->value => YouTubeRules::class,
Platform::LinkedIn->value => LinkedInRules::class,
Platform::LinkedInPage->value => LinkedInRules::class,
Platform::Threads->value => ThreadsRules::class,
Platform::Pinterest->value => PinterestRules::class,
Platform::Bluesky->value => BlueskyRules::class,
Platform::Mastodon->value => MastodonRules::class,
];
foreach ($map as $value => $class) {
Registry::register(
Platform::from($value),
$class,
);
}
}
protected function configureMorphMap(): void
{
Relation::enforceMorphMap([

View file

@ -45,8 +45,24 @@ const startGeneration = async () => {
}
};
// The streamer agent shares the structured-output prompt template with
// PostContentGenerator, so what comes through the stream is a JSON object
// like {"content": "...", "image_title": "...", ...}. We only want the
// content field — and only once the JSON is complete (mid-stream the parse
// fails and we show nothing, avoiding the typewriter-of-JSON effect).
const previewText = computed(() => {
if (! text.value) return '';
try {
const parsed = JSON.parse(text.value);
if (parsed && typeof parsed.content === 'string') return parsed.content;
} catch {
// Mid-stream the JSON is incomplete — leave preview empty.
}
return '';
});
const apply = () => {
emit('apply', text.value);
emit('apply', previewText.value);
open.value = false;
};
@ -56,7 +72,7 @@ const retry = () => {
startGeneration();
};
const canApply = computed(() => status.value === 'completed' && text.value.trim().length > 0);
const canApply = computed(() => status.value === 'completed' && previewText.value.trim().length > 0);
const canRetry = computed(() => status.value === 'completed' || status.value === 'failed');
watch(open, () => {
@ -88,15 +104,16 @@ watch(open, () => {
<div v-if="status !== 'idle'" class="grid gap-2">
<Label class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.ai.generate.preview_label') }}</Label>
<div class="min-h-[120px] whitespace-pre-wrap rounded-lg border-2 border-foreground bg-card px-3 py-2 text-sm font-medium text-foreground shadow-2xs">{{ text || '...' }}</div>
<div class="min-h-[120px] whitespace-pre-wrap rounded-lg border-2 border-foreground bg-card px-3 py-2 text-sm font-medium text-foreground shadow-2xs">{{ previewText || '...' }}</div>
<p v-if="status === 'failed'" class="text-xs font-semibold text-rose-700">{{ errorMessage }}</p>
</div>
</div>
<DialogFooter>
<Button
v-if="status === 'idle'"
:disabled="! prompt.trim() || dispatching"
v-if="status === 'idle' || status === 'streaming'"
:loading="dispatching || status === 'streaming'"
:disabled="! prompt.trim()"
@click="startGeneration"
>
{{ $t('posts.ai.generate.start') }}

View file

@ -42,13 +42,13 @@ export const useAiStream = () => {
subscribedName = channelName;
echo().private(channelName)
.listen('.TextDelta', (e: TextDeltaEvent) => {
.listen('.text_delta', (e: TextDeltaEvent) => {
text.value += e.delta ?? '';
})
.listen('.StreamEnd', () => {
.listen('.stream_end', () => {
status.value = 'completed';
})
.listen('.Error', (e: ErrorEvent) => {
.listen('.error', (e: ErrorEvent) => {
status.value = 'failed';
errorMessage.value = e?.message ?? 'AI generation failed';
});

View file

@ -99,7 +99,6 @@ const post = computed(() => props.post);
// Terminal states the user cannot recover from — delete, navigate, but never edit.
const isReadOnly = computed(() => ['publishing', 'published', 'partially_published'].includes(post.value.status));
const isPublishing = computed(() => post.value.status === 'publishing');
const isPublished = computed(() => ['published', 'partially_published'].includes(post.value.status));
const isScheduled = computed(() => post.value.status === 'scheduled');
// Locked states — terminal + scheduled. Field edits and auto-save suppressed;
// user must unschedule to re-enter draft and edit.

View file

@ -1,61 +0,0 @@
<?php
declare(strict_types=1);
use App\Ai\PlatformRules\Contract;
use App\Ai\PlatformRules\Registry;
use App\Ai\PlatformRules\XRules;
use App\Enums\SocialAccount\Platform;
test('registry returns rule instance for every supported platform', function (Platform $platform) {
$rules = Registry::for($platform);
expect($rules)
->toBeInstanceOf(Contract::class)
->and(strlen($rules->summary()))->toBeGreaterThan(30)
->and($rules->specs())->toBeArray();
})->with([
[Platform::Instagram],
[Platform::InstagramFacebook],
[Platform::Facebook],
[Platform::X],
[Platform::TikTok],
[Platform::YouTube],
[Platform::LinkedIn],
[Platform::LinkedInPage],
[Platform::Threads],
[Platform::Pinterest],
[Platform::Bluesky],
[Platform::Mastodon],
]);
test('forMany returns rules for the given platforms in order', function () {
$rules = Registry::forMany([Platform::X, Platform::Instagram, Platform::TikTok]);
expect($rules)->toHaveCount(3);
expect($rules[0]->platform())->toBe(Platform::X);
expect($rules[1]->platform())->toBe(Platform::Instagram);
expect($rules[2]->platform())->toBe(Platform::TikTok);
});
test('forMany skips platforms with no registered rules', function () {
Registry::clear();
Registry::register(Platform::X, XRules::class);
$rules = Registry::forMany([Platform::X, Platform::Instagram]);
expect($rules)->toHaveCount(1);
expect($rules[0]->platform())->toBe(Platform::X);
});
test('X rules summary mentions 280 char limit', function () {
expect(Registry::for(Platform::X)->summary())->toContain('280');
});
test('Instagram rules summary mentions 9:16 for Reel', function () {
expect(Registry::for(Platform::Instagram)->summary())->toContain('9:16');
});
test('TikTok rules specs indicate video only', function () {
expect(Registry::for(Platform::TikTok)->specs()['video_only'])->toBeTrue();
});

View file

@ -1,46 +0,0 @@
<?php
declare(strict_types=1);
use App\Ai\Providers\ExtendedGeminiProvider;
use Illuminate\Contracts\Events\Dispatcher;
use Laravel\Ai\Gateway\Gemini\GeminiGateway;
beforeEach(function () {
$this->provider = new ExtendedGeminiProvider(
new GeminiGateway(app(Dispatcher::class)),
['name' => 'gemini', 'driver' => 'gemini'],
app(Dispatcher::class),
);
});
test('supports social media vertical aspect ratio 9:16', function () {
$options = $this->provider->defaultImageOptions('9:16');
expect($options['aspect_ratio'])->toBe('9:16');
});
test('supports social media horizontal aspect ratio 16:9', function () {
$options = $this->provider->defaultImageOptions('16:9');
expect($options['aspect_ratio'])->toBe('16:9');
});
test('still supports the three original ratios from the base provider', function () {
expect($this->provider->defaultImageOptions('1:1')['aspect_ratio'])->toBe('1:1');
expect($this->provider->defaultImageOptions('2:3')['aspect_ratio'])->toBe('2:3');
expect($this->provider->defaultImageOptions('3:2')['aspect_ratio'])->toBe('3:2');
});
test('supports additional Instagram-friendly ratios', function () {
expect($this->provider->defaultImageOptions('4:5')['aspect_ratio'])->toBe('4:5');
expect($this->provider->defaultImageOptions('5:4')['aspect_ratio'])->toBe('5:4');
expect($this->provider->defaultImageOptions('4:3')['aspect_ratio'])->toBe('4:3');
expect($this->provider->defaultImageOptions('3:4')['aspect_ratio'])->toBe('3:4');
});
test('drops unsupported aspect ratios silently', function () {
$options = $this->provider->defaultImageOptions('gibberish');
expect($options)->not->toHaveKey('aspect_ratio');
});