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 / AIService.php

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

296 lines 13.3 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\Operations\OperationRegistry;
7 use Better_Payment\Lite\AI\Operations\OperationValidator;
8 use Better_Payment\Lite\AI\Prompt\PromptBuilder;
9 use Better_Payment\Lite\AI\Schema\CampaignSchema;
10 use Better_Payment\Lite\Campaign\Elements\ElementRegistry;
11
12 if ( ! defined( 'ABSPATH' ) ) {
13 exit;
14 }
15
16 /**
17 * Orchestrates a single AI turn end-to-end.
18 *
19 * build prompt → active provider->chat (with operation tools) → extract
20 * candidate operations → validate/sanitise against the schema → return a clean
21 * result the REST layer can hand to the client.
22 *
23 * Provider-agnostic: it only ever talks to {@see AIManager::active_provider()}.
24 *
25 * @see PromptBuilder
26 * @see ResponseValidator
27 * @see OperationValidator
28 */
29 class AIService {
30
31 /**
32 * Modes that produce prose, not operations. They attach no operation tools,
33 * never extract or force operations, and return an empty operations array —
34 * the caller reads `assistant_message` as the whole result. 'brief' is the
35 * wizard's pre-generation brief writer; 'analyze' still attaches tools because
36 * it offers one-click-fix suggestions alongside its report.
37 *
38 * @var array<int, string>
39 */
40 private static $text_only_modes = [ 'brief' ];
41
42 /**
43 * Run one AI turn.
44 *
45 * @param string $mode 'generate' | 'edit' | 'analyze' | 'brief'
46 * @param string $message The user instruction.
47 * @param array $context [ 'layout' => [...], 'meta' => [...] ] trusted current state.
48 * @param array $history Prior turns [ [ 'role' => ..., 'content' => ... ], ... ].
49 * @param array $options Per-call provider options (e.g. a larger max_tokens).
50 * @param array $target Optional single-widget focus: [ 'id', 'type', 'settings' ].
51 * When set it both narrows the prompt and scopes the
52 * returned operations to that element id.
53 * @return array|\WP_Error {
54 * @type string $assistant_message
55 * @type array $operations Validated, sanitised operations.
56 * @type array $usage
57 * }
58 */
59 public static function run( string $mode, string $message, array $context = [], array $history = [], array $options = [], array $target = [] ) {
60 $provider = AIManager::active_provider();
61 if ( null === $provider ) {
62 return new \WP_Error( 'ai_no_provider', __( 'No AI provider is configured.', 'better-payment' ), [ 'status' => 400 ] );
63 }
64 if ( ! $provider->is_configured() ) {
65 return new \WP_Error( 'ai_not_configured', __( 'The selected AI provider is missing its API key. Add it under Better Payment → Settings → AI.', 'better-payment' ), [ 'status' => 400 ] );
66 }
67
68 $text_only = in_array( $mode, self::$text_only_modes, true );
69
70 $system = PromptBuilder::system_prompt( $mode, AIManager::system_prompt(), $target );
71 $messages = self::build_messages( $history, PromptBuilder::user_message( $message, $context ) );
72
73 // Per-call options (e.g. a larger max_tokens for full-layout generation)
74 // override the provider defaults; the system prompt is always set here.
75 // Operation tools are attached for every mode EXCEPT text-only ones — a
76 // brief writer has nothing to call, and offering it the tools only invites
77 // it to (which then goes nowhere).
78 $chat_options = array_merge( $options, [ 'system' => $system ] );
79 if ( ! $text_only ) {
80 $chat_options['tools'] = OperationRegistry::as_tools();
81 }
82
83 $result = $provider->chat( $messages, $chat_options );
84
85 if ( ! empty( $result['error'] ) ) {
86 return new \WP_Error( 'ai_provider_error', (string) $result['error'], [ 'status' => 502 ] );
87 }
88
89 // A targeted turn may only touch the element it was pointed at. The prompt
90 // says so too, but the prompt is a request; this is the guarantee.
91 $scope = self::scope_for( $target );
92
93 // A text-only mode never carries operations — skip extraction entirely so
94 // a stray tool call the model made anyway cannot become an applied edit.
95 $operations = $text_only ? [] : self::extract_operations( $result, $context, $scope );
96
97 // Robustness: sometimes the model *describes* the change ("I will update the
98 // button label…") but never calls the tool, so nothing is applied. When we
99 // got no operations but the reply reads like an intended edit, retry once
100 // forcing a tool call so the change actually happens.
101 //
102 // Never in 'analyze' mode. A critique that has no one-click fix to offer is
103 // a perfectly good critique; forcing a tool call there manufactures an edit
104 // the user never asked for out of a sentence like "the story could be
105 // stronger", and hands them an Apply button for it. Never in a text-only
106 // mode either — there is no operation it could be trying to make.
107 if ( ! $text_only && 'analyze' !== $mode && empty( $operations ) && self::narrates_change( (string) ( $result['text'] ?? '' ) ) ) {
108 $forced = $provider->chat( $messages, array_merge( $chat_options, [ 'tool_choice' => 'required' ] ) );
109 if ( empty( $forced['error'] ) ) {
110 $forced_ops = self::extract_operations( $forced, $context, $scope );
111 if ( ! empty( $forced_ops ) ) {
112 $result = $forced;
113 $operations = $forced_ops;
114 }
115 }
116 }
117
118 $assistant_message = trim( (string) ( $result['text'] ?? '' ) );
119 if ( '' === $assistant_message ) {
120 $assistant_message = self::fallback_message( $mode, $operations );
121 }
122
123 // A free install still gets the exact Pro widget it asked for — inserted as
124 // a locked preview (see OperationValidator::validate_insert_block). Whatever
125 // the model wrote, append a plain note that the widget is Pro-only so the
126 // user is never surprised it is inactive on their live page. This is
127 // enforcement, not a request: it does not depend on the model choosing to
128 // mention the restriction. No-op when Pro is active or nothing was locked.
129 $notice = self::pro_locked_notice(
130 CampaignSchema::locked_pro_types_in_operations( $operations, $context )
131 );
132 if ( '' !== $notice ) {
133 $assistant_message .= "\n\n" . $notice;
134 }
135
136 return [
137 'assistant_message' => $assistant_message,
138 'operations' => $operations,
139 'usage' => $result['usage'] ?? [],
140 ];
141 }
142
143 /**
144 * The user-facing note appended when a free install just gained one or more
145 * locked Pro widgets. Empty for an empty type list.
146 *
147 * Written as the small Markdown subset the assistant bubble renders
148 * ({@see ai/reportFormat.js}) — a bold lead line, then plain prose. It names
149 * the widgets by their human label, not their type key.
150 *
151 * @param array<int, string> $types Pro element type keys (see
152 * {@see CampaignSchema::locked_pro_types_in_operations()}).
153 * @return string
154 */
155 public static function pro_locked_notice( array $types ): string {
156 $labels = [];
157 foreach ( $types as $type ) {
158 if ( '' === $type ) {
159 continue;
160 }
161 $schema = ElementRegistry::get( $type );
162 $labels[] = is_array( $schema ) && ! empty( $schema['label'] ) ? (string) $schema['label'] : $type;
163 }
164 $labels = array_values( array_unique( $labels ) );
165
166 if ( empty( $labels ) ) {
167 return '';
168 }
169
170 $names = self::join_labels( $labels );
171
172 if ( count( $labels ) > 1 ) {
173 return sprintf(
174 /* translators: %s: comma-separated Pro widget names (e.g. "FAQ, Video and Donors Wall"). */
175 __( '**Heads up — %s are Better Payment Pro widgets.** I added them to your campaign as you asked, but on the free plugin they show as a locked preview and stay inactive on your live page. Upgrade to Better Payment Pro to switch them on.', 'better-payment' ),
176 $names
177 );
178 }
179
180 return sprintf(
181 /* translators: %s: a single Pro widget name (e.g. "FAQ"). */
182 __( '**Heads up — %s is a Better Payment Pro widget.** I added it to your campaign as you asked, but on the free plugin it shows as a locked preview and stays inactive on your live page. Upgrade to Better Payment Pro to switch it on.', 'better-payment' ),
183 $names
184 );
185 }
186
187 /**
188 * Join widget labels into a readable list: "A", "A and B", "A, B and C".
189 *
190 * @param array<int, string> $labels
191 * @return string
192 */
193 private static function join_labels( array $labels ): string {
194 $count = count( $labels );
195 if ( 0 === $count ) {
196 return '';
197 }
198 if ( 1 === $count ) {
199 return $labels[0];
200 }
201 $last = array_pop( $labels );
202 return implode( ', ', $labels ) . ' ' . __( 'and', 'better-payment' ) . ' ' . $last;
203 }
204
205 /**
206 * Text to show when the model returned tool calls but no prose.
207 *
208 * This has to know the mode. 'analyze' does not apply anything — its
209 * operations are held as suggestions the user accepts one at a time — so the
210 * edit-mode wording ("Done — I applied the changes.") rendered directly above
211 * a column of un-clicked **Apply** buttons, telling the user the work was
212 * finished when nothing had happened at all.
213 *
214 * @param string $mode 'generate' | 'edit' | 'analyze' | 'brief'
215 * @param array $operations Validated operations for this turn.
216 * @return string
217 */
218 private static function fallback_message( string $mode, array $operations ): string {
219 // A brief writer that returned no prose produced nothing usable — there is
220 // no sensible stand-in sentence to invent, and inventing one would land in
221 // the user's editable brief box as if it were the improved brief. Leave it
222 // empty so BriefWriter can detect the miss and keep the current brief.
223 if ( 'brief' === $mode ) {
224 return '';
225 }
226
227 if ( 'analyze' === $mode ) {
228 return empty( $operations )
229 ? __( 'I reviewed the campaign and found nothing that needs changing.', 'better-payment' )
230 : __( 'Here is what I would improve. Apply any suggestion below to make the change.', 'better-payment' );
231 }
232
233 return empty( $operations )
234 ? __( 'I could not complete that request. Please try rephrasing.', 'better-payment' )
235 : __( 'Done — I applied the changes.', 'better-payment' );
236 }
237
238 /**
239 * Extract + validate operations from a provider chat result.
240 *
241 * @param array $result
242 * @param array $context
243 * @param array $scope Optional [ 'element_id' => string ] restriction.
244 * @return array<int, array>
245 */
246 private static function extract_operations( array $result, array $context, array $scope = [] ): array {
247 $candidates = ResponseValidator::extract_operations( $result );
248 return OperationValidator::validate_batch( $candidates, $context, $scope );
249 }
250
251 /**
252 * Turn a target descriptor into a validator scope.
253 *
254 * Kept separate so an empty/malformed target degrades to "no scope" — an
255 * unscoped edit — rather than to a scope on the empty string, which would
256 * match no element and silently discard every operation the model returned.
257 *
258 * @param array $target
259 * @return array{element_id?: string}
260 */
261 private static function scope_for( array $target ): array {
262 $id = isset( $target['id'] ) ? trim( (string) $target['id'] ) : '';
263 return '' === $id ? [] : [ 'element_id' => $id ];
264 }
265
266 /**
267 * Whether the assistant text reads like it intended to make a change but
268 * (apparently) narrated it instead of calling a tool. Used to decide whether
269 * to retry with a forced tool call.
270 */
271 private static function narrates_change( string $text ): bool {
272 if ( '' === trim( $text ) ) {
273 return false;
274 }
275 // A first-person "I'm about to do it" phrase — NOT a question like
276 // "what would you like to change?" (which must not trigger a forced retry).
277 $intent = preg_match( "/\b(i\s*(?:will|'ll|am going to|'m going to)|let'?s|let me|here'?s|here is)\b/i", $text );
278 // … paired with a change verb.
279 $change = preg_match( '/\b(chang|updat|set|add|remov|delet|rewrit|improv|mov|replac|adjust|make it|swap|revis)/i', $text );
280 return (bool) $intent && (bool) $change;
281 }
282
283 /**
284 * Assemble the messages array from prior history + the new user message.
285 *
286 * @param array $history
287 * @param string $user_message
288 * @return array<int, array{role: string, content: string}>
289 */
290 private static function build_messages( array $history, string $user_message ): array {
291 $messages = ConversationManager::sanitize( $history );
292 $messages[] = [ 'role' => 'user', 'content' => $user_message ];
293 return $messages;
294 }
295 }
296