feat: Pipeline Settings M3 — assembled-instructions preview + prompt version history/diff (#173, v0.25.0)
Read-only assembled-instructions preview (last-run rendered prompt) with Template-vs-Preview guardrail; per-prompt version history + LCS diff. Stage-list-driven. QA APPROVE df92ed3; CI green. Agent-Session: pipeline-settings-m3-merge
This commit is contained in:
199
includes/admin/class-pipeline-preview-source.php
Normal file
199
includes/admin/class-pipeline-preview-source.php
Normal file
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Resolves the "assembled instructions" preview body for a prompt key.
|
||||
*
|
||||
* What: Two strategies, tried in order:
|
||||
*
|
||||
* 1. Last-run strategy: Queries generation_log for the most recent
|
||||
* successful row whose stage maps to the given prompt_key via
|
||||
* Stage_Display_Map. Decodes the stored request_json and extracts
|
||||
* the user-role message content (the fully-rendered, token-filled
|
||||
* instruction the LLM actually received). This is the preferred
|
||||
* source because it is factually what ran.
|
||||
*
|
||||
* 2. Sample render: Falls back to Prompt_Registry::render() with
|
||||
* placeholder token values taken from the registry def. Tokens are
|
||||
* replaced with "[sample: <token_name>]" labels so the admin can
|
||||
* see the template's token slots without live data. Labelled
|
||||
* "sample — no run found" in the UI.
|
||||
*
|
||||
* Neither path has a save route. All returned text is plain strings;
|
||||
* escaping is the caller's responsibility (Pipeline_Preview_Handler
|
||||
* calls esc_html before JSON-encoding).
|
||||
*
|
||||
* Stage-list-driven: Stage_Display_Map::all() iterates the full known
|
||||
* stage vocabulary; Phase 2b stages resolve automatically.
|
||||
*
|
||||
* Who calls it: PRAutoBlogger_Pipeline_Preview_Handler::handle().
|
||||
* Dependencies: WordPress $wpdb, PRAutoBlogger_Stage_Display_Map,
|
||||
* PRAutoBlogger_Prompt_Registry.
|
||||
*
|
||||
* @see ajax/class-pipeline-preview-handler.php -- Sole caller.
|
||||
* @see core/class-stage-display-map.php -- prompt_key -> stage map.
|
||||
* @see core/class-prompt-registry.php -- render() + default_body().
|
||||
* @see ARCHITECTURE.md -- generation_log schema.
|
||||
*/
|
||||
class PRAutoBlogger_Pipeline_Preview_Source {
|
||||
|
||||
/**
|
||||
* Try to extract the rendered prompt from the most recent successful
|
||||
* generation_log row that used the given prompt key.
|
||||
*
|
||||
* Returns null when no matching row exists or when the request_json
|
||||
* cannot be decoded into a usable messages array.
|
||||
*
|
||||
* @param string $prompt_key Registry key (e.g. 'analysis.system').
|
||||
* @return array{rendered: string, run_date: string, run_post: string}|null
|
||||
*/
|
||||
public static function last_run( string $prompt_key ): ?array {
|
||||
global $wpdb;
|
||||
if ( null === $wpdb ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stage = self::stage_for_key( $prompt_key );
|
||||
if ( null === $stage ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$row = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT request_json, created_at, post_id
|
||||
FROM {$table}
|
||||
WHERE stage = %s
|
||||
AND response_status = 'success'
|
||||
AND request_json IS NOT NULL
|
||||
ORDER BY id DESC
|
||||
LIMIT 1",
|
||||
$stage
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
if ( ! is_array( $row ) || empty( $row['request_json'] ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$body = json_decode( (string) $row['request_json'], true );
|
||||
if ( ! is_array( $body ) || empty( $body['messages'] ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rendered = self::extract_rendered_from_messages( (array) $body['messages'], $prompt_key );
|
||||
if ( '' === $rendered ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$run_post = '';
|
||||
if ( ! empty( $row['post_id'] ) ) {
|
||||
$post = get_post( (int) $row['post_id'] );
|
||||
if ( $post instanceof WP_Post ) {
|
||||
$run_post = $post->post_title;
|
||||
}
|
||||
}
|
||||
|
||||
return array(
|
||||
'rendered' => $rendered,
|
||||
'run_date' => (string) $row['created_at'],
|
||||
'run_post' => $run_post,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce a sample render of the active template with placeholder tokens.
|
||||
*
|
||||
* Token slots in the template ({{ token_name }}) are substituted with
|
||||
* "[sample: token_name]" so the admin can see every injection point
|
||||
* without needing live generation data.
|
||||
*
|
||||
* @param string $prompt_key Registry key.
|
||||
* @return string Rendered string (may be empty when key is unknown).
|
||||
*/
|
||||
public static function sample_render( string $prompt_key ): string {
|
||||
$active = PRAutoBlogger_Prompt_Registry::get_active( $prompt_key );
|
||||
$body = null !== $active
|
||||
? (string) $active['body']
|
||||
: (string) PRAutoBlogger_Prompt_Registry::default_body( $prompt_key );
|
||||
|
||||
if ( '' === $body ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Find all {{ token_name }} placeholders and substitute each with a
|
||||
// clearly labelled placeholder so the admin sees the injection map.
|
||||
$tokens = array();
|
||||
preg_match_all( '/\{\{\s*(\w+)\s*\}\}/', $body, $matches );
|
||||
if ( ! empty( $matches[1] ) ) {
|
||||
foreach ( array_unique( $matches[1] ) as $token ) {
|
||||
// translators: %s = token name, e.g. 'niche_description'.
|
||||
$tokens[ $token ] = sprintf( '[%s]', $token );
|
||||
}
|
||||
}
|
||||
|
||||
return PRAutoBlogger_Prompt_Registry::fill( $body, $tokens );
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a prompt registry key to its primary stage via Stage_Display_Map.
|
||||
*
|
||||
* Iterates all() so new Phase 2b stages are picked up automatically.
|
||||
*
|
||||
* @param string $prompt_key Registry key.
|
||||
* @return string|null Stage slug, or null when no stage owns this key.
|
||||
*/
|
||||
private static function stage_for_key( string $prompt_key ): ?string {
|
||||
foreach ( PRAutoBlogger_Stage_Display_Map::all() as $stage => $def ) {
|
||||
if ( isset( $def['prompt_key'] ) && $def['prompt_key'] === $prompt_key ) {
|
||||
return $stage;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the most informative rendered text from a messages array.
|
||||
*
|
||||
* For system-prompt keys (*.system) we return the system message content.
|
||||
* For user/agent-prompt keys we return the last user-role message content.
|
||||
* Falls back to the longest non-empty message when role detection fails.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $messages Decoded messages array.
|
||||
* @param string $key Prompt key hint.
|
||||
* @return string
|
||||
*/
|
||||
private static function extract_rendered_from_messages( array $messages, string $key ): string {
|
||||
$is_system_key = str_contains( $key, '.system' ) || str_ends_with( $key, '.rewriter_system' );
|
||||
|
||||
// Try role-based selection first.
|
||||
$target_role = $is_system_key ? 'system' : 'user';
|
||||
$last_match = '';
|
||||
foreach ( $messages as $msg ) {
|
||||
if ( is_array( $msg )
|
||||
&& isset( $msg['role'], $msg['content'] )
|
||||
&& $msg['role'] === $target_role
|
||||
&& is_string( $msg['content'] )
|
||||
&& '' !== $msg['content'] ) {
|
||||
$last_match = $msg['content'];
|
||||
}
|
||||
}
|
||||
if ( '' !== $last_match ) {
|
||||
return $last_match;
|
||||
}
|
||||
|
||||
// Fallback: return the longest non-empty message content.
|
||||
$longest = '';
|
||||
foreach ( $messages as $msg ) {
|
||||
if ( is_array( $msg ) && isset( $msg['content'] ) && is_string( $msg['content'] )
|
||||
&& strlen( $msg['content'] ) > strlen( $longest ) ) {
|
||||
$longest = $msg['content'];
|
||||
}
|
||||
}
|
||||
return $longest;
|
||||
}
|
||||
}
|
||||
@@ -9,19 +9,24 @@ declare(strict_types=1);
|
||||
* What: Adds a "Pipeline" submenu under the PRAutoBlogger parent menu so
|
||||
* operators can configure per-step model, system instructions, agent
|
||||
* prompts, and parameters for every LLM stage in one place. This
|
||||
* page is ADDITIVE in M1 — the existing Settings sections remain
|
||||
* page is ADDITIVE in M1 -- the existing Settings sections remain
|
||||
* unchanged and both surfaces edit the same wp_options / prompts
|
||||
* table. Decomposition of redundant tabs is M2.
|
||||
* M3 localizes preview / history / diff nonces so pipeline-settings.js
|
||||
* can make authenticated AJAX requests for the assembled-instructions
|
||||
* preview and per-prompt version history/diff.
|
||||
* Who calls it: PRAutoBlogger::register_admin_hooks() via add_action
|
||||
* ('admin_menu', ..., 13) and ('admin_enqueue_scripts', ...).
|
||||
* Dependencies: PRAutoBlogger_Pipeline_Settings_Save_Handler (save),
|
||||
* PRAutoBlogger_Pipeline_Settings_Renderer (render).
|
||||
*
|
||||
* @see admin/class-pipeline-settings-renderer.php — Step rail + panel HTML.
|
||||
* @see admin/class-pipeline-settings-save-handler.php — nonce + save logic.
|
||||
* @see admin/class-admin-page.php — Parent menu registration (priority 10).
|
||||
* @see ARCHITECTURE.md — Admin pages table.
|
||||
* @see INFORMATION-ARCHITECTURE.md — Admin page slug registry.
|
||||
* @see admin/class-pipeline-settings-renderer.php -- Step rail + panel HTML.
|
||||
* @see admin/class-pipeline-settings-save-handler.php -- nonce + save logic.
|
||||
* @see admin/class-admin-page.php -- Parent menu registration (priority 10).
|
||||
* @see ajax/class-pipeline-preview-handler.php -- Preview AJAX (M3).
|
||||
* @see ajax/class-pipeline-history-handler.php -- History/diff AJAX (M3).
|
||||
* @see ARCHITECTURE.md -- Admin pages table.
|
||||
* @see INFORMATION-ARCHITECTURE.md -- Admin page slug registry.
|
||||
*/
|
||||
class PRAutoBlogger_Pipeline_Settings_Page {
|
||||
|
||||
@@ -37,7 +42,7 @@ class PRAutoBlogger_Pipeline_Settings_Page {
|
||||
/**
|
||||
* Register the Pipeline submenu page under the PRAutoBlogger parent menu.
|
||||
*
|
||||
* Priority 13 — after board (11), dossier (12). The parent slug must be
|
||||
* Priority 13 -- after board (11), dossier (12). The parent slug must be
|
||||
* registered at priority 10 by PRAutoBlogger_Admin_Page::on_register_menu()
|
||||
* before any submenu can be attached.
|
||||
*
|
||||
@@ -57,8 +62,8 @@ class PRAutoBlogger_Pipeline_Settings_Page {
|
||||
/**
|
||||
* Enqueue CSS / JS required by the Pipeline Settings page.
|
||||
*
|
||||
* Reuses the existing admin + model-picker asset pairs; adds a small
|
||||
* pipeline-specific stylesheet and script.
|
||||
* M3 adds localized data for the assembled-instructions preview toggle
|
||||
* and the version history / diff panel (AJAX actions + nonces).
|
||||
*
|
||||
* @param string $hook_suffix Current admin page hook suffix.
|
||||
* @return void
|
||||
@@ -87,6 +92,21 @@ class PRAutoBlogger_Pipeline_Settings_Page {
|
||||
'imageModels' => PRAutoBlogger_Settings_Fields_Extended::get_image_models(),
|
||||
)
|
||||
);
|
||||
|
||||
// M3: localize preview + history + diff nonces for pipeline-settings.js.
|
||||
wp_localize_script(
|
||||
'prautoblogger-pipeline',
|
||||
'prabPipeline',
|
||||
array(
|
||||
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||
'previewAction' => PRAutoBlogger_Pipeline_Preview_Handler::ACTION,
|
||||
'previewNonce' => wp_create_nonce( PRAutoBlogger_Pipeline_Preview_Handler::ACTION ),
|
||||
'historyAction' => PRAutoBlogger_Pipeline_History_Handler::ACTION_HISTORY,
|
||||
'historyNonce' => wp_create_nonce( PRAutoBlogger_Pipeline_History_Handler::ACTION_HISTORY ),
|
||||
'diffAction' => PRAutoBlogger_Pipeline_History_Handler::ACTION_DIFF,
|
||||
'diffNonce' => wp_create_nonce( PRAutoBlogger_Pipeline_History_Handler::ACTION_DIFF ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,7 +123,6 @@ class PRAutoBlogger_Pipeline_Settings_Page {
|
||||
wp_die( esc_html__( 'You do not have permission to access this page.', 'prautoblogger' ) );
|
||||
}
|
||||
|
||||
// Process any pending save before output begins.
|
||||
$save_result = PRAutoBlogger_Pipeline_Settings_Save_Handler::maybe_process_save();
|
||||
|
||||
$renderer = new PRAutoBlogger_Pipeline_Settings_Renderer();
|
||||
|
||||
@@ -12,18 +12,25 @@ declare(strict_types=1);
|
||||
* fields (M2) so the template can render editable forms without touching
|
||||
* WP globals. The renderer contains NO business logic — it only reads
|
||||
* and shapes data for the view.
|
||||
* M3 additions: passes preview/history nonces and version-list data per
|
||||
* prompt panel so the Template/Preview toggle and version history
|
||||
* accordion have everything they need server-side.
|
||||
* Who calls it: PRAutoBlogger_Pipeline_Settings_Page::render_page() after save.
|
||||
* Dependencies: PRAutoBlogger_Pipeline_Settings_Step_Map (step list),
|
||||
* PRAutoBlogger_Pipeline_Settings_Option_Fields (step option defs),
|
||||
* PRAutoBlogger_Prompt_Registry (active row, versions, defs),
|
||||
* PRAutoBlogger_Prompt_Registry_Writer (list_versions for M3),
|
||||
* PRAutoBlogger_OpenRouter_Model_Field (model picker render),
|
||||
* PRAutoBlogger_Cost_Reporter (monthly spend header).
|
||||
*
|
||||
* @see admin/class-pipeline-settings-step-map.php — Step + key definitions.
|
||||
* @see admin/class-pipeline-settings-option-fields.php — Step option field defs.
|
||||
* @see admin/fields/class-open-router-model-field.php — Model picker component.
|
||||
* @see core/class-prompt-registry.php — Registry read API.
|
||||
* @see templates/admin/pipeline-settings-page.php — HTML template.
|
||||
* @see admin/class-pipeline-settings-step-map.php -- Step + key definitions.
|
||||
* @see admin/class-pipeline-settings-option-fields.php -- Step option field defs.
|
||||
* @see admin/fields/class-open-router-model-field.php -- Model picker component.
|
||||
* @see core/class-prompt-registry.php -- Registry read API.
|
||||
* @see core/class-prompt-registry-writer.php -- list_versions() (M3).
|
||||
* @see ajax/class-pipeline-preview-handler.php -- Preview AJAX (M3).
|
||||
* @see ajax/class-pipeline-history-handler.php -- History/diff AJAX (M3).
|
||||
* @see templates/admin/pipeline-settings-page.php -- HTML template.
|
||||
*/
|
||||
class PRAutoBlogger_Pipeline_Settings_Renderer {
|
||||
|
||||
@@ -42,24 +49,30 @@ class PRAutoBlogger_Pipeline_Settings_Renderer {
|
||||
$monthly_spend = $cost_reporter->get_monthly_spend();
|
||||
$budget = (float) get_option( 'prautoblogger_monthly_budget_usd', 50.00 );
|
||||
|
||||
// Determine the step context for the step-options panel.
|
||||
$step_context = in_array( $step['id'], PRAutoBlogger_Pipeline_Settings_Option_Fields::contexts(), true )
|
||||
? $step['id']
|
||||
: null;
|
||||
|
||||
$view = array(
|
||||
'steps' => $steps,
|
||||
'active_step' => $step,
|
||||
'save_result' => $save_result,
|
||||
'nonce_field' => PRAutoBlogger_Pipeline_Settings_Page::NONCE_FIELD,
|
||||
'nonce_action' => PRAutoBlogger_Pipeline_Settings_Page::NONCE_ACTION,
|
||||
'page_slug' => PRAutoBlogger_Pipeline_Settings_Page::PAGE_SLUG,
|
||||
'step_data' => $this->build_step_data( $step ),
|
||||
'monthly_spend' => $monthly_spend,
|
||||
'budget' => $budget,
|
||||
'global_fields' => $this->build_option_field_values( 'global' ),
|
||||
'step_context' => $step_context,
|
||||
'step_fields' => $step_context ? $this->build_option_field_values( $step_context ) : array(),
|
||||
'steps' => $steps,
|
||||
'active_step' => $step,
|
||||
'save_result' => $save_result,
|
||||
'nonce_field' => PRAutoBlogger_Pipeline_Settings_Page::NONCE_FIELD,
|
||||
'nonce_action' => PRAutoBlogger_Pipeline_Settings_Page::NONCE_ACTION,
|
||||
'page_slug' => PRAutoBlogger_Pipeline_Settings_Page::PAGE_SLUG,
|
||||
'step_data' => $this->build_step_data( $step ),
|
||||
'monthly_spend' => $monthly_spend,
|
||||
'budget' => $budget,
|
||||
'global_fields' => $this->build_option_field_values( 'global' ),
|
||||
'step_context' => $step_context,
|
||||
'step_fields' => $step_context ? $this->build_option_field_values( $step_context ) : array(),
|
||||
// M3: AJAX nonces localized into the view for JS use.
|
||||
'preview_nonce' => wp_create_nonce( PRAutoBlogger_Pipeline_Preview_Handler::ACTION ),
|
||||
'history_nonce' => wp_create_nonce( PRAutoBlogger_Pipeline_History_Handler::ACTION_HISTORY ),
|
||||
'diff_nonce' => wp_create_nonce( PRAutoBlogger_Pipeline_History_Handler::ACTION_DIFF ),
|
||||
'preview_action' => PRAutoBlogger_Pipeline_Preview_Handler::ACTION,
|
||||
'history_action' => PRAutoBlogger_Pipeline_History_Handler::ACTION_HISTORY,
|
||||
'diff_action' => PRAutoBlogger_Pipeline_History_Handler::ACTION_DIFF,
|
||||
);
|
||||
|
||||
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/pipeline-settings-page.php';
|
||||
@@ -68,10 +81,6 @@ class PRAutoBlogger_Pipeline_Settings_Renderer {
|
||||
/**
|
||||
* Build current option values for all fields in a context.
|
||||
*
|
||||
* Returns an array of [ field_def_with_current_value ] — each field
|
||||
* definition from Option_Fields augmented with a 'current' key holding
|
||||
* the live wp_option value (or the field's default when not set).
|
||||
*
|
||||
* @param string $context Step context identifier.
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
@@ -120,6 +129,10 @@ class PRAutoBlogger_Pipeline_Settings_Renderer {
|
||||
/**
|
||||
* Build display data for a single prompt key's edit panel.
|
||||
*
|
||||
* M3 addition: includes 'versions' (full list rows) for the history
|
||||
* accordion. The list is already fetched here so the template needs no
|
||||
* additional DB call.
|
||||
*
|
||||
* @param string $key Registry key.
|
||||
* @param array<string, array{body: string, model_option: ?string, params: array<string, mixed>}> $defs All defs.
|
||||
* @param array<string, int> $active_versions_map key => active version.
|
||||
@@ -137,7 +150,8 @@ class PRAutoBlogger_Pipeline_Settings_Renderer {
|
||||
$is_default = ( $body === $default_body );
|
||||
$created_at = null !== $active_row ? (string) $active_row['created_at'] : '';
|
||||
$author = null !== $active_row ? (string) $active_row['author'] : 'seed';
|
||||
$versions = PRAutoBlogger_Prompt_Registry_Writer::list_versions( $key );
|
||||
// M3: pass full version rows to the template for the history accordion.
|
||||
$versions = PRAutoBlogger_Prompt_Registry_Writer::list_versions( $key );
|
||||
|
||||
return array(
|
||||
'key' => $key,
|
||||
@@ -148,14 +162,13 @@ class PRAutoBlogger_Pipeline_Settings_Renderer {
|
||||
'created_at' => $created_at,
|
||||
'author' => $author,
|
||||
'version_count' => count( $versions ),
|
||||
'versions' => $versions,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the model picker for a step using the existing field component.
|
||||
*
|
||||
* Public so the template can call it directly.
|
||||
*
|
||||
* @param string $model_option wp_option name.
|
||||
* @param string $model_value Current model id stored in the option.
|
||||
* @param array<string, mixed> $field_args Field args forwarded to the picker.
|
||||
|
||||
Reference in New Issue
Block a user