feat(ai-templates): task 3 - template-driven generator + job
- Add AiTemplateRegistry (resolves, keys, default) - Add template/templateContext params to PostContentGenerator; schema() delegates to template when both are set; instructions() uses template.promptView() - Refactor StreamPostCreation.handle() to resolve template via registry, build TemplateContext, delegate assemble() to template; add createPostFromGenerated() - Remove handleSingle(), handleCarousel(), resolvedContentType() (no other callers) - PostAiCreateController passes template param from request (default image_card) - All existing AI tests green; registry test added
This commit is contained in:
parent
97798c010a
commit
9dba90129f
5 changed files with 95 additions and 90 deletions
|
|
@ -5,6 +5,8 @@
|
|||
namespace App\Ai\Agents;
|
||||
|
||||
use App\Ai\Agents\Concerns\ResolvesPlatformCopyBudget;
|
||||
use App\Ai\Templates\AiContentTemplate;
|
||||
use App\Ai\Templates\TemplateContext;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Ai\TemplateContextResolver;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
|
|
@ -27,6 +29,8 @@ public function __construct(
|
|||
public int $slideCount = 1,
|
||||
public ?string $platformContext = null,
|
||||
public bool $applyBrandVoice = true,
|
||||
public ?AiContentTemplate $template = null,
|
||||
public ?TemplateContext $templateContext = null,
|
||||
) {}
|
||||
|
||||
public function instructions(): string
|
||||
|
|
@ -45,12 +49,11 @@ public function instructions(): string
|
|||
->all();
|
||||
}
|
||||
|
||||
// Two budgets: the platform's HARD cap (must never exceed — would break
|
||||
// publishing) and the recommended SWEET SPOT length (what well-performing
|
||||
// posts actually look like, way below the hard cap on most platforms).
|
||||
$budget = $this->platformCopyBudget($this->platformContext);
|
||||
|
||||
return view('prompts.post_content.generator', [
|
||||
$view = $this->template?->promptView() ?? 'prompts.post_content.generator';
|
||||
|
||||
return view($view, [
|
||||
'brand_name' => $this->workspace->name ?? '',
|
||||
'brand_description' => $this->applyBrandVoice ? ($this->workspace->brand_description ?? '') : '',
|
||||
'brand_voice_traits' => $this->applyBrandVoice ? ($this->workspace->brand_voice_traits ?? []) : [],
|
||||
|
|
@ -67,6 +70,10 @@ public function instructions(): string
|
|||
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
if ($this->template !== null && $this->templateContext !== null) {
|
||||
return $this->template->schema($schema, $this->templateContext);
|
||||
}
|
||||
|
||||
if ($this->format === 'carousel') {
|
||||
return [
|
||||
'caption' => $schema->string()->description('The Instagram caption for the carousel post.')->required(),
|
||||
|
|
@ -77,7 +84,7 @@ public function schema(JsonSchema $schema): array
|
|||
->description('The role of this slide in the carousel arc. First slide is `hook` (specific real problem). Last slide is `cta` (one specific next action). Middle slides are `development` (unfold the idea) or `proof` (concrete result, before/after, behind-the-scenes, real learning). For 4+ slides, at least one middle slide must be `proof`.')
|
||||
->required(),
|
||||
'title' => $s->string()->description('Headline of the slide. Short, impactful.')->required(),
|
||||
'body' => $s->string()->description('Supporting text below the headline. 1-3 sentences.')->required(),
|
||||
'body' => $s->string()->description('Supporting body below the headline. 1-3 sentences.')->required(),
|
||||
'image_keywords' => $s->array()->items($schema->string())->description('2-4 search keywords for Unsplash.')->required(),
|
||||
]))
|
||||
->min($this->slideCount)
|
||||
|
|
|
|||
44
app/Ai/Templates/AiTemplateRegistry.php
Normal file
44
app/Ai/Templates/AiTemplateRegistry.php
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Ai\Templates;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
class AiTemplateRegistry
|
||||
{
|
||||
/** @var array<int, class-string<AiContentTemplate>> */
|
||||
private const TEMPLATES = [
|
||||
ImageCardTemplate::class,
|
||||
CarouselTemplate::class,
|
||||
];
|
||||
|
||||
/** @return array<int, AiContentTemplate> */
|
||||
public function all(): array
|
||||
{
|
||||
return array_map(fn (string $class) => app($class), self::TEMPLATES);
|
||||
}
|
||||
|
||||
/** @return array<int, string> */
|
||||
public function keys(): array
|
||||
{
|
||||
return array_map(fn (AiContentTemplate $t) => $t->key(), $this->all());
|
||||
}
|
||||
|
||||
public function find(string $key): AiContentTemplate
|
||||
{
|
||||
foreach ($this->all() as $template) {
|
||||
if ($template->key() === $key) {
|
||||
return $template;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException("Unknown AI content template: {$key}");
|
||||
}
|
||||
|
||||
public function default(): AiContentTemplate
|
||||
{
|
||||
return app(ImageCardTemplate::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -51,6 +51,7 @@ public function start(StartPostCreationRequest $request): JsonResponse
|
|||
imageCount: (int) $request->input('image_count', 0),
|
||||
prompt: $request->string('prompt')->toString(),
|
||||
date: $request->input('date'),
|
||||
template: $request->input('template', 'image_card'),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@
|
|||
use App\Actions\Post\CreatePost;
|
||||
use App\Ai\Agents\PostContentGenerator;
|
||||
use App\Ai\Agents\PostContentHumanizer;
|
||||
use App\Ai\Templates\AiTemplateRegistry;
|
||||
use App\Ai\Templates\GeneratedPost;
|
||||
use App\Ai\Templates\TemplateContext;
|
||||
use App\Enums\Notification\Channel as NotificationChannel;
|
||||
use App\Enums\Notification\Type as NotificationType;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
|
|
@ -17,7 +20,6 @@
|
|||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Ai\RecordAiUsage;
|
||||
use App\Services\Image\PostImagePipeline;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
|
|
@ -38,6 +40,7 @@ public function __construct(
|
|||
public int $imageCount,
|
||||
public string $prompt,
|
||||
public ?string $date = null,
|
||||
public string $template = 'image_card',
|
||||
) {
|
||||
$this->onQueue('ai');
|
||||
}
|
||||
|
|
@ -47,16 +50,19 @@ public function handle(): void
|
|||
$workspace = Workspace::findOrFail($this->workspaceId);
|
||||
$socialAccount = $this->socialAccountId ? SocialAccount::find($this->socialAccountId) : null;
|
||||
|
||||
$isCarousel = $this->format === ContentType::CAROUSEL_FORMAT;
|
||||
$agentFormat = $isCarousel ? 'carousel' : 'single';
|
||||
$template = app(AiTemplateRegistry::class)->find($this->template);
|
||||
$context = new TemplateContext($workspace, $socialAccount, $this->format, $this->imageCount);
|
||||
|
||||
$isCarousel = $template->generatorFormat() === 'carousel';
|
||||
$slideCount = $isCarousel && $this->imageCount > 0 ? $this->imageCount : 1;
|
||||
|
||||
$agent = new PostContentGenerator(
|
||||
workspace: $workspace,
|
||||
format: $agentFormat,
|
||||
format: $template->generatorFormat(),
|
||||
slideCount: $slideCount,
|
||||
platformContext: $this->format,
|
||||
template: $template,
|
||||
templateContext: $context,
|
||||
);
|
||||
|
||||
try {
|
||||
|
|
@ -72,18 +78,13 @@ public function handle(): void
|
|||
metadata: ['agent' => 'post_generator', 'format' => $this->format],
|
||||
);
|
||||
|
||||
// StructuredAgentResponse implements ArrayAccess: access via $response['key']
|
||||
$structured = $response->structured ?? [];
|
||||
|
||||
// Second pass: rewrite human-readable text to remove AI-tells. Image
|
||||
// keywords pass through untouched (they need to stay in English).
|
||||
$structured = $this->humanize($workspace, $structured, $isCarousel ? 'carousel' : 'single');
|
||||
$structured = $this->humanize($workspace, $structured, $template->generatorFormat());
|
||||
|
||||
if ($isCarousel) {
|
||||
$this->handleCarousel($workspace, $socialAccount, $structured);
|
||||
} else {
|
||||
$this->handleSingle($workspace, $socialAccount, $structured);
|
||||
}
|
||||
$generated = $template->assemble($structured, $context);
|
||||
$post = $this->createPostFromGenerated($workspace, $generated, $socialAccount);
|
||||
$this->notifyReady($workspace, $post);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('StreamPostCreation failed', [
|
||||
'creation_id' => $this->creationId,
|
||||
|
|
@ -96,19 +97,6 @@ public function handle(): void
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored content type for the requested generation format. The carousel
|
||||
* generation format is persisted as an Instagram feed post.
|
||||
*/
|
||||
private function resolvedContentType(): ?ContentType
|
||||
{
|
||||
if ($this->format === ContentType::CAROUSEL_FORMAT) {
|
||||
return ContentType::InstagramFeed;
|
||||
}
|
||||
|
||||
return ContentType::tryFrom($this->format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the structured generator output through the humanizer pass and merge
|
||||
* the humanized text fields back over the original structure (preserving
|
||||
|
|
@ -183,80 +171,28 @@ private function humanize(Workspace $workspace, array $structured, string $forma
|
|||
return $structured;
|
||||
}
|
||||
|
||||
private function handleCarousel(Workspace $workspace, ?SocialAccount $socialAccount, array $structured): void
|
||||
{
|
||||
$caption = (string) data_get($structured, 'caption', '');
|
||||
|
||||
$media = [];
|
||||
|
||||
if ($socialAccount) {
|
||||
$media = app(PostImagePipeline::class)->forCarousel(
|
||||
workspace: $workspace,
|
||||
account: $socialAccount,
|
||||
structured: $structured,
|
||||
contentType: $this->resolvedContentType(),
|
||||
);
|
||||
}
|
||||
|
||||
$post = $this->createPost($workspace, $caption, $media, $socialAccount);
|
||||
|
||||
$this->notifyReady($workspace, $post);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $structured
|
||||
*/
|
||||
private function handleSingle(Workspace $workspace, ?SocialAccount $socialAccount, array $structured): void
|
||||
{
|
||||
$contentType = ContentType::tryFrom($this->format);
|
||||
$supportsCaption = $contentType?->supportsCaption() ?? true;
|
||||
|
||||
$rawContent = (string) data_get($structured, 'content', data_get($structured, 'text', ''));
|
||||
|
||||
$media = [];
|
||||
|
||||
if ($this->imageCount > 0 && $socialAccount) {
|
||||
$media = app(PostImagePipeline::class)->forSingle(
|
||||
workspace: $workspace,
|
||||
account: $socialAccount,
|
||||
structured: $structured,
|
||||
contentType: $this->resolvedContentType(),
|
||||
);
|
||||
}
|
||||
|
||||
$caption = $supportsCaption ? $rawContent : '';
|
||||
$post = $this->createPost($workspace, $caption, $media, $socialAccount);
|
||||
|
||||
$this->notifyReady($workspace, $post);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $media
|
||||
*/
|
||||
private function createPost(Workspace $workspace, string $content, array $media, ?SocialAccount $socialAccount): Post
|
||||
private function createPostFromGenerated(Workspace $workspace, GeneratedPost $generated, ?SocialAccount $socialAccount): Post
|
||||
{
|
||||
$user = User::findOrFail($this->userId);
|
||||
|
||||
$post = CreatePost::execute($workspace, $user, [
|
||||
'content' => $content,
|
||||
'media' => $media,
|
||||
'content' => $generated->content,
|
||||
'media' => $generated->media,
|
||||
'date' => $this->date,
|
||||
]);
|
||||
|
||||
$contentType = $this->resolvedContentType();
|
||||
|
||||
if ($contentType && $socialAccount) {
|
||||
$aspectRatio = $this->aspectRatioFor($contentType);
|
||||
if ($generated->contentType && $socialAccount) {
|
||||
$aspectRatio = $this->aspectRatioFor($generated->contentType);
|
||||
|
||||
$post->postPlatforms()
|
||||
->where('social_account_id', $socialAccount->id)
|
||||
->each(function ($platform) use ($aspectRatio, $contentType): void {
|
||||
->each(function ($platform) use ($aspectRatio, $generated): void {
|
||||
$meta = $platform->meta ?? [];
|
||||
if ($aspectRatio !== null) {
|
||||
$meta['aspect_ratio'] = $aspectRatio;
|
||||
}
|
||||
$platform->meta = $meta;
|
||||
$platform->content_type = $contentType->value;
|
||||
$platform->content_type = $generated->contentType->value;
|
||||
$platform->enabled = true;
|
||||
$platform->save();
|
||||
});
|
||||
|
|
|
|||
17
tests/Feature/Ai/Templates/AiTemplateRegistryTest.php
Normal file
17
tests/Feature/Ai/Templates/AiTemplateRegistryTest.php
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Ai\Templates\AiTemplateRegistry;
|
||||
use App\Ai\Templates\ImageCardTemplate;
|
||||
|
||||
test('registry resolves keys and defaults to image_card', function () {
|
||||
$registry = app(AiTemplateRegistry::class);
|
||||
expect($registry->keys())->toContain('image_card', 'carousel')
|
||||
->and($registry->find('image_card'))->toBeInstanceOf(ImageCardTemplate::class)
|
||||
->and($registry->default()->key())->toBe('image_card');
|
||||
});
|
||||
|
||||
test('registry throws on unknown key', function () {
|
||||
expect(fn () => app(AiTemplateRegistry::class)->find('nope'))->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
Loading…
Reference in a new issue