| 1 |
<?php |
| 2 |
|
| 3 |
namespace Better_Payment\Lite\AI\Services; |
| 4 |
|
| 5 |
if ( ! defined( 'ABSPATH' ) ) { |
| 6 |
exit; |
| 7 |
} |
| 8 |
|
| 9 |
/** |
| 10 |
* Campaign analysis / critique. |
| 11 |
* |
| 12 |
* Runs {@see AIService} in 'analyze' mode. The model returns a natural-language |
| 13 |
* report plus optional one-click-fix operations, which the panel holds as |
| 14 |
* *suggestions* — nothing here is applied until the user accepts it. |
| 15 |
* |
| 16 |
* Two guarantees this class adds on top of the raw turn, because the prompt asks |
| 17 |
* for both but a prompt is not an enforcement layer: |
| 18 |
* |
| 19 |
* - **The review stays inside the campaign.** Operations that would introduce a |
| 20 |
* widget the campaign does not have (`insert_block`, `set_layout`) or that |
| 21 |
* target an element id not in the current layout are dropped. Reviewing a page |
| 22 |
* is not an opportunity to upsell widgets the owner never chose, and an Apply |
| 23 |
* button that adds a Donors Wall to a campaign with no Donors Wall fixes |
| 24 |
* nothing the user asked about. |
| 25 |
* - **The report reads as a document.** Fenced code blocks and a reply that is |
| 26 |
* nothing but a JSON dump are stripped from the prose; the model is told not to |
| 27 |
* emit them, and this catches the turn where it does anyway. |
| 28 |
*/ |
| 29 |
class CampaignAnalyzer { |
| 30 |
|
| 31 |
/** |
| 32 |
* Operations that can only ever introduce new structure. Analyze critiques |
| 33 |
* what exists, so none of them is ever in scope for this mode. |
| 34 |
* |
| 35 |
* @var array<int, string> |
| 36 |
*/ |
| 37 |
private static $structural_ops = [ 'insert_block', 'set_layout' ]; |
| 38 |
|
| 39 |
/** |
| 40 |
* @param array $context [ 'layout' => [...], 'meta' => [...] ] |
| 41 |
* @return array|\WP_Error { assistant_message, operations, usage } |
| 42 |
*/ |
| 43 |
public static function analyze( array $context = [] ) { |
| 44 |
$instruction = __( 'Analyze this campaign and suggest concrete, high-impact improvements.', 'better-payment' ); |
| 45 |
|
| 46 |
$result = AIService::run( 'analyze', $instruction, $context ); |
| 47 |
|
| 48 |
if ( is_wp_error( $result ) ) { |
| 49 |
return $result; |
| 50 |
} |
| 51 |
|
| 52 |
$result['operations'] = self::scope_to_campaign( (array) ( $result['operations'] ?? [] ), $context ); |
| 53 |
$result['assistant_message'] = self::clean_report( (string) ( $result['assistant_message'] ?? '' ) ); |
| 54 |
|
| 55 |
return $result; |
| 56 |
} |
| 57 |
|
| 58 |
/** |
| 59 |
* Drop suggestions that are not about a widget this campaign contains. |
| 60 |
* |
| 61 |
* @param array $operations Validated operations from the turn. |
| 62 |
* @param array $context [ 'layout' => [...], 'meta' => [...] ] |
| 63 |
* @return array<int, array> |
| 64 |
*/ |
| 65 |
private static function scope_to_campaign( array $operations, array $context ): array { |
| 66 |
$known = self::element_ids( $context ); |
| 67 |
|
| 68 |
$kept = []; |
| 69 |
foreach ( $operations as $operation ) { |
| 70 |
if ( ! is_array( $operation ) ) { |
| 71 |
continue; |
| 72 |
} |
| 73 |
|
| 74 |
$name = isset( $operation['op'] ) ? (string) $operation['op'] : ''; |
| 75 |
|
| 76 |
if ( in_array( $name, self::$structural_ops, true ) ) { |
| 77 |
continue; |
| 78 |
} |
| 79 |
|
| 80 |
// Anything naming an element must name one that is actually there. |
| 81 |
if ( isset( $operation['element_id'] ) && ! in_array( (string) $operation['element_id'], $known, true ) ) { |
| 82 |
continue; |
| 83 |
} |
| 84 |
|
| 85 |
$kept[] = $operation; |
| 86 |
} |
| 87 |
|
| 88 |
/** |
| 89 |
* Filter the operations an analysis turn is allowed to suggest. |
| 90 |
* |
| 91 |
* @param array $kept Operations surviving the in-campaign scope check. |
| 92 |
* @param array $operations The full set the model returned. |
| 93 |
* @param array $context Current campaign state. |
| 94 |
*/ |
| 95 |
return apply_filters( 'better_payment/ai/analysis_operations', $kept, $operations, $context ); |
| 96 |
} |
| 97 |
|
| 98 |
/** |
| 99 |
* Every element id present in the campaign right now. |
| 100 |
* |
| 101 |
* @param array $context |
| 102 |
* @return array<int, string> |
| 103 |
*/ |
| 104 |
private static function element_ids( array $context ): array { |
| 105 |
$ids = []; |
| 106 |
$layout = is_array( $context['layout'] ?? null ) ? $context['layout'] : []; |
| 107 |
$columns = (array) ( $layout['columns'] ?? [] ); |
| 108 |
|
| 109 |
foreach ( $columns as $column ) { |
| 110 |
if ( ! is_array( $column ) ) { |
| 111 |
continue; |
| 112 |
} |
| 113 |
foreach ( (array) ( $column['elements'] ?? [] ) as $element ) { |
| 114 |
if ( is_array( $element ) && ! empty( $element['id'] ) ) { |
| 115 |
$ids[] = (string) $element['id']; |
| 116 |
} |
| 117 |
} |
| 118 |
} |
| 119 |
|
| 120 |
return $ids; |
| 121 |
} |
| 122 |
|
| 123 |
/** |
| 124 |
* Strip machine output from a report meant for a human. |
| 125 |
* |
| 126 |
* Fenced blocks and a reply that is *entirely* a JSON object are the two |
| 127 |
* shapes that have turned up in place of prose. A brace mid-sentence is left |
| 128 |
* alone — this removes structures, it does not rewrite writing. |
| 129 |
* |
| 130 |
* @param string $text |
| 131 |
* @return string |
| 132 |
*/ |
| 133 |
private static function clean_report( string $text ): string { |
| 134 |
// Fenced code blocks, including an unterminated trailing one. |
| 135 |
$stripped = preg_replace( '/```[a-z]*\s*\n.*?(?:```|\z)/is', '', $text ); |
| 136 |
$trimmed = trim( is_string( $stripped ) ? $stripped : $text ); |
| 137 |
|
| 138 |
// A reply that is nothing but a JSON/array dump carries no report at all. |
| 139 |
if ( '' !== $trimmed && preg_match( '/^[\{\[].*[\}\]]$/s', $trimmed ) ) { |
| 140 |
return __( 'I reviewed the campaign. Apply any suggestion below to make the change.', 'better-payment' ); |
| 141 |
} |
| 142 |
|
| 143 |
// Collapse the blank lines the stripped fences left behind. |
| 144 |
$collapsed = preg_replace( "/\n{3,}/", "\n\n", $trimmed ); |
| 145 |
|
| 146 |
return is_string( $collapsed ) ? trim( $collapsed ) : $trimmed; |
| 147 |
} |
| 148 |
} |
| 149 |
|