feat: Pipeline Settings M1 — per-step model picker, system instructions, agent prompts (#170, v0.23.0)
Additive per-step Pipeline Settings page. Reuses the existing model_select picker + versioned prompt registry; no migration. QA APPROVE for eea4968; CI green. Agent-Session: pipeline-settings-m1-merge
This commit is contained in:
112
includes/admin/class-pipeline-settings-page.php
Normal file
112
includes/admin/class-pipeline-settings-page.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Registers the Pipeline Settings wp-admin submenu page and its assets.
|
||||
*
|
||||
* 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
|
||||
* unchanged and both surfaces edit the same wp_options / prompts
|
||||
* table. Decomposition of redundant tabs is M2.
|
||||
* 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.
|
||||
*/
|
||||
class PRAutoBlogger_Pipeline_Settings_Page {
|
||||
|
||||
/** wp-admin page slug. */
|
||||
public const PAGE_SLUG = 'prautoblogger-pipeline';
|
||||
|
||||
/** Nonce action for saving prompt edits on this page. */
|
||||
public const NONCE_ACTION = 'prautoblogger_pipeline_save';
|
||||
|
||||
/** Nonce field name posted with the save form. */
|
||||
public const NONCE_FIELD = 'prautoblogger_pipeline_nonce';
|
||||
|
||||
/**
|
||||
* Register the Pipeline submenu page under the PRAutoBlogger parent menu.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function on_register_menu(): void {
|
||||
add_submenu_page(
|
||||
'prautoblogger-settings',
|
||||
__( 'Pipeline Settings', 'prautoblogger' ),
|
||||
__( 'Pipeline', 'prautoblogger' ),
|
||||
'manage_options',
|
||||
self::PAGE_SLUG,
|
||||
array( $this, 'render_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.
|
||||
*
|
||||
* @param string $hook_suffix Current admin page hook suffix.
|
||||
* @return void
|
||||
*/
|
||||
public function on_enqueue_assets( string $hook_suffix ): void {
|
||||
if ( 'prautoblogger_page_' . self::PAGE_SLUG !== $hook_suffix ) {
|
||||
return;
|
||||
}
|
||||
|
||||
wp_enqueue_style( 'prautoblogger-admin', PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/admin.css', array(), PRAUTOBLOGGER_VERSION );
|
||||
wp_enqueue_style( 'prautoblogger-model-picker', PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/model-picker.css', array( 'prautoblogger-admin' ), PRAUTOBLOGGER_VERSION );
|
||||
wp_enqueue_style( 'prautoblogger-pipeline', PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/pipeline-settings.css', array( 'prautoblogger-admin' ), PRAUTOBLOGGER_VERSION );
|
||||
|
||||
wp_enqueue_script( 'prautoblogger-admin', PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/admin.js', array( 'jquery' ), PRAUTOBLOGGER_VERSION, true );
|
||||
wp_enqueue_script( 'prautoblogger-model-picker', PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/model-picker.js', array( 'jquery', 'prautoblogger-admin' ), PRAUTOBLOGGER_VERSION, true );
|
||||
wp_enqueue_script( 'prautoblogger-pipeline', PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/pipeline-settings.js', array( 'jquery', 'prautoblogger-admin' ), PRAUTOBLOGGER_VERSION, true );
|
||||
|
||||
wp_localize_script(
|
||||
'prautoblogger-admin',
|
||||
'prautobloggerAdmin',
|
||||
array(
|
||||
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||
'adminUrl' => admin_url(),
|
||||
'siteUrl' => home_url( '/' ),
|
||||
'modelsNonce' => wp_create_nonce( 'prautoblogger_get_models' ),
|
||||
'imageModels' => PRAutoBlogger_Settings_Fields_Extended::get_image_models(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Pipeline Settings page.
|
||||
*
|
||||
* Capability check first; then delegate to the save handler (processes
|
||||
* POST before output starts) and the renderer (assembles view data and
|
||||
* includes the template).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function render_page(): void {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
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();
|
||||
$renderer->render( $save_result );
|
||||
}
|
||||
}
|
||||
148
includes/admin/class-pipeline-settings-renderer.php
Normal file
148
includes/admin/class-pipeline-settings-renderer.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Assembles view data and renders the Pipeline Settings page template.
|
||||
*
|
||||
* What: Reads current option values, active prompt versions, and registry
|
||||
* params, then passes a typed data structure to the pipeline-settings-
|
||||
* page.php template. The renderer contains NO business logic — it only
|
||||
* reads and shapes data for the view. All output is escaped by the
|
||||
* template or by helper methods on this class.
|
||||
* Who calls it: PRAutoBlogger_Pipeline_Settings_Page::render_page() after
|
||||
* the save handler runs.
|
||||
* Dependencies: PRAutoBlogger_Pipeline_Settings_Step_Map (step list),
|
||||
* PRAutoBlogger_Prompt_Registry (active row, versions, defs),
|
||||
* 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/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.
|
||||
*/
|
||||
class PRAutoBlogger_Pipeline_Settings_Renderer {
|
||||
|
||||
/**
|
||||
* Build the view-data array and include the page template.
|
||||
*
|
||||
* Gathers budget and spend data here so the template receives plain scalars
|
||||
* and performs no business-logic instantiation of its own.
|
||||
*
|
||||
* @param array{status: string, message: string} $save_result Result from save handler.
|
||||
* @return void
|
||||
*/
|
||||
public function render( array $save_result ): void {
|
||||
$active_step = isset( $_GET['step'] ) ? sanitize_key( $_GET['step'] ) : 'research';
|
||||
$steps = PRAutoBlogger_Pipeline_Settings_Step_Map::steps();
|
||||
$step = PRAutoBlogger_Pipeline_Settings_Step_Map::find( $active_step ) ?? $steps[0];
|
||||
|
||||
// Gather cost-header data in the renderer so the template stays logic-free.
|
||||
$cost_reporter = new PRAutoBlogger_Cost_Reporter();
|
||||
$monthly_spend = $cost_reporter->get_monthly_spend();
|
||||
$budget = (float) get_option( 'prautoblogger_monthly_budget_usd', 50.00 );
|
||||
|
||||
$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,
|
||||
);
|
||||
|
||||
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/pipeline-settings-page.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble all data needed to render a single step panel.
|
||||
*
|
||||
* @param array<string, mixed> $step Step definition from Step_Map::steps().
|
||||
* @return array<string, mixed> View data for the step panel.
|
||||
*/
|
||||
public function build_step_data( array $step ): array {
|
||||
$defs = PRAutoBlogger_Prompt_Registry::defs();
|
||||
$active_versions_map = PRAutoBlogger_Prompt_Registry::active_versions();
|
||||
|
||||
// Model info.
|
||||
$model_option = (string) ( $step['model_option'] ?? '' );
|
||||
$model_value = '' !== $model_option ? (string) get_option( $model_option, '' ) : '';
|
||||
|
||||
// System prompt.
|
||||
$system_key = (string) ( $step['system_key'] ?? '' );
|
||||
$system_data = $this->build_prompt_panel_data( $system_key, $defs, $active_versions_map );
|
||||
|
||||
// Agent/user prompts.
|
||||
$agent_panels = array();
|
||||
foreach ( ( $step['agent_keys'] ?? array() ) as $key ) {
|
||||
$agent_panels[ $key ] = $this->build_prompt_panel_data( $key, $defs, $active_versions_map );
|
||||
}
|
||||
|
||||
// Params from the def for the system key (canonical params are on system).
|
||||
$params = isset( $defs[ $system_key ] ) ? ( $defs[ $system_key ]['params'] ?? array() ) : array();
|
||||
|
||||
return array(
|
||||
'step' => $step,
|
||||
'model_option' => $model_option,
|
||||
'model_value' => $model_value,
|
||||
'system' => $system_data,
|
||||
'agent_panels' => $agent_panels,
|
||||
'params' => $params,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build display data for a single prompt key's edit panel.
|
||||
*
|
||||
* @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.
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function build_prompt_panel_data( string $key, array $defs, array $active_versions_map ): array {
|
||||
if ( '' === $key ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$active_row = PRAutoBlogger_Prompt_Registry::get_active( $key );
|
||||
$active_version = $active_versions_map[ $key ] ?? 0;
|
||||
$body = null !== $active_row ? (string) $active_row['body'] : (string) PRAutoBlogger_Prompt_Registry::default_body( $key );
|
||||
$default_body = (string) PRAutoBlogger_Prompt_Registry::default_body( $key );
|
||||
$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 );
|
||||
|
||||
return array(
|
||||
'key' => $key,
|
||||
'body' => $body,
|
||||
'default_body' => $default_body,
|
||||
'is_default' => $is_default,
|
||||
'active_version' => $active_version,
|
||||
'created_at' => $created_at,
|
||||
'author' => $author,
|
||||
'version_count' => count( $versions ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the model picker for a step using the existing field component.
|
||||
*
|
||||
* Public so the template can call it directly. Keeps markup generation
|
||||
* in one place and avoids duplicating the picker rendering logic.
|
||||
*
|
||||
* @param string $model_option wp_option name (e.g. 'prautoblogger_writing_model').
|
||||
* @param string $model_value Current model id stored in the option.
|
||||
* @param array<string, mixed> $field_args Field args forwarded to the picker.
|
||||
* @return void Side effect: outputs HTML.
|
||||
*/
|
||||
public function render_model_picker( string $model_option, string $model_value, array $field_args ): void {
|
||||
PRAutoBlogger_OpenRouter_Model_Field::render( $model_option, $model_value, $field_args );
|
||||
}
|
||||
}
|
||||
233
includes/admin/class-pipeline-settings-save-handler.php
Normal file
233
includes/admin/class-pipeline-settings-save-handler.php
Normal file
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Processes form submissions on the Pipeline Settings page.
|
||||
*
|
||||
* What: A stateless save handler for the Pipeline Settings page. On a valid
|
||||
* POST it verifies the nonce, checks capabilities, sanitizes inputs,
|
||||
* and either:
|
||||
* - saves the model option via update_option(), or
|
||||
* - creates a new immutable prompt version in the registry
|
||||
* (PRAutoBlogger_Prompt_Registry_Writer::create_version()).
|
||||
* Saving a prompt NEVER mutates the existing version — it creates a
|
||||
* new one (the registry's core invariant). A "reset to default" POST
|
||||
* creates a new version with the canonical default body.
|
||||
* Model values are read from the POST key that matches the option name
|
||||
* (e.g. $_POST['prautoblogger_research_model']), which is the hidden
|
||||
* input name that PRAutoBlogger_OpenRouter_Model_Field::render() emits.
|
||||
* Prompt keys arrive as a slug-encoded form value that is matched
|
||||
* against the allowlist; the raw POST value is never used as a key
|
||||
* before allowlist validation.
|
||||
* Returns a result array that render_page() passes to the template.
|
||||
* Who calls it: PRAutoBlogger_Pipeline_Settings_Page::render_page() before
|
||||
* any output is produced.
|
||||
* Dependencies: PRAutoBlogger_Pipeline_Settings_Step_Map (key allowlists),
|
||||
* PRAutoBlogger_Prompt_Registry_Writer (create_version),
|
||||
* PRAutoBlogger_Prompt_Registry (default_body, flush_cache).
|
||||
*
|
||||
* @see admin/class-pipeline-settings-step-map.php — Allowed key definitions.
|
||||
* @see admin/fields/class-open-router-model-field.php — Emits name="$option_name" hidden input.
|
||||
* @see core/class-prompt-registry-writer.php — Immutable version writes.
|
||||
* @see core/class-prompt-registry.php — Read side + default_body.
|
||||
*/
|
||||
class PRAutoBlogger_Pipeline_Settings_Save_Handler {
|
||||
|
||||
/**
|
||||
* Inspect the current request and process the save when it is a valid
|
||||
* Pipeline Settings form submission.
|
||||
*
|
||||
* Side effects: may call update_option() or
|
||||
* PRAutoBlogger_Prompt_Registry_Writer::create_version();
|
||||
* returns error on nonce/cap failure.
|
||||
*
|
||||
* @return array{status: string, message: string} 'idle'|'saved'|'error'.
|
||||
*/
|
||||
public static function maybe_process_save(): array {
|
||||
$idle = array(
|
||||
'status' => 'idle',
|
||||
'message' => '',
|
||||
);
|
||||
|
||||
if ( 'POST' !== $_SERVER['REQUEST_METHOD'] ) {
|
||||
return $idle;
|
||||
}
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing
|
||||
if ( empty( $_POST[ PRAutoBlogger_Pipeline_Settings_Page::NONCE_FIELD ] ) ) {
|
||||
return $idle;
|
||||
}
|
||||
|
||||
// Capability check.
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
return array(
|
||||
'status' => 'error',
|
||||
'message' => __( 'You do not have permission to save settings.', 'prautoblogger' ),
|
||||
);
|
||||
}
|
||||
|
||||
// Nonce verification.
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
|
||||
if ( ! wp_verify_nonce( wp_unslash( $_POST[ PRAutoBlogger_Pipeline_Settings_Page::NONCE_FIELD ] ), PRAutoBlogger_Pipeline_Settings_Page::NONCE_ACTION ) ) {
|
||||
return array(
|
||||
'status' => 'error',
|
||||
'message' => __( 'Security check failed. Please reload and try again.', 'prautoblogger' ),
|
||||
);
|
||||
}
|
||||
|
||||
$action = isset( $_POST['pipeline_action'] ) ? sanitize_key( $_POST['pipeline_action'] ) : '';
|
||||
|
||||
if ( 'save_model' === $action ) {
|
||||
return self::handle_model_save();
|
||||
}
|
||||
if ( 'save_prompt' === $action || 'reset_prompt' === $action ) {
|
||||
return self::handle_prompt_save( $action );
|
||||
}
|
||||
|
||||
return $idle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a model selection for one step.
|
||||
*
|
||||
* The model value is read from $_POST[$option_name] — the same key that
|
||||
* PRAutoBlogger_OpenRouter_Model_Field::render($option_name, ...) emits as
|
||||
* <input type="hidden" name="$option_name" ...>. model-picker.js writes to
|
||||
* that hidden input directly via DOM id, so the value is present under the
|
||||
* option-name key in the submitted POST body.
|
||||
*
|
||||
* Only allowlisted model option names from Step_Map may be updated.
|
||||
*
|
||||
* @return array{status: string, message: string}
|
||||
*/
|
||||
private static function handle_model_save(): array {
|
||||
$option_name = isset( $_POST['model_option'] ) ? sanitize_key( $_POST['model_option'] ) : '';
|
||||
|
||||
if ( ! in_array( $option_name, PRAutoBlogger_Pipeline_Settings_Step_Map::allowed_model_options(), true ) ) {
|
||||
return array(
|
||||
'status' => 'error',
|
||||
'message' => __( 'Unknown model option.', 'prautoblogger' ),
|
||||
);
|
||||
}
|
||||
|
||||
// Read the value from the named hidden input that render() produced.
|
||||
// PRAutoBlogger_OpenRouter_Model_Field::render($id, ...) emits:
|
||||
// <input type="hidden" id="$id" name="$id" value="..." />
|
||||
// model-picker.js writes the chosen model to that input by DOM id,
|
||||
// so the form posts under the option name, not a separate 'model_id' key.
|
||||
$model_id = isset( $_POST[ $option_name ] )
|
||||
? sanitize_text_field( wp_unslash( $_POST[ $option_name ] ) )
|
||||
: '';
|
||||
|
||||
// For the image model, delegate to the same sanitizer logic as
|
||||
// PRAutoBlogger_Settings_Sanitizer to derive + store provider consistently.
|
||||
if ( 'prautoblogger_image_model' === $option_name ) {
|
||||
$candidate = sanitize_text_field( $model_id );
|
||||
$provider = PRAutoBlogger_Image_Model_Registry::provider_for( $candidate );
|
||||
if ( '' !== $provider ) {
|
||||
update_option( 'prautoblogger_image_provider', $provider );
|
||||
update_option( $option_name, $candidate );
|
||||
return array(
|
||||
'status' => 'saved',
|
||||
'message' => __( 'Model saved.', 'prautoblogger' ),
|
||||
);
|
||||
}
|
||||
return array(
|
||||
'status' => 'error',
|
||||
'message' => __( 'Image model not recognised. Selection unchanged.', 'prautoblogger' ),
|
||||
);
|
||||
}
|
||||
|
||||
update_option( $option_name, sanitize_text_field( $model_id ) );
|
||||
return array(
|
||||
'status' => 'saved',
|
||||
'message' => __( 'Model saved.', 'prautoblogger' ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new prompt version (or reset to default) in the registry.
|
||||
*
|
||||
* The prompt key is received as a slug (dots replaced with hyphens in the
|
||||
* form value). We match the slug against the allowlist by resolving each
|
||||
* allowed key to its slug form — this avoids any reverse-parsing ambiguity
|
||||
* for keys that contain underscores (e.g. content.single_pass).
|
||||
*
|
||||
* Prompt bodies are treated as privileged — only sanitize_textarea_field()
|
||||
* is applied to strip any script injection, but content is not further
|
||||
* mangled — the operator is intentionally writing LLM instructions.
|
||||
*
|
||||
* @param string $action 'save_prompt' or 'reset_prompt'.
|
||||
* @return array{status: string, message: string}
|
||||
*/
|
||||
private static function handle_prompt_save( string $action ): array {
|
||||
// The form sends the key with dots replaced by hyphens (HTML-safe slug).
|
||||
$slug = isset( $_POST['prompt_key'] ) ? sanitize_key( wp_unslash( $_POST['prompt_key'] ) ) : '';
|
||||
|
||||
// Resolve slug back to the real registry key via allowlist lookup.
|
||||
$prompt_key = self::resolve_key_from_slug( $slug );
|
||||
if ( null === $prompt_key ) {
|
||||
return array(
|
||||
'status' => 'error',
|
||||
'message' => __( 'Unknown prompt key.', 'prautoblogger' ),
|
||||
);
|
||||
}
|
||||
|
||||
if ( 'reset_prompt' === $action ) {
|
||||
$body = PRAutoBlogger_Prompt_Registry::default_body( $prompt_key );
|
||||
if ( null === $body ) {
|
||||
return array(
|
||||
'status' => 'error',
|
||||
'message' => __( 'No default found for this prompt key.', 'prautoblogger' ),
|
||||
);
|
||||
}
|
||||
$author = 'reset:pipeline-ui';
|
||||
} else {
|
||||
$raw_body = isset( $_POST['prompt_body'] ) ? wp_unslash( $_POST['prompt_body'] ) : '';
|
||||
$body = sanitize_textarea_field( $raw_body );
|
||||
$author = 'pipeline-ui:' . ( wp_get_current_user()->user_login ?? 'admin' );
|
||||
}
|
||||
|
||||
$version = PRAutoBlogger_Prompt_Registry_Writer::create_version(
|
||||
$prompt_key,
|
||||
$body,
|
||||
null,
|
||||
null,
|
||||
$author,
|
||||
true
|
||||
);
|
||||
|
||||
if ( 0 === $version ) {
|
||||
return array(
|
||||
'status' => 'error',
|
||||
'message' => __( 'Failed to save prompt. Is the prompts table available?', 'prautoblogger' ),
|
||||
);
|
||||
}
|
||||
|
||||
return array(
|
||||
'status' => 'saved',
|
||||
// translators: %1$s = prompt key, %2$d = new version number.
|
||||
'message' => sprintf( __( 'Saved %1$s as version %2$d.', 'prautoblogger' ), esc_html( $prompt_key ), $version ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a URL/form slug back to the canonical registry key.
|
||||
*
|
||||
* Each allowed key is converted to a slug (dots → hyphens, everything
|
||||
* lowercased by sanitize_key) and compared against the submitted value.
|
||||
* No string-reverse parsing — only allowlist membership determines a match.
|
||||
*
|
||||
* @param string $slug Slug as received from the form (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;
|
||||
}
|
||||
}
|
||||
142
includes/admin/class-pipeline-settings-step-map.php
Normal file
142
includes/admin/class-pipeline-settings-step-map.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Canonical definition of every LLM pipeline step shown in Pipeline Settings.
|
||||
*
|
||||
* What: A single source of truth for step metadata: display name, icon,
|
||||
* model option key, registry prompt keys (system + agent/user),
|
||||
* and parameters sourced from PRAutoBlogger_Prompt_Registry::defs().
|
||||
* Non-LLM steps (Scoring, Publish) are intentionally absent — they
|
||||
* have no model/prompt config to surface.
|
||||
* Who calls it: PRAutoBlogger_Pipeline_Settings_Renderer (step rail + panels),
|
||||
* PRAutoBlogger_Pipeline_Settings_Save_Handler (key validation).
|
||||
* Dependencies: PRAutoBlogger_Prompt_Registry::defs() for param snapshots.
|
||||
*
|
||||
* @see admin/class-pipeline-settings-renderer.php — Consumes step definitions.
|
||||
* @see core/class-prompt-defaults.php — Canonical prompt bodies.
|
||||
* @see core/class-prompt-defaults-editorial.php — Editor/research/image bodies.
|
||||
*/
|
||||
class PRAutoBlogger_Pipeline_Settings_Step_Map {
|
||||
|
||||
/**
|
||||
* Ordered list of LLM step definitions for the Pipeline Settings UI.
|
||||
*
|
||||
* Each entry is an associative array with:
|
||||
* id string Machine identifier (URL param / JS key).
|
||||
* label string Display name shown in the step rail.
|
||||
* icon string Dashicons class for the step button.
|
||||
* model_option string|null wp_option that controls the model for this step.
|
||||
* capability string 'text→text' or 'image_generation' — drives picker.
|
||||
* system_key string|null Registry key for the system prompt.
|
||||
* agent_keys string[] Registry keys for agent/user prompts (ordered).
|
||||
* description string One-line note shown above the step panel.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function steps(): array {
|
||||
return array(
|
||||
array(
|
||||
'id' => 'research',
|
||||
'label' => __( 'Research', 'prautoblogger' ),
|
||||
'icon' => 'dashicons-search',
|
||||
'model_option' => 'prautoblogger_research_model',
|
||||
'capability' => 'text→text',
|
||||
'system_key' => 'research.system',
|
||||
'agent_keys' => array(),
|
||||
'description' => __( 'LLM deep-research call. The user-side research brief is a setting below, not a registry prompt.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'analysis',
|
||||
'label' => __( 'Analysis', 'prautoblogger' ),
|
||||
'icon' => 'dashicons-chart-bar',
|
||||
'model_option' => 'prautoblogger_analysis_model',
|
||||
'capability' => 'text→text',
|
||||
'system_key' => 'analysis.system',
|
||||
'agent_keys' => array( 'analysis.user' ),
|
||||
'description' => __( 'Scores and selects ideas from research findings.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'writer',
|
||||
'label' => __( 'Writer', 'prautoblogger' ),
|
||||
'icon' => 'dashicons-edit',
|
||||
'model_option' => 'prautoblogger_writing_model',
|
||||
'capability' => 'text→text',
|
||||
'system_key' => 'content.system',
|
||||
'agent_keys' => array( 'content.single_pass', 'content.outline', 'content.draft', 'content.polish' ),
|
||||
'description' => __( 'Generates article content. Active sub-stages depend on the Writing Pipeline mode setting.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'editorial',
|
||||
'label' => __( 'Editorial', 'prautoblogger' ),
|
||||
'icon' => 'dashicons-admin-comments',
|
||||
'model_option' => 'prautoblogger_editor_model',
|
||||
'capability' => 'text→text',
|
||||
'system_key' => 'editor.system',
|
||||
'agent_keys' => array( 'editor.review' ),
|
||||
'description' => __( 'Single-pass editorial review — approves, revises, or rejects the draft.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'image',
|
||||
'label' => __( 'Image', 'prautoblogger' ),
|
||||
'icon' => 'dashicons-format-image',
|
||||
'model_option' => 'prautoblogger_image_model',
|
||||
'capability' => 'image_generation',
|
||||
'system_key' => 'image.rewriter_system',
|
||||
'agent_keys' => array( 'image.style_template' ),
|
||||
'description' => __( 'Image prompt rewriter + diffusion generation. The rewriter LLM uses the Analysis model.', 'prautoblogger' ),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a single step definition by its id, or null when not found.
|
||||
*
|
||||
* @param string $step_id Machine identifier of the step.
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function find( string $step_id ): ?array {
|
||||
foreach ( self::steps() as $step ) {
|
||||
if ( $step['id'] === $step_id ) {
|
||||
return $step;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* All registry keys that the save handler is permitted to update via
|
||||
* the Pipeline Settings page. Any submitted key not in this list is rejected.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public static function allowed_prompt_keys(): array {
|
||||
$keys = array();
|
||||
foreach ( self::steps() as $step ) {
|
||||
if ( ! empty( $step['system_key'] ) ) {
|
||||
$keys[] = $step['system_key'];
|
||||
}
|
||||
foreach ( ( $step['agent_keys'] ?? array() ) as $k ) {
|
||||
$keys[] = $k;
|
||||
}
|
||||
}
|
||||
return array_unique( $keys );
|
||||
}
|
||||
|
||||
/**
|
||||
* All model option names that the save handler may update.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public static function allowed_model_options(): array {
|
||||
$opts = array();
|
||||
foreach ( self::steps() as $step ) {
|
||||
if ( ! empty( $step['model_option'] ) ) {
|
||||
$opts[] = $step['model_option'];
|
||||
}
|
||||
}
|
||||
return array_unique( $opts );
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,19 @@ declare(strict_types=1);
|
||||
* What: a button showing the current model's name + pricing. Clicking it
|
||||
* opens a JS-powered popup (model-picker.js) with a searchable list.
|
||||
* Includes an estimated cost preview below based on historical usage.
|
||||
* For image-generation steps the static cost-per-image price is shown
|
||||
* in the trigger button; the per-generation cost-preview panel is
|
||||
* intentionally omitted (image cost is a fixed $/image, not token-
|
||||
* based, so the historical-token preview formula does not apply).
|
||||
* Who calls it: PRAutoBlogger_Admin_Page::render_field() delegates here
|
||||
* for fields with type 'model_select'.
|
||||
* for fields with type 'model_select', and
|
||||
* PRAutoBlogger_Pipeline_Settings_Renderer::render_model_picker()
|
||||
* for the Pipeline Settings page.
|
||||
* Dependencies: PRAutoBlogger_Model_Registry_Interface (cached registry lookup),
|
||||
* PRAutoBlogger_Cost_Tracker (historical token averages).
|
||||
*
|
||||
* @see admin/class-admin-page.php — Calls render() from the main field switch.
|
||||
* @see admin/class-pipeline-settings-renderer.php — Calls render_model_picker().
|
||||
* @see assets/js/model-picker.js — JS popup that fetches + displays models.
|
||||
* @see services/interface-model-registry.php — Registry interface for find_model().
|
||||
* @see core/class-cost-tracker.php — get_avg_tokens_for_stages() for cost preview.
|
||||
@@ -55,6 +62,9 @@ class PRAutoBlogger_OpenRouter_Model_Field {
|
||||
}
|
||||
|
||||
// Hidden input carries the actual value for the form submission.
|
||||
// The name attribute equals $id so that PRAutoBlogger_Pipeline_Settings_Save_Handler
|
||||
// can read $_POST[$option_name] directly — model-picker.js writes the selected
|
||||
// model id to this input by DOM id, and the browser posts it under this name.
|
||||
printf(
|
||||
'<input type="hidden" id="%s" name="%s" value="%s" />',
|
||||
esc_attr( $id ),
|
||||
@@ -75,7 +85,9 @@ class PRAutoBlogger_OpenRouter_Model_Field {
|
||||
esc_html( $display_price )
|
||||
);
|
||||
|
||||
// Cost preview panel — only for text→text models (not image).
|
||||
// Per-generation cost-preview panel — text→text models only.
|
||||
// Image steps show a static $/image price in the trigger button above;
|
||||
// the token-based historical cost formula does not apply to image generation.
|
||||
if ( 'image_generation' !== $capability && '' !== $value ) {
|
||||
$cost_preview = self::get_cost_preview( $id, $value );
|
||||
if ( $cost_preview ) {
|
||||
@@ -130,7 +142,10 @@ class PRAutoBlogger_OpenRouter_Model_Field {
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a setting ID to its constituent pipeline stages.
|
||||
* Map a text→text setting ID to its constituent pipeline stages for the
|
||||
* cost-preview calculation. Image-generation settings are not listed here
|
||||
* because the cost preview is intentionally skipped for image steps
|
||||
* (static $/image price is shown in the picker trigger instead).
|
||||
*
|
||||
* @param string $field_id Setting ID.
|
||||
*
|
||||
@@ -141,6 +156,9 @@ class PRAutoBlogger_OpenRouter_Model_Field {
|
||||
'prautoblogger_analysis_model' => array( 'analysis' ),
|
||||
'prautoblogger_writing_model' => array( 'outline', 'draft', 'polish' ),
|
||||
'prautoblogger_editor_model' => array( 'review' ),
|
||||
'prautoblogger_research_model' => array( 'research', 'llm_research' ),
|
||||
// prautoblogger_image_model is intentionally absent: image cost is a
|
||||
// fixed $/image (shown in the trigger button), not a token-based preview.
|
||||
);
|
||||
return $map[ $field_id ] ?? array();
|
||||
}
|
||||
|
||||
@@ -72,6 +72,11 @@ class PRAutoBlogger {
|
||||
add_action( 'admin_menu', array( $board_page, 'on_register_menu' ), 11 );
|
||||
add_action( 'admin_enqueue_scripts', array( $board_page, 'on_enqueue_assets' ) );
|
||||
|
||||
// Pipeline Settings: visible submenu, priority 13 (after dossier).
|
||||
$pipeline_page = new PRAutoBlogger_Pipeline_Settings_Page();
|
||||
add_action( 'admin_menu', array( $pipeline_page, 'on_register_menu' ), 13 );
|
||||
add_action( 'admin_enqueue_scripts', array( $pipeline_page, 'on_enqueue_assets' ) );
|
||||
|
||||
// Dossier: link-accessed hidden submenu, priority 12 (after board).
|
||||
$dossier_page = new PRAutoBlogger_Dossier_Page();
|
||||
add_action( 'admin_menu', array( $dossier_page, 'on_register_menu' ), 12 );
|
||||
|
||||
Reference in New Issue
Block a user