feat: structured carousel script with hook/development/proof/cta roles

Replaces the vague "intro → development → conclusion" guidance in the
generator prompt with an explicit per-slide arc: open with a specific
real urgent problem, develop the idea while creating swipe motivation,
prove with concrete evidence (result/before-after/BTS/learning), and
close with a single specific next action. Includes a slide_count to
role-distribution table (2 through 6+ slides).

Adds a `role` enum field to each slide in the carousel JSON schema
(hook, development, proof, cta) so the structure is enforced via SDK
not just prompt text. Field is additive — handleCarousel still reads
only title/body/image_keywords, so consumers are unaffected.
This commit is contained in:
Paulo Castellano 2026-05-08 21:05:50 -03:00
parent 26e7688e96
commit 47cf7392e8
3 changed files with 69 additions and 2 deletions

View file

@ -80,13 +80,17 @@ public function schema(JsonSchema $schema): array
'caption' => $schema->string()->description('The Instagram caption for the carousel post.')->required(),
'slides' => $schema->array()
->items($schema->object(fn ($s) => [
'role' => $s->string()
->enum(['hook', 'development', 'proof', 'cta'])
->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(),
'image_keywords' => $s->array()->items($schema->string())->description('2-4 search keywords for Unsplash.')->required(),
]))
->min($this->slideCount)
->max($this->slideCount)
->description("Exactly {$this->slideCount} slides for the carousel, in order.")
->description("Exactly {$this->slideCount} slides for the carousel, in order. First slide must have role `hook`, last slide must have role `cta`.")
->required(),
];
}

View file

@ -59,6 +59,7 @@
Output format: a JSON object with `caption` (the Instagram caption text) and a `slides` array.
CRITICAL: The `slides` array MUST contain exactly {{ $slide_count ?? 1 }} items — no fewer, no more. Count carefully before responding. Each slide object must have:
- `role`: one of `hook`, `development`, `proof`, `cta` (see roteiro rules below)
- `title`: a short, impactful headline for that slide (in {{ $content_language ?? 'en' }})
- `body`: 1-3 sentences of supporting text (in {{ $content_language ?? 'en' }})
- `image_keywords`: 2-4 words describing a CONCRETE VISUAL SCENE for an Unsplash image search.
@ -80,7 +81,31 @@
ALWAYS write these in English, even when content_language is not 'en'. Unsplash's search index is English-only — Portuguese/Spanish queries return poor results.
Example for a pt-BR post about productivity: `["person typing laptop", "coffee shop morning"]`, NOT `["produtividade", "trabalho"]`.
Plan the {{ $slide_count ?? 1 }}-slide narrative arc first (intro → development → conclusion or hook → points → CTA), then write each slide. The caption should tease the carousel content and encourage swiping.
## Carousel script (roteiro) — this is the difference between a carousel that converts and one that gets ignored
A carousel is not a list of bullet points. It's a sequence with a structure: open a problem, develop the idea, prove it works, give a next step. Plan the arc BEFORE writing — decide what each slide does — then write.
**Slide 1 — `hook` (always the first slide):**
Open with a SPECIFIC, real, urgent problem the reader recognizes — something that makes them think "isso é comigo agora". Not a generic theme ("productivity tips"), not a greeting ("hey there!"), not a setup ("today we'll talk about..."). State the pain or the surprising claim directly. If the reader doesn't feel the hook in the first 2 seconds, the rest doesn't matter.
**Middle slides — `development` and `proof` (slides 2 to N-1):**
Each middle slide opens an idea and gives the reader a reason to swipe to the next. Don't deliver the full answer in any single slide — that kills the swipe. Move from abstract (the problem) to concrete (here's how it actually works). Trust rises sharply the moment the carousel goes from "isso é importante" to "funciona assim, na prática".
- `development` slides: unfold the idea, show the path, walk the reader through the how. One step or one angle per slide.
- `proof` slides: concrete evidence — a result, a before/after, a behind-the-scenes detail, a real number, a learning from doing it. The more applicable, the more it converts. When the carousel has 4 or more slides, INCLUDE AT LEAST ONE `proof` slide in the middle.
**Last slide — `cta` (always the final slide):**
A single, specific next action the reader can do right now. Not "follow for more content", not "let me know what you think", not loose. Something concrete tied to the post's promise — what to apply today, what to try tomorrow, what to read/save/comment specifically. Never end the carousel hanging.
**Role distribution by slide count:**
- 2 slides: `hook` + `cta`
- 3 slides: `hook` + `development` + `cta`
- 4 slides: `hook` + `development` + `proof` + `cta`
- 5 slides: `hook` + `development` + `proof` + `development` + `cta`
- 6+ slides: `hook` + alternate `development`/`proof` (at least one `proof`) + `cta`
**Caption:**
The `caption` should tease the carousel's promise and reinforce the swipe — not summarize. Make the reader curious about what's inside the slides, then encourage swiping.
@else
Output format: a JSON object with:
- `content`: the full post caption in {{ $content_language ?? 'en' }} (no preamble, no quotation marks). This is what gets published.

View file

@ -66,6 +66,21 @@
expect($schema)->not->toHaveKey('content');
});
test('carousel slide schema includes role enum', function () {
$workspace = Workspace::factory()->make();
$agent = new PostContentGenerator(workspace: $workspace, format: 'carousel', slideCount: 4);
$schemaFactory = new JsonSchemaTypeFactory;
$schema = $agent->schema($schemaFactory);
$slidesArray = $schema['slides']->toArray();
$slideItem = $slidesArray['items'];
expect($slideItem['properties'])->toHaveKey('role');
expect($slideItem['properties']['role']['enum'])->toBe(['hook', 'development', 'proof', 'cta']);
expect($slideItem['required'])->toContain('role');
});
test('carousel instructions mention slide count', function () {
$workspace = Workspace::factory()->make();
$agent = new PostContentGenerator(workspace: $workspace, format: 'carousel', slideCount: 5);
@ -76,6 +91,29 @@
expect($instructions)->toContain('slides');
});
test('carousel instructions describe the roteiro arc with hook, development, proof and cta', function () {
$workspace = Workspace::factory()->make();
$agent = new PostContentGenerator(workspace: $workspace, format: 'carousel', slideCount: 4);
$instructions = $agent->instructions();
expect($instructions)->toContain('hook');
expect($instructions)->toContain('development');
expect($instructions)->toContain('proof');
expect($instructions)->toContain('cta');
expect($instructions)->toContain('next action');
});
test('single format instructions do not include carousel roteiro section', function () {
$workspace = Workspace::factory()->make();
$agent = new PostContentGenerator(workspace: $workspace, format: 'single');
$instructions = $agent->instructions();
expect($instructions)->not->toContain('Carousel script');
expect($instructions)->not->toContain('Role distribution by slide count');
});
test('single instructions mention image_keywords', function () {
$workspace = Workspace::factory()->make();
$agent = new PostContentGenerator(workspace: $workspace, format: 'single');