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:
peptiderepo
2026-06-23 13:33:18 +07:00
committed by GitHub
parent 8b789b3f1b
commit 2569b08862
16 changed files with 1634 additions and 107 deletions

View 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;
}
}

View File

@@ -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();

View File

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

View File

@@ -0,0 +1,291 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* AJAX handler: fetch version history and compute diffs for a prompt key.
*
* What: Two actions on one class —
* prautoblogger_pipeline_history: returns all stored versions for a key
* (version number, author, created_at, active flag). Read-only; never
* mutates the registry.
* prautoblogger_pipeline_diff: returns a line-level unified diff between
* two specified versions (or between a version and the current active
* body). Diff is computed server-side (no external binary required)
* and returned as an array of {type, text} lines (added/removed/context)
* so the JS renderer can apply colour without parsing unified format.
*
* Security: manage_options cap + nonce on every request. All registry key
* inputs are validated against the Step_Map allowlist. Diff output
* is esc_html'd before encoding.
*
* Who triggers it: pipeline-settings.js on history accordion open and diff
* button click.
* Dependencies: PRAutoBlogger_Prompt_Registry_Writer (list_versions),
* PRAutoBlogger_Prompt_Registry (get_active, default_body),
* PRAutoBlogger_Pipeline_Settings_Step_Map (key allowlist).
*
* @see ajax/class-pipeline-preview-handler.php -- Preview assembled text.
* @see core/class-prompt-registry-writer.php -- list_versions() source.
* @see ARCHITECTURE.md #21 -- Prompt registry design.
*/
class PRAutoBlogger_Pipeline_History_Handler {
/** AJAX action: list all versions for a key. */
public const ACTION_HISTORY = 'prautoblogger_pipeline_history';
/** AJAX action: diff two versions. */
public const ACTION_DIFF = 'prautoblogger_pipeline_diff';
/**
* Register wp-ajax hooks.
*
* @return void
*/
public static function register_hooks(): void {
add_action( 'wp_ajax_' . self::ACTION_HISTORY, array( __CLASS__, 'handle_history' ) );
add_action( 'wp_ajax_' . self::ACTION_DIFF, array( __CLASS__, 'handle_diff' ) );
}
/**
* Return all stored versions for a prompt key, newest first.
*
* POST fields:
* nonce string wp_nonce for 'prautoblogger_pipeline_history'.
* prompt_key string Registry key slug (dots to hyphens).
*
* @return void
*/
public static function handle_history(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
}
$nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : '';
if ( ! wp_verify_nonce( $nonce, self::ACTION_HISTORY ) ) {
wp_send_json_error( array( 'message' => __( 'Security check failed.', 'prautoblogger' ) ), 403 );
}
$slug = isset( $_POST['prompt_key'] ) ? sanitize_key( wp_unslash( $_POST['prompt_key'] ) ) : '';
$prompt_key = self::resolve_key_from_slug( $slug );
if ( null === $prompt_key ) {
wp_send_json_error( array( 'message' => __( 'Unknown prompt key.', 'prautoblogger' ) ), 400 );
}
$rows = PRAutoBlogger_Prompt_Registry_Writer::list_versions( $prompt_key );
$versions = array();
foreach ( $rows as $row ) {
$versions[] = array(
'version' => (int) $row['version'],
'author' => esc_html( (string) $row['author'] ),
'created_at' => esc_html( (string) $row['created_at'] ),
'active' => (bool) $row['active'],
);
}
wp_send_json_success( array( 'versions' => $versions ) );
}
/**
* Compute and return a line-level diff between two prompt versions.
*
* POST fields:
* nonce string wp_nonce for 'prautoblogger_pipeline_diff'.
* prompt_key string Registry key slug (dots to hyphens).
* version_a int Older version number (or 0 = factory default).
* version_b int Newer version number (or 0 = current active).
*
* Returns {lines: [{type: 'added'|'removed'|'context', text: string}], header: string}.
*
* @return void
*/
public static function handle_diff(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
}
$nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : '';
if ( ! wp_verify_nonce( $nonce, self::ACTION_DIFF ) ) {
wp_send_json_error( array( 'message' => __( 'Security check failed.', 'prautoblogger' ) ), 403 );
}
$slug = isset( $_POST['prompt_key'] ) ? sanitize_key( wp_unslash( $_POST['prompt_key'] ) ) : '';
$prompt_key = self::resolve_key_from_slug( $slug );
if ( null === $prompt_key ) {
wp_send_json_error( array( 'message' => __( 'Unknown prompt key.', 'prautoblogger' ) ), 400 );
}
$version_a = isset( $_POST['version_a'] ) ? (int) $_POST['version_a'] : 0;
$version_b = isset( $_POST['version_b'] ) ? (int) $_POST['version_b'] : 0;
$body_a = self::fetch_version_body( $prompt_key, $version_a );
$body_b = self::fetch_version_body( $prompt_key, $version_b );
if ( null === $body_a || null === $body_b ) {
wp_send_json_error( array( 'message' => __( 'Version not found.', 'prautoblogger' ) ), 404 );
}
$label_a = 0 === $version_a ? __( 'default', 'prautoblogger' ) : sprintf( 'v%d', $version_a );
$label_b = 0 === $version_b ? __( 'current', 'prautoblogger' ) : sprintf( 'v%d', $version_b );
$lines = self::compute_diff( $body_a, $body_b );
wp_send_json_success(
array(
'lines' => $lines,
'header' => esc_html(
sprintf(
/* translators: 1: prompt key, 2: old version label, 3: new version label */
__( '%1$s — %2$s → %3$s', 'prautoblogger' ),
$prompt_key,
$label_a,
$label_b
)
),
)
);
}
/**
* Fetch the body text for a specific version number.
* Version 0 means the factory default (in-code canonical body).
*
* @param string $key Registry key.
* @param int $version Version number, or 0 for factory default.
* @return string|null Body text, or null when not found.
*/
private static function fetch_version_body( string $key, int $version ): ?string {
if ( 0 === $version ) {
return PRAutoBlogger_Prompt_Registry::default_body( $key );
}
$rows = PRAutoBlogger_Prompt_Registry_Writer::list_versions( $key );
foreach ( $rows as $row ) {
if ( (int) $row['version'] === $version ) {
return (string) $row['body'];
}
}
return null;
}
/**
* Compute a line-level diff of two text bodies.
*
* Uses a simple LCS-based diff (no external binary). Returns an array of
* {type: 'added'|'removed'|'context', text: string} objects with
* text already esc_html'd. Context lines are capped at 3 surrounding
* changed lines to keep response size bounded.
*
* @param string $old Old text body.
* @param string $new New text body.
* @return array<int, array{type: string, text: string}>
*/
private static function compute_diff( string $old, string $new ): array {
$old_lines = explode( "\n", $old );
$new_lines = explode( "\n", $new );
$m = count( $old_lines );
$n = count( $new_lines );
// LCS table — for reasonable-sized prompts (typically < 200 lines).
$lcs = array_fill( 0, $m + 1, array_fill( 0, $n + 1, 0 ) );
for ( $i = 1; $i <= $m; $i++ ) {
for ( $j = 1; $j <= $n; $j++ ) {
if ( $old_lines[ $i - 1 ] === $new_lines[ $j - 1 ] ) {
$lcs[ $i ][ $j ] = $lcs[ $i - 1 ][ $j - 1 ] + 1;
} else {
$lcs[ $i ][ $j ] = max( $lcs[ $i - 1 ][ $j ], $lcs[ $i ][ $j - 1 ] );
}
}
}
// Backtrack to build edit ops.
$ops = array();
$i = $m;
$j = $n;
while ( $i > 0 || $j > 0 ) {
if ( $i > 0 && $j > 0 && $old_lines[ $i - 1 ] === $new_lines[ $j - 1 ] ) {
array_unshift( $ops, array( 'context', $old_lines[ $i - 1 ] ) );
--$i;
--$j;
} elseif ( $j > 0 && ( 0 === $i || $lcs[ $i ][ $j - 1 ] >= $lcs[ $i - 1 ][ $j ] ) ) {
array_unshift( $ops, array( 'added', $new_lines[ $j - 1 ] ) );
--$j;
} else {
array_unshift( $ops, array( 'removed', $old_lines[ $i - 1 ] ) );
--$i;
}
}
// Collapse context: keep 3 lines before/after each changed block.
$context_radius = 3;
$changed = array();
foreach ( $ops as $idx => $op ) {
if ( 'context' !== $op[0] ) {
$changed[ $idx ] = true;
}
}
$result = array();
$skipped = 0;
foreach ( $ops as $idx => $op ) {
$near_change = false;
for ( $d = -$context_radius; $d <= $context_radius; $d++ ) {
if ( isset( $changed[ $idx + $d ] ) ) {
$near_change = true;
break;
}
}
if ( 'context' === $op[0] && ! $near_change ) {
$skipped++;
continue;
}
if ( $skipped > 0 ) {
$result[] = array(
'type' => 'omitted',
'text' => esc_html(
sprintf(
/* translators: %d: number of skipped context lines */
__( '… %d lines unchanged …', 'prautoblogger' ),
$skipped
)
),
);
$skipped = 0;
}
$result[] = array(
'type' => $op[0],
'text' => esc_html( $op[1] ),
);
}
if ( $skipped > 0 ) {
$result[] = array(
'type' => 'omitted',
'text' => esc_html(
sprintf(
/* translators: %d: number of skipped context lines */
__( '… %d lines unchanged …', 'prautoblogger' ),
$skipped
)
),
);
}
return $result;
}
/**
* Resolve a slug back to the canonical registry key via the step map allowlist.
*
* @param string $slug Slug from POST (after sanitize_key).
* @return string|null Canonical key, or null when not in the allowlist.
*/
private static function resolve_key_from_slug( string $slug ): ?string {
foreach ( PRAutoBlogger_Pipeline_Settings_Step_Map::allowed_prompt_keys() as $key ) {
if ( sanitize_key( str_replace( '.', '-', $key ) ) === $slug ) {
return $key;
}
}
return null;
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* AJAX handler: fetch the assembled (rendered) preview for a prompt key.
*
* What: Returns the fully-assembled instruction text for a given prompt key
* and stage. Prefers the last run's rendered prompt (extracted from
* the generation_log request_json) so the admin sees the exact text
* the LLM received; falls back to a sample render (template with
* placeholder token values) when no run exists yet. The preview is
* ALWAYS read-only — there is no save path, cap check, or nonce that
* would allow the returned text to be persisted. Escaping is done
* server-side; the client renders as pre-formatted text.
*
* Stage-list-driven: the prompt_key -> stage mapping goes through
* Stage_Display_Map so Phase 2b stages auto-resolve without rework.
*
* Security: cap check (manage_options) + nonce verification on every
* request. Output is JSON with esc_html'd preview body + a
* boolean indicating whether the source was a real run.
*
* Who triggers it: pipeline-settings.js fetch() on "Preview" tab click.
* Dependencies: PRAutoBlogger_Prompt_Registry (render / get_active),
* PRAutoBlogger_Pipeline_Preview_Source (last-run query),
* PRAutoBlogger_Pipeline_Settings_Step_Map (key allowlist),
* PRAutoBlogger_Stage_Display_Map (stage vocabulary).
*
* @see ajax/class-pipeline-history-handler.php -- Version list / diff AJAX.
* @see admin/class-pipeline-settings-page.php -- Registers nonce for JS.
* @see core/class-prompt-registry.php -- Template render API.
* @see core/class-stage-display-map.php -- Stage vocabulary.
* @see ARCHITECTURE.md #21 -- Prompt registry design.
*/
class PRAutoBlogger_Pipeline_Preview_Handler {
/** AJAX action name (wp_ajax_ prefixed, manage_options gate). */
public const ACTION = 'prautoblogger_pipeline_preview';
/**
* Register wp-ajax hooks. Called from PRAutoBlogger::register_ajax_hooks().
*
* @return void
*/
public static function register_hooks(): void {
add_action( 'wp_ajax_' . self::ACTION, array( __CLASS__, 'handle' ) );
}
/**
* Handle the AJAX request.
*
* Expected POST fields:
* nonce string wp_nonce for 'prautoblogger_pipeline_preview'.
* prompt_key string Registry key slug (dots to hyphens, post sanitize_key).
*
* @return void Outputs JSON and calls wp_die().
*/
public static function handle(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
}
$nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : '';
if ( ! wp_verify_nonce( $nonce, self::ACTION ) ) {
wp_send_json_error( array( 'message' => __( 'Security check failed.', 'prautoblogger' ) ), 403 );
}
$slug = isset( $_POST['prompt_key'] ) ? sanitize_key( wp_unslash( $_POST['prompt_key'] ) ) : '';
$prompt_key = self::resolve_key_from_slug( $slug );
if ( null === $prompt_key ) {
wp_send_json_error( array( 'message' => __( 'Unknown prompt key.', 'prautoblogger' ) ), 400 );
}
$source_data = PRAutoBlogger_Pipeline_Preview_Source::last_run( $prompt_key );
if ( null !== $source_data ) {
wp_send_json_success(
array(
'preview' => esc_html( $source_data['rendered'] ),
'from_run' => true,
'run_date' => esc_html( $source_data['run_date'] ),
'run_post' => esc_html( $source_data['run_post'] ),
)
);
}
$rendered = PRAutoBlogger_Pipeline_Preview_Source::sample_render( $prompt_key );
wp_send_json_success(
array(
'preview' => esc_html( $rendered ),
'from_run' => false,
'run_date' => '',
'run_post' => '',
)
);
}
/**
* Resolve a sanitize_key'd slug back to a canonical registry key.
* Identical logic to Save_Handler so the same form slug works for both.
*
* @param string $slug Slug from POST (after sanitize_key).
* @return string|null Canonical key, or null when not in the allowlist.
*/
private static function resolve_key_from_slug( string $slug ): ?string {
foreach ( PRAutoBlogger_Pipeline_Settings_Step_Map::allowed_prompt_keys() as $key ) {
if ( sanitize_key( str_replace( '.', '-', $key ) ) === $slug ) {
return $key;
}
}
return null;
}
}

View File

@@ -198,6 +198,10 @@ class PRAutoBlogger {
$ideas = new PRAutoBlogger_Ideas_Browser();
add_action( 'wp_ajax_prautoblogger_generate_from_idea', array( $ideas, 'on_ajax_generate_from_idea' ) );
add_action( 'wp_ajax_prautoblogger_idea_gen_status', array( $ideas, 'on_ajax_idea_gen_status' ) );
// v0.25.0 (M3): pipeline prompt preview + history/diff endpoints.
PRAutoBlogger_Pipeline_Preview_Handler::register_hooks();
PRAutoBlogger_Pipeline_History_Handler::register_hooks();
}
/**