PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.3.3
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.3.3
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 / Prompt / PromptBuilder.php

PromptBuilder.php in Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More 2.3.3, at includes/AI/Prompt/PromptBuilder.php

496 lines 22.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\Prompt;
4
5 use Better_Payment\Lite\Admin\DB;
6 use Better_Payment\Lite\AI\AIManager;
7 use Better_Payment\Lite\AI\Operations\OperationRegistry;
8 use Better_Payment\Lite\AI\Schema\CampaignSchema;
9
10 if ( ! defined( 'ABSPATH' ) ) {
11 exit;
12 }
13
14 /**
15 * Builds the structured prompts sent to the model.
16 *
17 * The model receives (a) a system prompt describing its role, the campaign
18 * schema, and the operation catalog, and (b) a user message carrying a compact
19 * snapshot of the current campaign plus the user's instruction. Only what the
20 * model needs is sent — never rendered HTML — keeping prompts small.
21 *
22 * Reusable templates live in Prompt/Templates/ and are loaded by name.
23 *
24 * @see CampaignSchema::for_prompt()
25 * @see OperationRegistry::as_tools()
26 */
27 class PromptBuilder {
28
29 /**
30 * Build the system prompt for a given mode.
31 *
32 * @param string $mode 'generate' | 'edit' | 'analyze' | 'brief'
33 * @param string $custom Optional admin-configured system prompt appended at the end.
34 * @param array $target Optional focused element: [ 'id' => ..., 'type' => ..., 'settings' => [...] ].
35 * When given, a "Target element" section pins the turn to that one widget.
36 * @return string
37 */
38 public static function system_prompt( string $mode = 'edit', string $custom = '', array $target = [] ): string {
39 // The brief writer produces prose, not operations. It has no use for the
40 // element schema, the operation catalog or the operation-output rules —
41 // handing it every widget schema would only tempt it to describe a
42 // layout when its whole job is to sharpen a paragraph. Keep its prompt to
43 // the template plus the site facts (currency, so it never writes a symbol).
44 if ( 'brief' === $mode ) {
45 $parts = [];
46 $parts[] = self::load_template( 'brief' );
47 $parts[] = "## Site settings\n" . self::describe_site();
48 if ( '' !== trim( $custom ) ) {
49 $parts[] = "## Additional instructions\n" . trim( $custom );
50 }
51 /** This filter is documented above where the main prompt returns. */
52 return apply_filters( 'better_payment/ai/system_prompt', implode( "\n\n", array_filter( $parts ) ), $mode );
53 }
54
55 $schema = CampaignSchema::for_prompt();
56
57 $parts = [];
58 $parts[] = self::load_template( $mode );
59 $parts[] = "## Site settings\n" . self::describe_site();
60 $parts[] = "## Campaign schema you may produce\n" . self::describe_schema( $schema );
61
62 $pro = self::describe_pro_widgets( $mode );
63 if ( '' !== $pro ) {
64 $parts[] = "## Pro widgets\n" . $pro;
65 }
66
67 $parts[] = "## Operations you may call\n" . self::describe_operations();
68
69 $images = self::describe_image_support( $mode );
70 if ( '' !== $images ) {
71 $parts[] = "## Images\n" . $images;
72 }
73
74 $parts[] = self::load_template( 'rules' );
75
76 $focus = self::describe_target( $target );
77 if ( '' !== $focus ) {
78 $parts[] = "## Target element — this turn edits ONLY this widget\n" . $focus;
79 }
80
81 if ( '' !== trim( $custom ) ) {
82 $parts[] = "## Additional instructions\n" . trim( $custom );
83 }
84
85 /**
86 * Filter the assembled AI system prompt.
87 *
88 * @param string $prompt
89 * @param string $mode
90 */
91 return apply_filters( 'better_payment/ai/system_prompt', implode( "\n\n", array_filter( $parts ) ), $mode );
92 }
93
94 /**
95 * Site facts the model would otherwise guess at.
96 *
97 * Currency is here because a model with no stated currency writes `$` — USD is
98 * what it has seen most. On a EUR site the renderer then prints the real `€`
99 * beside the model's invented `$`, and the campaign shows two currencies at
100 * once. Telling it the answer is cheaper than detecting the symptom.
101 *
102 * Note this is context, not permission: `rules.php` still forbids writing a
103 * currency symbol at all, because the renderer owns formatting. This exists so
104 * the model can *reason* about amounts (a "€10,000 goal" reads differently from
105 * a "$10,000" one), not so it can format them.
106 *
107 * @return string
108 */
109 private static function describe_site(): string {
110 $currency = DB::get_settings( 'better_payment_settings_general_general_currency' );
111
112 if ( ! is_string( $currency ) || '' === $currency ) {
113 $currency = 'USD';
114 }
115
116 return "- Campaign currency: {$currency}. The renderer applies this itself — never write a"
117 . ' currency symbol or an amount into any text or label setting.';
118 }
119
120 /**
121 * State plainly that the three Pro widgets are live on this install.
122 *
123 * The whole-page schema already lists them once Pro is active (they become
124 * offerable), but a bare type key in a long list reads as one more option.
125 * This says they are fully supported, so a request like
126 * "answer the questions donors keep asking" reaches `faq` instead of being
127 * answered with another `campaign_description` paragraph.
128 *
129 * Never in 'analyze' mode. An analysis is a review of the campaign the owner
130 * built, and `CampaignAnalyzer::scope_to_campaign()` drops `insert_block` and
131 * `set_layout` outright — so a Pro section there could only produce report
132 * prose recommending widgets the report is forbidden to add. Same reason that
133 * method exists: a review is not an upsell.
134 *
135 * On a FREE install the same section flips purpose: it names the three Pro
136 * widgets — which are deliberately absent from the schema above — so the model
137 * can honour an explicit request for one ("add an FAQ") by inserting that exact
138 * widget instead of quietly substituting a free one. It stays out of 'analyze'
139 * for the same reason the Pro-active copy does: a review is not an upsell.
140 *
141 * @param string $mode 'generate' | 'edit' | 'analyze'
142 * @return string Empty in 'analyze' mode, or when no Pro widgets are registered.
143 */
144 private static function describe_pro_widgets( string $mode ): string {
145 if ( 'analyze' === $mode ) {
146 return '';
147 }
148
149 if ( ! CampaignSchema::pro_enabled() ) {
150 return self::describe_locked_pro_widgets();
151 }
152
153 $types = array_values(
154 array_filter(
155 CampaignSchema::offerable_element_types(),
156 [ CampaignSchema::class, 'is_pro_element_type' ]
157 )
158 );
159
160 if ( empty( $types ) ) {
161 return '';
162 }
163
164 return 'Better Payment Pro is active on this site, so these widgets are fully available'
165 . ' and fully supported — treat them exactly like the free ones: '
166 . implode( ', ', $types ) . '.'
167 . "\nUse one when it genuinely does a job no free widget does, not to fill space."
168 . ' Their settings and content rules are listed with every other element above,'
169 . ' and they must be followed just as strictly.';
170 }
171
172 /**
173 * Free-install variant: name the locked Pro widgets and the one thing to do
174 * with them.
175 *
176 * They are absent from the schema on purpose, so the model does not reach for
177 * them unprompted (the free page has no renderer for them). But a user who asks
178 * for one by name must get that widget, not a free stand-in — the plugin shows
179 * it as a locked preview and the assistant explains the restriction. This
180 * carves the narrow exception to the "only use the listed element types" rule.
181 *
182 * @return string Empty when no Pro widgets are registered.
183 */
184 private static function describe_locked_pro_widgets(): string {
185 $types = CampaignSchema::pro_element_types();
186 if ( empty( $types ) ) {
187 return '';
188 }
189
190 $keys = implode( ', ', $types );
191
192 return 'Better Payment Pro is NOT active on this site. `' . $keys . '` are locked Pro'
193 . ' widgets: they are intentionally absent from the element list above, and you must NOT'
194 . ' add one on your own initiative.'
195 . "\nThe ONE exception: when the user EXPLICITLY asks for one of them by name (\"add an"
196 . ' FAQ\", \"put a video here\", \"show a donors wall\"), you MUST honour it:'
197 . "\n1. Insert that exact widget using its own type key (" . $keys . ') — via insert_block,'
198 . ' or as an element inside set_layout. Never substitute a free widget for it, and never'
199 . ' silently skip it.'
200 . "\n2. Do not write any of its content settings; a locked widget only ever shows sample"
201 . ' content.'
202 . "\n3. In your natural-language reply, tell the user it is a Pro-only feature that will"
203 . ' appear as a locked preview and needs a Better Payment Pro upgrade to go live.';
204 }
205
206 /**
207 * How the model should handle a request for a picture — gated on the active
208 * provider's image capability.
209 *
210 * The `generate_image` operation is always in the catalog, but nothing told
211 * the model to *reach for it* when a user asks for one. So "regenerate the
212 * campaign with an AI-generated image" was answered with a `photo` element
213 * whose `src` the model invented or left blank: generation forces the default
214 * hero over it, but the edit path deliberately does not (it must preserve the
215 * user's real uploads), so the blank `src` survived and rendered as a broken
216 * image. This section names the tool and the exact request words that should
217 * trigger it, and how to target it without duplicating the photo.
218 *
219 * Capability-aware like {@see self::describe_pro_widgets()}: only an
220 * image-capable, configured provider (OpenAI / Gemini) can fulfil
221 * `generate_image`, so on a text-only provider we tell the model it cannot —
222 * and to say so — rather than have it emit an op that 502s.
223 *
224 * Never in 'analyze' mode: an analysis applies nothing and
225 * {@see CampaignAnalyzer::scope_to_campaign()} drops `generate_image` /
226 * `insert_block` anyway, so promising image creation there would only mislead.
227 *
228 * @param string $mode 'generate' | 'edit' | 'analyze'
229 * @return string
230 */
231 private static function describe_image_support( string $mode ): string {
232 if ( 'analyze' === $mode ) {
233 return '';
234 }
235
236 $provider = AIManager::active_provider();
237 $can_generate = null !== $provider && $provider->supports_images() && $provider->is_configured();
238
239 if ( $can_generate ) {
240 return 'You CAN create real images with the `generate_image` operation.'
241 . "\n- When the user asks you to create, generate, add, or replace an image or photo"
242 . ' — e.g. "add an AI-generated image", "regenerate with a new hero photo" — you MUST'
243 . ' call `generate_image` with a vivid `prompt` describing the picture. Only that'
244 . ' operation produces a real image; a `photo` element cannot, because you have no'
245 . ' genuine image URL to give it.'
246 . "\n- To change the picture in an existing photo element, pass its `element_id`. To"
247 . ' add a new image, pass the `column_id` it belongs in — and do NOT also add a'
248 . ' separate `photo` element for that same spot, or the campaign ends up with two.'
249 . "\n- Never invent an image URL, and never leave a `photo` element with an empty or"
250 . ' placeholder `src`; use `generate_image` for the picture instead.';
251 }
252
253 return 'You CANNOT generate images: the active AI provider does not support image generation'
254 . ' (or is missing its API key). If the user asks you to create or add an image, do NOT'
255 . ' add a `photo` element with an invented or empty `src` — it renders as a broken image.'
256 . ' Instead, say plainly that image generation needs an image-capable provider (OpenAI or'
257 . ' Gemini) selected under Settings → AI, and make no image change this turn.';
258 }
259
260 /**
261 * Describe the one widget this turn is allowed to touch.
262 *
263 * The builder's per-element "AI quick edit" buttons used to convey their target
264 * as nothing but a sentence in the user message ("… Target element id: …"), on
265 * top of a system prompt describing every element type equally. Asked to
266 * shorten one headline the model was told the schema of the whole page and
267 * given no reason to believe the request was local — so it answered with copy
268 * for whichever widget it found most interesting, and, because nothing scoped
269 * the result, those edits were applied.
270 *
271 * This section names the widget, its purpose, the exact fields it has, and what
272 * is currently in them. The prompt is the half that makes the output *relevant*;
273 * {@see \Better_Payment\Lite\AI\Operations\OperationValidator::validate_batch()}
274 * with a scope is the half that makes it *safe*. Neither replaces the other.
275 *
276 * @param array $target [ 'id' => string, 'type' => string, 'settings' => array ]
277 * @return string Empty when no usable target was given.
278 */
279 private static function describe_target( array $target ): string {
280 $id = isset( $target['id'] ) ? (string) $target['id'] : '';
281 $type = isset( $target['type'] ) ? (string) $target['type'] : '';
282
283 if ( '' === $id || '' === $type || ! CampaignSchema::is_element_type( $type ) ) {
284 return '';
285 }
286
287 $described = CampaignSchema::describe_element( $type );
288 $settings = isset( $target['settings'] ) && is_array( $target['settings'] ) ? $target['settings'] : [];
289
290 // Only the keys this element actually accepts, so "current values" cannot
291 // advertise a stale key the user could then ask the model to edit.
292 $current = [];
293 foreach ( $settings as $key => $value ) {
294 if ( CampaignSchema::is_allowed_settings_key( $type, (string) $key ) ) {
295 $current[ $key ] = $value;
296 }
297 }
298 $current_json = wp_json_encode( self::trim_settings( $current ), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );
299
300 $lines = [];
301 $lines[] = sprintf( '- Widget: %s (`%s`)', $described['label'], $type );
302 $lines[] = sprintf( '- Element id: `%s` — use this exact value as `element_id`.', $id );
303
304 $guide = trim( (string) $described['guide'] );
305 if ( '' !== $guide ) {
306 $lines[] = '- What this widget\'s content is for: ' . $guide;
307 }
308
309 $lines[] = '- The only fields it has: ' . self::describe_setting_keys( $described['settings'] );
310 $lines[] = "- What is in them right now:\n```json\n" . $current_json . "\n```";
311 $lines[] = '';
312 $lines[] = 'For this turn:';
313 $lines[] = '1. The user\'s request is about THIS widget. Read it that way even if the wording is generic ("make it shorter" means this widget\'s copy).';
314 $lines[] = '2. Reply with exactly one `update_block` on `' . $id . '` (or `replace_image` if it is an image). Do NOT call `set_layout`, `insert_block`, `delete_block`, `move_block`, `update_meta`, `set_colors` or `set_donation_amounts` — every one of them is discarded on this turn, so a change you put there simply will not happen.';
315 $lines[] = '3. Send only the keys you are actually changing, and only keys from the list above. Anything else is dropped.';
316 $lines[] = '4. Write for what this widget does. If it has no free-text field worth rewriting, change the setting that genuinely serves the request rather than inventing prose for a field that is a toggle or a number.';
317
318 return implode( "\n", $lines );
319 }
320
321 /**
322 * Build the user message: instruction + compact campaign context.
323 *
324 * @param string $instruction
325 * @param array $context [ 'layout' => [...], 'meta' => [...] ]
326 * @return string
327 */
328 public static function user_message( string $instruction, array $context = [] ): string {
329 $snapshot = self::compact_context( $context );
330 $json = wp_json_encode( $snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );
331
332 return "Current campaign state:\n```json\n{$json}\n```\n\nUser request: " . trim( $instruction );
333 }
334
335 /**
336 * Reduce builder state to only what the model needs.
337 *
338 * @param array $context
339 * @return array
340 */
341 public static function compact_context( array $context ): array {
342 $meta = is_array( $context['meta'] ?? null ) ? $context['meta'] : [];
343 $layout = is_array( $context['layout'] ?? null ) ? $context['layout'] : [];
344
345 $columns = [];
346 foreach ( (array) ( $layout['columns'] ?? [] ) as $column ) {
347 if ( ! is_array( $column ) ) {
348 continue;
349 }
350 $elements = [];
351 foreach ( (array) ( $column['elements'] ?? [] ) as $element ) {
352 if ( ! is_array( $element ) ) {
353 continue;
354 }
355 $elements[] = [
356 'id' => $element['id'] ?? '',
357 'type' => $element['type'] ?? '',
358 'settings' => self::trim_settings( $element['settings'] ?? [] ),
359 ];
360 }
361 $columns[] = [
362 'id' => $column['id'] ?? '',
363 'width' => $column['width'] ?? '',
364 'elements' => $elements,
365 ];
366 }
367
368 // Only keep meta keys the AI can act on.
369 $kept_meta = [];
370 foreach ( CampaignSchema::writable_meta_keys() as $key ) {
371 if ( array_key_exists( $key, $meta ) ) {
372 $kept_meta[ $key ] = $meta[ $key ];
373 }
374 }
375
376 return [
377 'layout' => [
378 'layout' => $layout['layout'] ?? '1-column',
379 'columns' => $columns,
380 ],
381 'meta' => $kept_meta,
382 ];
383 }
384
385 /**
386 * Drop long/verbose setting values from the context to save tokens.
387 *
388 * @param mixed $settings
389 * @return array
390 */
391 private static function trim_settings( $settings ): array {
392 if ( ! is_array( $settings ) ) {
393 return [];
394 }
395 $out = [];
396 foreach ( $settings as $key => $value ) {
397 if ( is_string( $value ) && strlen( $value ) > 400 ) {
398 $out[ $key ] = mb_substr( $value, 0, 400 ) . '';
399 } else {
400 $out[ $key ] = $value;
401 }
402 }
403 return $out;
404 }
405
406 // ------------------------------------------------------------------ describe
407
408 /**
409 * @param array $schema
410 */
411 /**
412 * Serialise the schema, giving each element its key list AND a line saying
413 * what that element's content is for.
414 *
415 * The `Content:` line is not decoration. Without it the model sees `headline`
416 * on `progress_bar`, `campaign_summary`, `social_sharing` and `donors_wall`
417 * and writes one interchangeable sentence into all four, because a key name is
418 * not a brief. Each widget has a distinct job on the page and the copy has to
419 * reflect it — see {@see \Better_Payment\Lite\AI\Schema\ElementContentGuide}.
420 *
421 * @param array $schema
422 */
423 private static function describe_schema( array $schema ): string {
424 $lines = [];
425 $lines[] = 'Layout presets: ' . implode( ', ', $schema['layout_presets'] ) . '.';
426 $lines[] = 'Writable campaign meta keys: ' . implode( ', ', $schema['meta_keys'] ) . '.';
427 $lines[] = 'Element types, their settings, and what each one\'s content is for.';
428 $lines[] = 'Write content that fits the widget you are filling — these are different jobs on the page, not the same paragraph repeated:';
429 foreach ( $schema['elements'] as $element ) {
430 $lines[] = sprintf(
431 '- %s (%s) — settings: %s',
432 $element['type'],
433 (string) ( $element['label'] ?? $element['type'] ),
434 self::describe_setting_keys( (array) ( $element['settings'] ?? [] ) )
435 );
436
437 $guide = trim( (string) ( $element['guide'] ?? '' ) );
438 if ( '' !== $guide ) {
439 $lines[] = ' Content: ' . $guide;
440 }
441 }
442 return implode( "\n", $lines );
443 }
444
445 /**
446 * Render one element's settings map as `key (kind)` pairs.
447 *
448 * The control kind is not decoration either. `CampaignSchema::describe_element()`
449 * has always computed it and this method used to throw it away, emitting a bare
450 * key list — so `show_days`, `columns` and `headline` reached the model looking
451 * identical, and nothing in the prompt said that two of them are not places to
452 * write a sentence. Enumerated keys additionally list their accepted values,
453 * which is the only way the model can know `layout` takes `list|grid|ticker`
454 * rather than a description of a layout.
455 *
456 * @param array<string, array> $settings key => [ 'type' => ..., 'enum' => [...] ]
457 * @return string
458 */
459 private static function describe_setting_keys( array $settings ): string {
460 $keys = [];
461 foreach ( $settings as $key => $spec ) {
462 if ( ! empty( $spec['enum'] ) ) {
463 $keys[] = $key . ' (' . implode( '|', (array) $spec['enum'] ) . ')';
464 continue;
465 }
466
467 $keys[] = $key . ' (' . ( '' !== (string) ( $spec['type'] ?? '' ) ? (string) $spec['type'] : 'text' ) . ')';
468 }
469 return implode( ', ', $keys );
470 }
471
472 private static function describe_operations(): string {
473 $lines = [];
474 foreach ( OperationRegistry::get_all() as $name => $def ) {
475 $params = [];
476 foreach ( (array) ( $def['params'] ?? [] ) as $pkey => $pspec ) {
477 $params[] = $pkey . ( ! empty( $pspec['required'] ) ? '*' : '' );
478 }
479 $lines[] = sprintf( '- %s(%s) — %s', $name, implode( ', ', $params ), $def['summary'] ?? '' );
480 }
481 return implode( "\n", $lines ) . "\n(* = required. Call operations as tools; do not emit raw HTML.)";
482 }
483
484 /**
485 * Load a prompt template file by name from Prompt/Templates/.
486 */
487 private static function load_template( string $name ): string {
488 $file = __DIR__ . '/Templates/' . sanitize_file_name( $name ) . '.php';
489 if ( ! file_exists( $file ) ) {
490 return '';
491 }
492 $content = include $file;
493 return is_string( $content ) ? $content : '';
494 }
495 }
496