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