PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.4.0
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.4.0
2.4.1 2.4.0 2.3.4 2.3.3 2.3.2 2.3.1 2.3.0 2.2.2 2.2.1 2.2.0 2.1.2 2.1.1 trunk 0.0.1 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.0.7 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 All 68 releases
better-payment / includes / AI / Services / CampaignGenerator.php

CampaignGenerator.php in Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More 2.4.0, at includes/AI/Services/CampaignGenerator.php

1,015 lines 45.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Better_Payment\Lite\AI\Services;
4
5 use Better_Payment\Lite\AI\AIManager;
6 use Better_Payment\Lite\AI\Layout\LayoutLibrary;
7 use Better_Payment\Lite\AI\Schema\CampaignSchema;
8 use Better_Payment\Lite\Campaign\Elements\ElementRegistry;
9 use Better_Payment\Lite\Campaign\Templates\CategoryRegistry;
10
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 /**
16 * Full-campaign generation from a natural-language brief.
17 *
18 * Thin wrapper over {@see AIService} in 'generate' mode that also derives a
19 * convenience { layout, meta } from the returned operations, so the /ai/generate
20 * endpoint can hand the client a ready-to-apply campaign in addition to the raw
21 * operations.
22 */
23 class CampaignGenerator {
24
25 /**
26 * A full page layout (the large `set_layout` tool call plus its element JSON)
27 * needs far more output than a conversational edit. Generation therefore uses
28 * a generous token floor so the layout is not truncated and silently dropped,
29 * leaving only the small meta calls applied.
30 */
31 const GENERATE_TOKEN_FLOOR = 8000;
32
33 /**
34 * @param string $brief
35 * @param array $context Optional starting state.
36 * @param string $category Campaign category slug the user explicitly chose in
37 * the Smart Prompt Wizard. '' when they picked no
38 * category tile (or wrote their own prompt), in which
39 * case one is inferred from the brief.
40 * @param array $fields Campaign fields the user filled in themselves, keyed
41 * by meta key. A key present but empty means they were
42 * asked and left it blank, which is enforced as "stays
43 * empty" — see {@see UserFieldGuard}. Absent entirely
44 * (the free-form prompt path) leaves the model's value.
45 * @return array|\WP_Error { assistant_message, operations, layout, meta, usage, layout_generated, category }
46 */
47 public static function generate( string $brief, array $context = [], string $category = '', array $fields = [] ) {
48 $config = AIManager::config_for( AIManager::active_provider_id() );
49 $max_tokens = max( self::GENERATE_TOKEN_FLOOR, (int) ( $config['max_tokens'] ?? 0 ) );
50 $original = $brief;
51
52 // Detect the category once, from the ORIGINAL brief — before the
53 // structure/palette instructions are appended below. Those append element
54 // names ("photo"), column roles and colour words that are ours, not the
55 // user's, and matching against them would classify the campaign by our own
56 // boilerplate. It is returned as metadata; it no longer picks the artwork.
57 $resolved = self::resolve_category( $category, $original );
58
59 // Every AI-generated photo uses the single neutral default hero
60 // (`ai-default-hero.svg`), for every category and template — never
61 // category-specific artwork. This asset is always present, so a generated
62 // photo can never fall back to an empty/broken `src` that renders as bare
63 // alt text in the builder. The model cannot produce a real image URL, so
64 // fill_default_photos() forces this onto every photo element.
65 $photo_url = self::default_photo_url();
66
67 // Two structural paths, and only ONE of them may impose a structure:
68 //
69 // - The user pre-chose a layout in the template picker → build into that
70 // exact structure, and snap the model's output onto it afterwards. The
71 // structure is enforced because it is *the user's own choice*.
72 // - No layout chosen → the structure is derived from the user's BRIEF.
73 // Nothing is imposed, and `$selected` stays null so `attempt()` never
74 // runs `enforce_structure()`.
75 //
76 // This branch used to pick a blueprint from LayoutLibrary and treat it
77 // exactly like a user selection: it appended "build this layout with
78 // exactly these columns and widths", and then rewrote every column's
79 // width from the blueprint regardless of what came back. A user who asked
80 // for "a 50/50 column campaign" was answered with a 42%/58% page — their
81 // instruction was first contradicted by a more specific competing one, and
82 // then overwritten by code that ignored the model's answer entirely. The
83 // model could not have complied even if it tried.
84 $selected = self::selected_structure( $context );
85
86 if ( null !== $selected ) {
87 $brief .= self::structure_instruction( $selected );
88 } else {
89 $brief .= self::layout_instruction();
90 $brief .= self::palette_instruction( LayoutLibrary::pick_palette( $original ) );
91 }
92
93 // Offer the Pro widgets on an entitled install — in both structural paths,
94 // since a user who pre-picked a layout has paid for them just the same.
95 $brief .= self::pro_elements_instruction();
96
97 // Any video URL the model may keep has to come from the user, so read them
98 // from the ORIGINAL brief — before our own instruction text is appended.
99 $video_urls = self::urls_in( $original );
100
101 // Attempt generation. If the page layout doesn't come through (empty or
102 // dropped set_layout), retry once — forcing a tool call and nudging toward a
103 // concise, complete layout — before giving up honestly.
104 $result = self::attempt( $brief, $context, $selected, $max_tokens, false, $photo_url, $fields, $video_urls );
105 if ( is_wp_error( $result ) ) {
106 return $result;
107 }
108 if ( null === $result['layout'] ) {
109 $retry = self::attempt( $brief . self::retry_instruction(), $context, $selected, $max_tokens, true, $photo_url, $fields, $video_urls );
110 if ( ! is_wp_error( $retry ) && null !== $retry['layout'] ) {
111 $result = $retry;
112 }
113 }
114
115 $layout = $result['layout'];
116 $meta = $result['meta'];
117
118 // Name the campaign: if the model didn't set a title but the layout has a
119 // campaign_title, adopt its text (so the builder isn't left "Campaign Name").
120 if ( null !== $layout ) {
121 $has_title_op = false;
122 foreach ( $result['operations'] as $op ) {
123 if ( is_array( $op ) && 'update_meta' === ( $op['op'] ?? '' ) && 'title' === ( $op['key'] ?? '' ) ) {
124 $has_title_op = true;
125 break;
126 }
127 }
128 if ( ! $has_title_op && empty( $meta['title'] ) ) {
129 $title = self::first_title( $layout );
130 if ( '' !== $title ) {
131 $result['operations'][] = [ 'op' => 'update_meta', 'key' => 'title', 'value' => $title ];
132 $meta['title'] = $title;
133 }
134 }
135 }
136
137 $result['layout'] = $layout;
138 $result['meta'] = $meta;
139 $result['layout_generated'] = null !== $layout;
140 // '' when the campaign matched no known category — the client can tell
141 // "we used your category's artwork" from "we used the default hero".
142 $result['category'] = $resolved;
143
144 // Be honest: a generation that produced no page layout (only meta) left the
145 // canvas empty. Do not report success — tell the user so they can retry.
146 if ( null === $layout ) {
147 $result['assistant_message'] = __(
148 'I set up the campaign details, but the page layout did not come through — the response may have been too long. Please try generating again, optionally with a shorter brief.',
149 'better-payment'
150 );
151 }
152
153 return $result;
154 }
155
156 /**
157 * One generation attempt: run the model, snap onto the chosen structure, and
158 * extract { layout, meta }. An all-empty layout is dropped (returns layout null).
159 *
160 * @param string $brief
161 * @param array $context
162 * @param array|null $selected
163 * @param int $max_tokens
164 * @param bool $force Force a tool call (tool_choice: required).
165 * @param string $photo_url Image to force onto every photo element.
166 * @param array $fields The user's own campaign fields (see generate()).
167 * @param array $video_urls URLs found in the user's brief — the only ones a
168 * `video` element may keep (see guard_video_urls()).
169 * @return array|\WP_Error
170 */
171 private static function attempt( string $brief, array $context, $selected, int $max_tokens, bool $force, string $photo_url = '', array $fields = [], array $video_urls = [] ) {
172 $options = [ 'max_tokens' => $max_tokens ];
173 if ( $force ) {
174 $options['tool_choice'] = 'required';
175 }
176
177 $result = AIService::run( 'generate', $brief, $context, [], $options );
178 if ( is_wp_error( $result ) ) {
179 return $result;
180 }
181
182 if ( null !== $selected ) {
183 $result['operations'] = self::enforce_structure( $result['operations'], $selected );
184 }
185
186 // Give photo elements a real image (the AI can't produce one), so the
187 // campaign looks finished — users swap it via the media library.
188 $result['operations'] = self::fill_default_photos( $result['operations'], $photo_url );
189
190 // Same reasoning as photos, sharper consequence: a model cannot know a real
191 // video address either, and an invented one resolves to a stranger's video.
192 // The video guard ships with Pro; Lite runs its copy until Pro provides it.
193 $result['operations'] = self::guard_generated_operations( $result['operations'], $video_urls );
194
195 // The amount tiers and the button that submits them are one action: the
196 // button must sit directly under the Donate Amount block. Structural, so
197 // the prompt alone cannot guarantee it — this is the part that holds.
198 $result['operations'] = self::enforce_donate_button_placement( $result['operations'] );
199
200 // The user's own answers outrank the model's: a field they left blank
201 // stays blank, and a value they gave is restored verbatim. Runs BEFORE
202 // meta is derived below, so the operations and that meta always agree.
203 $result['operations'] = UserFieldGuard::apply( $result['operations'], $fields );
204
205 // Bake every generated widget's registry defaults into its settings, so a
206 // generated element never ships missing a default the way a hand-dropped
207 // one never does. The model routinely omits keys, or emits a blank
208 // ("" / [] / null) for a value it has no real answer for — and a blank
209 // must fall back to the default, not overwrite it. The visible casualty is
210 // the Video widget: guard_video_urls() drops an invented url and the model
211 // often sends an empty one, which then clobbered the default sample video
212 // and rendered an empty embed. This is general — every widget, every
213 // default. Runs AFTER guard_video_urls() (so an invented/empty url is gone
214 // and the default now fills it) and AFTER fill_default_photos() (so the
215 // forced, non-blank photo src is preserved).
216 $result['operations'] = self::fill_element_defaults( $result['operations'] );
217
218 $layout = null;
219 $meta = [];
220 foreach ( $result['operations'] as $operation ) {
221 $op = $operation['op'] ?? '';
222 if ( 'set_layout' === $op ) {
223 $layout = [ 'layout' => $operation['layout'], 'columns' => $operation['columns'] ];
224 } elseif ( 'update_meta' === $op ) {
225 $meta[ $operation['key'] ] = $operation['value'];
226 } elseif ( 'set_colors' === $op ) {
227 if ( isset( $operation['primary'] ) ) {
228 $meta['bpc_color_primary'] = $operation['primary'];
229 }
230 if ( isset( $operation['background'] ) ) {
231 $meta['bpc_color_background'] = $operation['background'];
232 }
233 } elseif ( 'set_donation_amounts' === $op ) {
234 $meta['bpc_suggested_amounts'] = $operation['amounts'];
235 }
236 }
237
238 // An all-empty layout must not wipe the canvas — drop the set_layout op.
239 if ( null !== $layout ) {
240 $total = 0;
241 foreach ( $layout['columns'] as $col ) {
242 $total += is_array( $col['elements'] ?? null ) ? count( $col['elements'] ) : 0;
243 }
244 if ( 0 === $total ) {
245 $layout = null;
246 $result['operations'] = array_values( array_filter(
247 $result['operations'],
248 static function ( $op ) {
249 return is_array( $op ) && 'set_layout' !== ( $op['op'] ?? '' );
250 }
251 ) );
252 }
253 }
254
255 $result['layout'] = $layout;
256 $result['meta'] = $meta;
257 return $result;
258 }
259
260 /**
261 * Instruction appended to the retry brief when the first attempt produced no
262 * usable layout.
263 *
264 * The type list is derived, never hardcoded. It used to be a literal string of
265 * the ten free types, which meant a Pro install's retry told the model its Pro
266 * widgets did not exist — and a retry is exactly the attempt that has to
267 * produce the finished page. It also silently went stale the moment any
268 * element was added to the registry.
269 */
270 private static function retry_instruction(): string {
271 $types = implode( ', ', CampaignSchema::offerable_element_types() );
272
273 return "\n\n" . sprintf(
274 /* translators: %s: comma-separated list of allowed element type keys. */
275 __( "Your previous attempt did not return a usable page layout. This time, call set_layout FIRST and keep every element's copy concise so the whole call fits in one response. Use ONLY these exact element type keys: %s.", 'better-payment' ),
276 $types
277 );
278 }
279
280 /**
281 * Instruction naming the Pro widgets, appended only on an entitled install.
282 *
283 * Generation no longer prescribes a blueprint, so this is the only text that
284 * proposes a placement: it says what the widgets are and when they earn their
285 * place, so the model treats them as deliberate additions rather than boxes to
286 * fill. Without it a Pro install
287 * generated pages indistinguishable from a free one — the user paid for three
288 * widgets the assistant never reached for.
289 *
290 * Two of the three carry a correctness constraint rather than a stylistic one,
291 * repeated here because this is the text that actually invites their use:
292 * Donors Wall renders live transaction data (writing donor names into a
293 * fundraising page would be fabricating a record of who gave money), and Video
294 * must not carry an invented URL.
295 */
296 private static function pro_elements_instruction(): string {
297 if ( ! CampaignSchema::pro_enabled() ) {
298 return '';
299 }
300
301 // Pro supplies this itself once it declares `ai_pro_widget_guidance`; this
302 // copy serves older Pro releases.
303 $instruction = CampaignSchema::lite_supplies_pro_guidance()
304 ? "\n\n" . __( "PRO WIDGETS AVAILABLE — this site has Better Payment Pro, so you may also use `donors_wall`, `faq` and `video`. Use them where they genuinely strengthen THIS campaign, not as a checklist: `faq` when a donor would hesitate over how the money is spent or whether giving is safe; `donors_wall` beside the donation ask to show momentum; `video` in the hero only when the brief actually supplies a video URL. Do not write donor names, amounts or dates into `donors_wall` — it renders real donations by itself. Do not invent a `video` URL.", 'better-payment' )
305 : '';
306
307 /**
308 * Filter the Pro-widget instruction appended to a generation brief on a Pro
309 * install. The value is the text appended to the brief, including its leading
310 * blank line — '' when Pro supplies the instruction itself.
311 */
312 return (string) apply_filters( 'better_payment/ai/pro_generation_instruction', $instruction );
313 }
314
315 /**
316 * The generation guards that only concern Pro widgets, for one attempt.
317 *
318 * Runs after fill_default_photos() and before donate-button placement, user
319 * fields and element defaults, so a `url` dropped here lets the Video widget's
320 * default fill in. Lite runs its own video URL guard only for an active Pro that
321 * does not declare `ai_pro_widget_guidance`; a current Pro guards through the
322 * filter. A free install needs no guard: its `video` is a lock stub with no
323 * settings keys, so the validator has already dropped any `url`.
324 *
325 * @param array $operations Validated operations.
326 * @param array $brief_urls http(s) URLs found in the user's original brief.
327 * @return array
328 */
329 public static function guard_generated_operations( array $operations, array $brief_urls = [] ): array {
330 if ( CampaignSchema::lite_supplies_pro_guidance() ) {
331 $operations = self::guard_video_urls( $operations, $brief_urls );
332 }
333
334 /**
335 * Filter one generation attempt's operations before donate-button placement,
336 * user-field enforcement and element defaults are applied. The second
337 * argument is `[ 'brief_urls' => string[] ]`, the URLs in the user's original
338 * brief — the only ones a generated element may keep.
339 */
340 $filtered = apply_filters( 'better_payment/ai/generated_operations', $operations, [ 'brief_urls' => $brief_urls ] );
341
342 return is_array( $filtered ) ? $filtered : $operations;
343 }
344
345 /**
346 * Settle on the campaign's category: the one the user explicitly chose, else
347 * one inferred from the brief, else '' for a campaign that fits no category
348 * we know ("a new category" — it gets the default hero).
349 *
350 * An explicit choice always wins. Only an *unknown* slug falls through to
351 * inference, so a user who picked "Medical" never gets Environmental artwork
352 * because their story happened to mention a flood.
353 *
354 * @param string $category Slug from the wizard; '' when none was picked.
355 * @param string $brief The user's brief, for inference.
356 * @return string A category slug, or ''.
357 */
358 public static function resolve_category( string $category, string $brief ): string {
359 if ( CategoryRegistry::exists( $category ) ) {
360 return $category;
361 }
362 return CategoryRegistry::match_text( $brief );
363 }
364
365 /**
366 * The default hero image for AI-generated campaigns — used for EVERY generated
367 * photo, in every category. A neutral, on-brand illustration that suits any
368 * fundraiser and reads as intentional (not a broken/empty box); users replace
369 * it via the media library. Filterable so a site can point it at its own photo
370 * globally.
371 *
372 * Every generated photo gets this one asset. Category-specific artwork was
373 * retired here on purpose: a category photo can be missing on disk (a broken
374 * `src` rendering as bare alt text), whereas `ai-default-hero.svg` always ships
375 * with the plugin. Category detection ({@see self::resolve_category()}) remains
376 * for metadata only.
377 */
378 public static function default_photo_url(): string {
379 $default = defined( 'BETTER_PAYMENT_ASSETS' )
380 ? BETTER_PAYMENT_ASSETS . '/img/campaign/ai-default-hero.svg'
381 : '';
382 /**
383 * Filter the default image applied to AI-generated photo elements.
384 *
385 * @param string $url
386 */
387 return (string) apply_filters( 'better_payment/ai/default_photo_url', $default );
388 }
389
390 /**
391 * Point every generated photo element at `$url` — always the default hero
392 * ({@see self::default_photo_url()}), which is what {@see self::generate()}
393 * passes.
394 *
395 * A text model cannot produce a real image URL, so any `src` it emits is a
396 * hallucination that renders as a broken image. We therefore ALWAYS replace
397 * the src (and reset the attachment id/sizes), while keeping the model's
398 * descriptive `alt`. Real images come from the `generate_image` operation
399 * instead, which runs client-side after generation.
400 *
401 * @param array $operations
402 * @param string $url Image URL; falls back to the default hero when '', and
403 * only skips the pass if that is empty too (never blanks a
404 * photo).
405 * @return array
406 */
407 public static function fill_default_photos( array $operations, string $url = '' ): array {
408 if ( '' === $url ) {
409 $url = self::default_photo_url();
410 }
411 if ( '' === $url ) {
412 return $operations;
413 }
414
415 foreach ( $operations as &$op ) {
416 if ( ! is_array( $op ) ) {
417 continue;
418 }
419 $name = $op['op'] ?? '';
420
421 if ( 'set_layout' === $name && ! empty( $op['columns'] ) && is_array( $op['columns'] ) ) {
422 foreach ( $op['columns'] as &$col ) {
423 if ( empty( $col['elements'] ) || ! is_array( $col['elements'] ) ) {
424 continue;
425 }
426 foreach ( $col['elements'] as &$el ) {
427 if ( is_array( $el ) && 'photo' === ( $el['type'] ?? '' ) ) {
428 $el['settings'] = self::default_photo_settings( $el['settings'] ?? [], $url );
429 }
430 }
431 unset( $el );
432 }
433 unset( $col );
434 } elseif ( 'insert_block' === $name && 'photo' === ( $op['type'] ?? '' ) ) {
435 $op['settings'] = self::default_photo_settings( $op['settings'] ?? [], $url );
436 }
437 }
438 unset( $op );
439
440 return $operations;
441 }
442
443 /**
444 * Force a photo element's settings onto the default image, keeping the model's
445 * alt text when it provided one.
446 *
447 * @param mixed $settings
448 * @param string $url
449 * @return array
450 */
451 private static function default_photo_settings( $settings, string $url ): array {
452 $settings = is_array( $settings ) ? $settings : [];
453 $settings['src'] = $url;
454 $settings['src_id'] = 0;
455 $settings['src_sizes'] = [];
456 if ( empty( $settings['alt'] ) ) {
457 $settings['alt'] = __( 'Campaign image', 'better-payment' );
458 }
459 return $settings;
460 }
461
462 /**
463 * Every http(s) URL appearing in a string.
464 *
465 * @return array<int, string>
466 */
467 public static function urls_in( string $text ): array {
468 if ( ! preg_match_all( '#https?://[^\s<>"\'\)\]]+#i', $text, $matches ) ) {
469 return [];
470 }
471
472 $urls = [];
473 foreach ( $matches[0] as $url ) {
474 // Trailing sentence punctuation is not part of the address.
475 $urls[] = rtrim( (string) $url, '.,;:!?' );
476 }
477
478 return array_values( array_unique( array_filter( $urls ) ) );
479 }
480
481 /**
482 * Drop a `video` element's `url` unless the user's brief actually contained it.
483 *
484 * The exact problem {@see self::fill_default_photos()} solves, with a worse
485 * failure mode. A text model has no way to know a real video address, so asked
486 * for a campaign video it produces a well-formed YouTube URL with an invented
487 * ID — and that ID belongs to *something*. The result is an unrelated
488 * stranger's video embedded on a fundraising page, presented as the campaign's
489 * own appeal. That is materially worse than a broken image, because it looks
490 * entirely intentional.
491 *
492 * A URL the user typed is theirs and is kept verbatim. Anything else is
493 * removed, which lets the element's schema default apply — the same sample
494 * video a user gets when they drop the Video widget by hand, so the outcome is
495 * a widget in its ordinary unconfigured state rather than a false claim.
496 *
497 * Note this cannot be left to the prompt: `rules.php` already forbids inventing
498 * values, and a prompt is a request. This is the part that holds.
499 *
500 * @param array $operations
501 * @param array $allowed URLs found in the user's brief.
502 * @return array
503 */
504 public static function guard_video_urls( array $operations, array $allowed = [] ): array {
505 foreach ( $operations as &$op ) {
506 if ( ! is_array( $op ) ) {
507 continue;
508 }
509 $name = $op['op'] ?? '';
510
511 if ( 'set_layout' === $name && ! empty( $op['columns'] ) && is_array( $op['columns'] ) ) {
512 foreach ( $op['columns'] as &$col ) {
513 if ( empty( $col['elements'] ) || ! is_array( $col['elements'] ) ) {
514 continue;
515 }
516 foreach ( $col['elements'] as &$el ) {
517 if ( is_array( $el ) && 'video' === ( $el['type'] ?? '' ) ) {
518 $el['settings'] = self::guarded_video_settings( $el['settings'] ?? [], $allowed );
519 }
520 }
521 unset( $el );
522 }
523 unset( $col );
524 } elseif ( 'insert_block' === $name && 'video' === ( $op['type'] ?? '' ) ) {
525 $op['settings'] = self::guarded_video_settings( $op['settings'] ?? [], $allowed );
526 }
527 }
528 unset( $op );
529
530 return $operations;
531 }
532
533 /**
534 * Remove an unvouched `url` from a video element's settings.
535 *
536 * Matching is exact against the brief's URLs, plus a prefix match so a user's
537 * link that the model reproduced with extra query parameters (`&t=30s`) still
538 * counts as theirs.
539 *
540 * @param mixed $settings
541 * @param array $allowed
542 * @return array
543 */
544 private static function guarded_video_settings( $settings, array $allowed ): array {
545 $settings = is_array( $settings ) ? $settings : [];
546
547 if ( ! isset( $settings['url'] ) ) {
548 return $settings;
549 }
550
551 $url = trim( (string) $settings['url'] );
552 if ( '' === $url ) {
553 return $settings;
554 }
555
556 foreach ( $allowed as $candidate ) {
557 $candidate = trim( (string) $candidate );
558 if ( '' === $candidate ) {
559 continue;
560 }
561 if ( $url === $candidate || 0 === strpos( $url, $candidate ) ) {
562 return $settings;
563 }
564 }
565
566 unset( $settings['url'] );
567
568 return $settings;
569 }
570
571 /**
572 * Merge each generated element's registry defaults UNDER its settings, so a
573 * generated widget carries the same complete defaults a hand-dropped one does.
574 *
575 * A model value overrides the default only when it is a real value. A blank —
576 * `null`, an empty/whitespace string, or an empty array — is treated as "not
577 * provided" and the default is kept. This is the crucial difference from a
578 * plain `array_merge`: the client's own merge (`freshIds`/`withDefaults`) fills
579 * only keys the model *omitted*, so a key the model emitted as `""` (which the
580 * model does whenever it has no real value) still clobbered the default. That
581 * is why a generated Video rendered an empty embed instead of the sample video.
582 *
583 * Generation-only on purpose. This is NOT the "emptied means empty" contract
584 * that governs edits: there is no prior user state on a freshly generated page,
585 * so a blank from the model is a non-answer, never a deliberate clear. Edits
586 * (CampaignEditor) never run through here, and templates apply client-side
587 * without it — so Pro's deliberately-empty template video `url` is untouched.
588 *
589 * @param array $operations
590 * @return array
591 */
592 public static function fill_element_defaults( array $operations ): array {
593 foreach ( $operations as &$op ) {
594 if ( ! is_array( $op ) ) {
595 continue;
596 }
597 $name = $op['op'] ?? '';
598
599 if ( 'set_layout' === $name && ! empty( $op['columns'] ) && is_array( $op['columns'] ) ) {
600 foreach ( $op['columns'] as &$col ) {
601 if ( empty( $col['elements'] ) || ! is_array( $col['elements'] ) ) {
602 continue;
603 }
604 foreach ( $col['elements'] as &$el ) {
605 if ( is_array( $el ) && ! empty( $el['type'] ) ) {
606 $el['settings'] = self::with_element_defaults(
607 (string) $el['type'],
608 is_array( $el['settings'] ?? null ) ? $el['settings'] : []
609 );
610 }
611 }
612 unset( $el );
613 }
614 unset( $col );
615 } elseif ( 'insert_block' === $name && ! empty( $op['type'] ) ) {
616 $op['settings'] = self::with_element_defaults(
617 (string) $op['type'],
618 is_array( $op['settings'] ?? null ) ? $op['settings'] : []
619 );
620 }
621 }
622 unset( $op );
623
624 return $operations;
625 }
626
627 /**
628 * The registry defaults for `$type`, with any non-blank model value laid on top.
629 *
630 * @param string $type
631 * @param array $settings Model-supplied settings.
632 * @return array
633 */
634 private static function with_element_defaults( string $type, array $settings ): array {
635 $defaults = ElementRegistry::get_defaults( $type );
636 if ( empty( $defaults ) ) {
637 return $settings;
638 }
639
640 $merged = $defaults;
641 foreach ( $settings as $key => $value ) {
642 if ( self::is_blank_value( $value ) ) {
643 continue; // keep the default
644 }
645 $merged[ $key ] = $value;
646 }
647
648 return $merged;
649 }
650
651 /**
652 * Whether a model-supplied setting value counts as "not provided", so the
653 * registry default should stand.
654 *
655 * `null`, empty/whitespace strings and empty arrays are blank. `0`, `false`
656 * and `'0'` are meaningful (a `number_to_show` of 0, a toggle turned off) and
657 * must override the default, so they are NOT blank.
658 *
659 * @param mixed $value
660 * @return bool
661 */
662 private static function is_blank_value( $value ): bool {
663 if ( null === $value ) {
664 return true;
665 }
666 if ( is_string( $value ) ) {
667 return '' === trim( $value );
668 }
669 if ( is_array( $value ) ) {
670 return empty( $value );
671 }
672
673 return false;
674 }
675
676 /**
677 * Keep the Donate Button (`donation_form`) directly beneath the Donate Amount
678 * (`donate_amount`) block in every generated campaign.
679 *
680 * The amount tiers and the button that submits them are one action split
681 * across two widgets: a donor picks an amount and the very next thing they
682 * must see is the button that takes it. A model composing "for the strongest
683 * narrative" will happily park the button in the opposite column, or three
684 * elements further down — which reads as two unrelated controls and loses the
685 * click. So on every `set_layout`, whenever a `donate_amount` block is present:
686 *
687 * - an existing `donation_form` anywhere in the layout is moved to sit
688 * immediately after it, in the SAME column (pulled across columns if the
689 * model split them apart);
690 * - if the layout has no `donation_form` at all, one is inserted there with
691 * its registry defaults — every amount block must be followed by a button
692 * (the same requirement `generate.php` states, made real here).
693 *
694 * Structural only — element settings are never touched. Same reasoning as the
695 * photo and video guards: the prompt asks for this, and this is what holds it.
696 *
697 * @param array $operations
698 * @return array
699 */
700 public static function enforce_donate_button_placement( array $operations ): array {
701 foreach ( $operations as &$op ) {
702 if ( ! is_array( $op ) || 'set_layout' !== ( $op['op'] ?? '' ) ) {
703 continue;
704 }
705 if ( empty( $op['columns'] ) || ! is_array( $op['columns'] ) ) {
706 continue;
707 }
708 $op['columns'] = self::place_donate_button( $op['columns'] );
709 }
710 unset( $op );
711
712 return $operations;
713 }
714
715 /**
716 * Position (or create) the Donate Button so it immediately follows the first
717 * Donate Amount block. No-op when the layout carries no `donate_amount`.
718 *
719 * @param array $columns
720 * @return array
721 */
722 private static function place_donate_button( array $columns ): array {
723 if ( null === self::find_element( $columns, 'donate_amount' ) ) {
724 return $columns;
725 }
726
727 // Take any existing button out of the layout (wherever the model put it)
728 // so it can be dropped back in the right place; build a default when the
729 // layout has none.
730 $existing = self::find_element( $columns, 'donation_form' );
731 if ( null !== $existing ) {
732 list( $bci, $bei ) = $existing;
733 $button = $columns[ $bci ]['elements'][ $bei ];
734 array_splice( $columns[ $bci ]['elements'], $bei, 1 );
735 } else {
736 $button = self::default_donate_button();
737 }
738
739 // Re-find the amount block AFTER the removal — pulling the button out from
740 // above it in the same column shifts its index — then drop the button in
741 // directly beneath it.
742 $amount_at = self::find_element( $columns, 'donate_amount' );
743 if ( null === $amount_at ) {
744 return $columns; // Defensive: donate_amount was never removed, so unreachable.
745 }
746 list( $aci, $aei ) = $amount_at;
747 array_splice( $columns[ $aci ]['elements'], $aei + 1, 0, array( $button ) );
748
749 return $columns;
750 }
751
752 /**
753 * The [column index, element index] of the first element of `$type`, or null.
754 *
755 * @param array $columns
756 * @param string $type
757 * @return array|null
758 */
759 private static function find_element( array $columns, string $type ) {
760 foreach ( $columns as $ci => $col ) {
761 $elements = is_array( $col['elements'] ?? null ) ? $col['elements'] : [];
762 foreach ( $elements as $ei => $el ) {
763 if ( is_array( $el ) && $type === ( $el['type'] ?? '' ) ) {
764 return [ $ci, $ei ];
765 }
766 }
767 }
768 return null;
769 }
770
771 /**
772 * A Donate Button element carrying its registry defaults — the same widget a
773 * user gets by dropping "Donate Button" onto the canvas by hand. No `id`: the
774 * client assigns ids to every `set_layout` element via freshIds().
775 *
776 * @return array
777 */
778 private static function default_donate_button(): array {
779 return [
780 'type' => 'donation_form',
781 'settings' => ElementRegistry::get_defaults( 'donation_form' ),
782 ];
783 }
784
785 /**
786 * The first campaign_title element's title text in a layout, or ''.
787 *
788 * @param array $layout
789 */
790 private static function first_title( array $layout ): string {
791 foreach ( $layout['columns'] ?? [] as $col ) {
792 foreach ( $col['elements'] ?? [] as $el ) {
793 if ( 'campaign_title' === ( $el['type'] ?? '' ) ) {
794 $title = trim( (string) ( $el['settings']['title'] ?? '' ) );
795 if ( '' !== $title ) {
796 return $title;
797 }
798 }
799 }
800 }
801 return '';
802 }
803
804 /**
805 * The layout structure the user pre-selected, or null when the campaign is
806 * a truly blank slate (no columns yet — the model may choose freely).
807 *
808 * @param array $context
809 * @return array|null [ 'preset' => string, 'columns' => array ]
810 */
811 private static function selected_structure( array $context ) {
812 $columns = $context['layout']['columns'] ?? null;
813 if ( ! is_array( $columns ) || 0 === count( $columns ) ) {
814 return null;
815 }
816 return [
817 'preset' => (string) ( $context['layout']['layout'] ?? '' ),
818 'columns' => array_values( $columns ),
819 ];
820 }
821
822 /**
823 * Instruction appended to the brief when a layout is already selected. Lists
824 * the exact columns (id + width + label) so the model returns that structure.
825 *
826 * @param array $selected
827 */
828 private static function structure_instruction( array $selected ): string {
829 $lines = [];
830 foreach ( $selected['columns'] as $i => $col ) {
831 $lines[] = sprintf(
832 ' %d) id "%s", width %s%s',
833 $i + 1,
834 (string) ( $col['id'] ?? ( 'col' . ( $i + 1 ) ) ),
835 (string) ( $col['width'] ?? '100%' ),
836 ! empty( $col['label'] ) ? ' — ' . $col['label'] : ''
837 );
838 }
839 $preset = '' !== $selected['preset'] ? $selected['preset'] : 'multi-column';
840
841 return "\n\n" . sprintf(
842 /* translators: 1: layout preset, 2: column count, 3: bullet list of columns. */
843 __( "IMPORTANT: The user has already chosen a %1\$s layout with exactly %2\$d column(s). Your set_layout MUST return exactly these columns, in this order, with these ids and widths — do not add, remove, or resize columns:\n%3\$s\nPlace appropriate elements into each column so the campaign fills the layout the user selected.", 'better-payment' ),
844 $preset,
845 count( $selected['columns'] ),
846 implode( "\n", $lines )
847 );
848 }
849
850 /**
851 * Prompt section for the free-form path: the page structure comes from the
852 * user's own brief, and from nothing else.
853 *
854 * This replaces `blueprint_instruction()`, which named a pre-built layout and
855 * ordered the model to reproduce its exact column count and widths. That was
856 * a second, more specific instruction competing with whatever the user had
857 * actually asked for — and the user lost every time. "Create a 50/50 column
858 * campaign" arrived alongside "build the Donation-first layout … width 42% …
859 * width 58% … keep the column count and widths above", and the page came back
860 * 42/58.
861 *
862 * Nothing here prescribes a shape. It tells the model to read the user's
863 * words for structure and obey them literally when they are there, and to
864 * compose a structure that suits *this* story when they are not. The trade is
865 * deliberate: the blueprint library existed to keep generated campaigns from
866 * looking cloned, and that variety now has to come from the brief rather than
867 * from a rotation we control. A page that ignores what the user typed is not
868 * worth the variety.
869 */
870 private static function layout_instruction(): string {
871 return "\n\n" . __(
872 "PAGE STRUCTURE — take it from the brief above, never from a default.\n"
873 . "1. Read the user's brief for anything that describes the page's shape: a number of columns, a split or ratio (\"50/50\", \"two equal columns\", \"70/30\"), a sidebar and which side it is on, a full-width or single-column page, a hero band, or the order sections should appear in. If the brief says it, build EXACTLY that — the stated column count, in the stated order, with widths matching the stated ratio (\"50/50\" means two columns of width \"50%\" and \"50%\"). A structural instruction from the user is not a preference to balance against your own judgement; it is the requirement.\n"
874 . "2. If the brief says nothing about structure, choose the one that best serves THIS campaign's story and audience, and vary it to suit the subject — an emergency appeal, a memorial and a school fundraiser should not come out identically composed. Do not default to a single house layout.\n"
875 . '3. Either way, set each column\'s `width` explicitly as a percentage string, pick the `layout` preset that matches the column count, and give every column a short, meaningful `label`.',
876 'better-payment'
877 );
878 }
879
880 /**
881 * Prompt section suggesting a primary brand colour — explicitly as a
882 * **fallback**, never as an override.
883 *
884 * Same failure mode as the old blueprint instruction, one notch quieter: this
885 * used to read "Set a primary brand colour near #E5484D", flatly, with no
886 * deference to the brief. A user who asked for their charity's green got our
887 * red, for the same reason they got 42/58 columns — a specific, imperative
888 * instruction of ours arriving after theirs. A colour named in the brief is a
889 * brand decision, and it is not ours to make.
890 *
891 * @param array $palette [ 'primary' => hex, 'mood' => string ]
892 */
893 private static function palette_instruction( array $palette ): string {
894 if ( empty( $palette['primary'] ) ) {
895 return '';
896 }
897 return "\n\n" . sprintf(
898 /* translators: 1: hex colour, 2: mood description. */
899 __( 'COLOUR — if the brief names a colour, a brand or an existing palette, use that and ignore this line. Only if it names none, set a primary brand colour near %1$s for a %2$s tone (call set_colors).', 'better-payment' ),
900 (string) $palette['primary'],
901 (string) ( $palette['mood'] ?? '' )
902 );
903 }
904
905 /**
906 * Snap any `set_layout` operation onto the user's selected structure so the
907 * preset, column ids, labels and widths are always preserved — regardless of
908 * what the model returned:
909 * - Column counts match → keep the model's per-column elements, force the
910 * column meta (id/label/width) from the selection.
911 * - Counts differ → rebuild the selected columns and redistribute all the
912 * model's elements across them proportionally to column width, so the
913 * layout is never left broken (the split case's classic failure).
914 *
915 * @param array $operations
916 * @param array $selected
917 * @return array
918 */
919 private static function enforce_structure( array $operations, array $selected ): array {
920 $orig = $selected['columns'];
921
922 foreach ( $operations as &$operation ) {
923 if ( ! is_array( $operation ) || 'set_layout' !== ( $operation['op'] ?? '' ) ) {
924 continue;
925 }
926
927 if ( '' !== $selected['preset'] ) {
928 $operation['layout'] = $selected['preset'];
929 }
930
931 $model_cols = is_array( $operation['columns'] ?? null ) ? $operation['columns'] : [];
932
933 if ( count( $model_cols ) === count( $orig ) ) {
934 foreach ( $model_cols as $i => $col ) {
935 foreach ( [ 'id', 'label', 'width', 'widthTablet', 'widthMobile' ] as $key ) {
936 if ( isset( $orig[ $i ][ $key ] ) ) {
937 $model_cols[ $i ][ $key ] = $orig[ $i ][ $key ];
938 }
939 }
940 }
941 $operation['columns'] = $model_cols;
942 continue;
943 }
944
945 // Column count mismatch — flatten every element the model produced and
946 // redistribute them across the selected columns by width weight.
947 $all = [];
948 foreach ( $model_cols as $col ) {
949 $elements = is_array( $col['elements'] ?? null ) ? $col['elements'] : [];
950 foreach ( $elements as $el ) {
951 $all[] = $el;
952 }
953 }
954 $operation['columns'] = self::distribute_elements( $all, $orig );
955 }
956 unset( $operation );
957
958 return $operations;
959 }
960
961 /**
962 * Distribute a flat list of elements across the given columns, weighted by
963 * each column's width so wider columns receive more elements. Elements stay in
964 * their original order and are placed contiguously.
965 *
966 * @param array $elements
967 * @param array $columns The selected columns (id/label/width, no elements).
968 * @return array Columns with an `elements` array on each.
969 */
970 private static function distribute_elements( array $elements, array $columns ): array {
971 $out = [];
972 foreach ( $columns as $col ) {
973 $col['elements'] = [];
974 $out[] = $col;
975 }
976 $n = count( $out );
977 if ( 0 === $n ) {
978 return $out;
979 }
980
981 // Cumulative width fractions → boundaries for contiguous assignment.
982 $weights = [];
983 $total = 0.0;
984 foreach ( $columns as $col ) {
985 $w = (float) preg_replace( '/[^0-9.]/', '', (string) ( $col['width'] ?? '100' ) );
986 if ( $w <= 0 ) {
987 $w = 1.0;
988 }
989 $weights[] = $w;
990 $total += $w;
991 }
992 $bounds = [];
993 $acc = 0.0;
994 foreach ( $weights as $w ) {
995 $acc += $w / $total;
996 $bounds[] = $acc;
997 }
998
999 $count = count( $elements );
1000 foreach ( $elements as $index => $element ) {
1001 $ratio = $count > 0 ? ( $index + 0.5 ) / $count : 0.0;
1002 $target = $n - 1;
1003 foreach ( $bounds as $i => $boundary ) {
1004 if ( $ratio <= $boundary ) {
1005 $target = $i;
1006 break;
1007 }
1008 }
1009 $out[ $target ]['elements'][] = $element;
1010 }
1011
1012 return $out;
1013 }
1014 }
1015