PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.3.1
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.3.1
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 1.0.5 1.0.6 All 66 releases
better-payment / includes / AI / Services / CampaignAnalyzer.php

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

149 lines 5.5 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 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