PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.4.1
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.4.1
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
← All changes | includes/AI/Services/CampaignGenerator.php +44 -102 2.3.0 → 2.4.1 View file →
@@ -164,9 +164,9 @@
164 164 * @param bool $force Force a tool call (tool_choice: required).
165 165 * @param string $photo_url Image to force onto every photo element.
166 166 * @param array $fields The user's own campaign fields (see generate()).
167 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()).
168 + * `video` element may keep (see guard_generated_operations()).
169 169 * @return array|\WP_Error
170 170 */
171 171 private static function attempt( string $brief, array $context, $selected, int $max_tokens, bool $force, string $photo_url = '', array $fields = [], array $video_urls = [] ) {
172 172 $options = [ 'max_tokens' => $max_tokens ];
@@ -188,9 +188,10 @@
188 188 $result['operations'] = self::fill_default_photos( $result['operations'], $photo_url );
189 189
190 190 // Same reasoning as photos, sharper consequence: a model cannot know a real
191 191 // video address either, and an invented one resolves to a stranger's video.
192 - $result['operations'] = self::guard_video_urls( $result['operations'], $video_urls );
192 + // The video guard ships with Pro, on `better_payment/ai/generated_operations`.
193 + $result['operations'] = self::guard_generated_operations( $result['operations'], $video_urls );
193 194
194 195 // The amount tiers and the button that submits them are one action: the
195 196 // button must sit directly under the Donate Amount block. Structural, so
196 197 // the prompt alone cannot guarantee it — this is the part that holds.
@@ -205,12 +206,12 @@
205 206 // generated element never ships missing a default the way a hand-dropped
206 207 // one never does. The model routinely omits keys, or emits a blank
207 208 // ("" / [] / null) for a value it has no real answer for — and a blank
208 209 // must fall back to the default, not overwrite it. The visible casualty is
209 - // the Video widget: guard_video_urls() drops an invented url and the model
210 + // the Video widget: Pro's video guard drops an invented url and the model
210 211 // often sends an empty one, which then clobbered the default sample video
211 212 // and rendered an empty embed. This is general — every widget, every
212 - // default. Runs AFTER guard_video_urls() (so an invented/empty url is gone
213 + // default. Runs AFTER guard_generated_operations() (so an invented/empty url is gone
213 214 // and the default now fills it) and AFTER fill_default_photos() (so the
214 215 // forced, non-blank photo src is preserved).
215 216 $result['operations'] = self::fill_element_defaults( $result['operations'] );
216 217
@@ -278,18 +279,19 @@
278 279
279 280 /**
280 281 * Instruction naming the Pro widgets, appended only on an entitled install.
281 282 *
282 - * The blueprint's per-column `pro` hints say *where* these go; this says what
283 - * they are and when they earn their place, so the model treats them as
284 - * deliberate additions rather than boxes to fill. Without it a Pro install
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
285 287 * generated pages indistinguishable from a free one — the user paid for three
286 288 * widgets the assistant never reached for.
287 289 *
288 - * Two of the three carry a correctness constraint rather than a stylistic one,
289 - * repeated here because this is the text that actually invites their use:
290 - * Donors Wall renders live transaction data (writing donor names into a
291 - * fundraising page would be fabricating a record of who gave money), and Video
290 + * Better Payment Pro supplies the text (`AI\ProWidgetGuidance`), including the two
291 + * correctness constraints it must repeat because this is the text that invites
292 + * the widgets' use: Donors Wall renders live transaction data (writing donor names
293 + * into a fundraising page would fabricate a record of who gave money), and Video
292 294 * must not carry an invented URL.
293 295 */
294 296 private static function pro_elements_instruction(): string {
295 297 if ( ! CampaignSchema::pro_enabled() ) {
@@ -295,12 +297,42 @@
295 297 if ( ! CampaignSchema::pro_enabled() ) {
296 298 return '';
297 299 }
298 300
299 - return "\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' );
301 + /**
302 + * Filter the Pro-widget instruction appended to a generation brief on a Pro
303 + * install. Better Payment Pro supplies it; the value is the text appended to
304 + * the brief, including its leading blank line.
305 + */
306 + return (string) apply_filters( 'better_payment/ai/pro_generation_instruction', '' );
300 307 }
301 308
302 309 /**
310 + * The generation guards that only concern Pro widgets, for one attempt.
311 + *
312 + * Runs after fill_default_photos() and before donate-button placement, user
313 + * fields and element defaults, so a `url` dropped here lets the Video widget's
314 + * default fill in. Better Payment Pro guards the Video widget's URL through the
315 + * filter. A free install needs no guard: its `video` is a lock stub with no
316 + * settings keys, so the validator has already dropped any `url`.
317 + *
318 + * @param array $operations Validated operations.
319 + * @param array $brief_urls http(s) URLs found in the user's original brief.
320 + * @return array
321 + */
322 + public static function guard_generated_operations( array $operations, array $brief_urls = [] ): array {
323 + /**
324 + * Filter one generation attempt's operations before donate-button placement,
325 + * user-field enforcement and element defaults are applied. The second
326 + * argument is `[ 'brief_urls' => string[] ]`, the URLs in the user's original
327 + * brief — the only ones a generated element may keep.
328 + */
329 + $filtered = apply_filters( 'better_payment/ai/generated_operations', $operations, [ 'brief_urls' => $brief_urls ] );
330 +
331 + return is_array( $filtered ) ? $filtered : $operations;
332 + }
333 +
334 + /**
303 335 * Settle on the campaign's category: the one the user explicitly chose, else
304 336 * one inferred from the brief, else '' for a campaign that fits no category
305 337 * we know ("a new category" — it gets the default hero).
306 338 *
@@ -432,98 +464,8 @@
432 464 $urls[] = rtrim( (string) $url, '.,;:!?' );
433 465 }
434 466
435 467 return array_values( array_unique( array_filter( $urls ) ) );
436 - }
437 -
438 - /**
439 - * Drop a `video` element's `url` unless the user's brief actually contained it.
440 - *
441 - * The exact problem {@see self::fill_default_photos()} solves, with a worse
442 - * failure mode. A text model has no way to know a real video address, so asked
443 - * for a campaign video it produces a well-formed YouTube URL with an invented
444 - * ID — and that ID belongs to *something*. The result is an unrelated
445 - * stranger's video embedded on a fundraising page, presented as the campaign's
446 - * own appeal. That is materially worse than a broken image, because it looks
447 - * entirely intentional.
448 - *
449 - * A URL the user typed is theirs and is kept verbatim. Anything else is
450 - * removed, which lets the element's schema default apply — the same sample
451 - * video a user gets when they drop the Video widget by hand, so the outcome is
452 - * a widget in its ordinary unconfigured state rather than a false claim.
453 - *
454 - * Note this cannot be left to the prompt: `rules.php` already forbids inventing
455 - * values, and a prompt is a request. This is the part that holds.
456 - *
457 - * @param array $operations
458 - * @param array $allowed URLs found in the user's brief.
459 - * @return array
460 - */
461 - public static function guard_video_urls( array $operations, array $allowed = [] ): array {
462 - foreach ( $operations as &$op ) {
463 - if ( ! is_array( $op ) ) {
464 - continue;
465 - }
466 - $name = $op['op'] ?? '';
467 -
468 - if ( 'set_layout' === $name && ! empty( $op['columns'] ) && is_array( $op['columns'] ) ) {
469 - foreach ( $op['columns'] as &$col ) {
470 - if ( empty( $col['elements'] ) || ! is_array( $col['elements'] ) ) {
471 - continue;
472 - }
473 - foreach ( $col['elements'] as &$el ) {
474 - if ( is_array( $el ) && 'video' === ( $el['type'] ?? '' ) ) {
475 - $el['settings'] = self::guarded_video_settings( $el['settings'] ?? [], $allowed );
476 - }
477 - }
478 - unset( $el );
479 - }
480 - unset( $col );
481 - } elseif ( 'insert_block' === $name && 'video' === ( $op['type'] ?? '' ) ) {
482 - $op['settings'] = self::guarded_video_settings( $op['settings'] ?? [], $allowed );
483 - }
484 - }
485 - unset( $op );
486 -
487 - return $operations;
488 - }
489 -
490 - /**
491 - * Remove an unvouched `url` from a video element's settings.
492 - *
493 - * Matching is exact against the brief's URLs, plus a prefix match so a user's
494 - * link that the model reproduced with extra query parameters (`&t=30s`) still
495 - * counts as theirs.
496 - *
497 - * @param mixed $settings
498 - * @param array $allowed
499 - * @return array
500 - */
501 - private static function guarded_video_settings( $settings, array $allowed ): array {
502 - $settings = is_array( $settings ) ? $settings : [];
503 -
504 - if ( ! isset( $settings['url'] ) ) {
505 - return $settings;
506 - }
507 -
508 - $url = trim( (string) $settings['url'] );
509 - if ( '' === $url ) {
510 - return $settings;
511 - }
512 -
513 - foreach ( $allowed as $candidate ) {
514 - $candidate = trim( (string) $candidate );
515 - if ( '' === $candidate ) {
516 - continue;
517 - }
518 - if ( $url === $candidate || 0 === strpos( $url, $candidate ) ) {
519 - return $settings;
520 - }
521 - }
522 -
523 - unset( $settings['url'] );
524 -
525 - return $settings;
526 468 }
527 469
528 470 /**
529 471 * Merge each generated element's registry defaults UNDER its settings, so a