| @@ -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 |