feat(phase2b): P2b.5 — Authority admin controls + new-stage panels (#182, v0.32.0)

Surfaces the master enable toggle (default OFF), per-category tier-map editor, citation threshold, and curate/seo/authority step panels + research-agent-count/editorial-max-rounds in Pipeline Settings. Image pipeline honors _prautoblogger_imagery_suppressed. Completes Phase 2b. QA APPROVE-WITH-P2 d1447340; CI green (phpcs+phpunit VPS-verified). 3 trivial P2 docs/cleanup follow-ups noted.

Agent-Session: prab-phase2b-5-merge
This commit is contained in:
peptiderepo
2026-06-24 11:06:27 +07:00
committed by GitHub
parent df83c4ebc5
commit 6dd23351cb
15 changed files with 482 additions and 12 deletions

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Authority pipeline field-array data for PRAutoBlogger_Pipeline_Settings_Option_Fields.
*
* What: Declarative field arrays for the three P2b.5 Authority-pipeline contexts:
* curate, seo, and authority. Extracted from Option_Fields_Data to keep
* that class under the 300-line rule. Contains no logic — only data.
* Who calls it: PRAutoBlogger_Pipeline_Settings_Option_Fields_Data::fields_for() (curate/seo/authority).
* Dependencies: WP i18n helpers (__) only.
*
* @see admin/class-pipeline-settings-option-fields-data.php — Core context router.
* @see CONVENTIONS.md §Authority pipeline options — naming + parse pattern.
*/
class PRAutoBlogger_Pipeline_Settings_Option_Fields_Data_Authority {
/**
* Route authority-tier context identifiers to their field definitions.
*
* @param string $context One of: curate|seo|authority.
* @return array<int, array<string, mixed>>
*/
public static function fields_for( string $context ): array {
switch ( $context ) {
case 'curate':
return self::curate_fields();
case 'seo':
return self::seo_fields();
case 'authority':
return self::authority_fields();
default:
return array();
}
}
/**
* Curate step context fields (P2b.5).
* The model picker and prompt editors in the step panel handle the main
* configuration. This textarea exposes optional override instructions
* that are appended to the curate stage system prompt.
*
* @return array<int, array<string, mixed>>
*/
private static function curate_fields(): array {
return array(
array(
'id' => 'prautoblogger_curate_instructions',
'label' => __( 'Curate Instructions', 'prautoblogger' ),
'type' => 'textarea',
'default' => '',
'description' => __( 'Optional custom instructions appended to the research judge (curate) system prompt. Authority tier only.', 'prautoblogger' ),
),
);
}
/**
* SEO step context fields (P2b.5).
*
* @return array<int, array<string, mixed>>
*/
private static function seo_fields(): array {
return array(
array(
'id' => 'prautoblogger_seo_instructions',
'label' => __( 'SEO Instructions', 'prautoblogger' ),
'type' => 'textarea',
'default' => '',
'description' => __( 'Custom instructions for the SEO stage LLM call.', 'prautoblogger' ),
),
);
}
/**
* Authority Settings context fields (P2b.5).
* Controls the master switch, citation gate, and per-category tier map.
* The tier-map textarea is a special read/write surface: the renderer
* converts prautoblogger_category_tiers (serialised array) back to
* 'slug: tier' lines; the save handler calls parse_and_save_category_tiers()
* to convert the textarea back to the array — see Save_Handler::handle_step_settings_save().
*
* @return array<int, array<string, mixed>>
*/
private static function authority_fields(): array {
return array(
array(
'id' => 'prautoblogger_authority_pipeline_enabled',
'label' => __( 'Authority Pipeline', 'prautoblogger' ),
'type' => 'toggle',
'default' => '0',
'description' => __( 'When ON, categories mapped to Authority tier use the full 6-stage pipeline (research to curate to draft to editorial to SEO to publish). Leave OFF until you have reviewed the configuration.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_citation_score_threshold',
'label' => __( 'Citation Score Gate', 'prautoblogger' ),
'type' => 'number',
'default' => 0,
'min' => 0,
'max' => 100,
'description' => __( 'Minimum source quality score (0-100) for auto-publish. Set 0 to skip the gate while calibrating. Calibrate after ~10 Authority runs.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_category_tiers_input',
'label' => __( 'Category Tier Map', 'prautoblogger' ),
'type' => 'textarea',
'default' => '',
'description' => __( 'One line per category slug. Format: slug: authority or slug: economy. New, YMYL, and unclassified categories default to Authority.', 'prautoblogger' ),
),
);
}
}

View File

@@ -10,19 +10,24 @@ declare(strict_types=1);
* from Option_Fields to satisfy the 300-line/file rule. Contains no
* logic — only data. Option keys are the same wp_option names that were
* previously registered in the retired AI Models, Content, and Sources
* Settings tabs.
* Settings tabs. P2b.5 adds curate, seo, and authority contexts (data in
* class-pipeline-settings-option-fields-data-authority.php per 300-line rule),
* and inserts new fields into the research and editorial contexts.
* Who calls it: PRAutoBlogger_Pipeline_Settings_Option_Fields::get_fields_for_context().
* Dependencies: WP i18n helpers (__) only.
* Dependencies: WP i18n helpers (__) only;
* PRAutoBlogger_Pipeline_Settings_Option_Fields_Data_Authority (curate/seo/authority).
*
* @see admin/class-pipeline-settings-option-fields.php — Public API wrapper + sanitizer.
* @see CONVENTIONS.md §Retired Settings Tabs — retirement pattern.
* @see admin/class-pipeline-settings-option-fields.php — Public API wrapper + sanitizer.
* @see admin/class-pipeline-settings-option-fields-data-authority.php — Authority/curate/seo fields.
* @see CONVENTIONS.md §Retired Settings Tabs — retirement pattern.
* @see CONVENTIONS.md §Authority pipeline options — naming pattern for Authority controls.
*/
class PRAutoBlogger_Pipeline_Settings_Option_Fields_Data {
/**
* Route a context string to its field definitions array.
*
* @param string $context One of: global|research|analysis|writer|editorial.
* @param string $context One of: global|research|analysis|writer|editorial|curate|seo|authority.
* @return array<int, array<string, mixed>>
*/
public static function fields_for( string $context ): array {
@@ -37,6 +42,10 @@ class PRAutoBlogger_Pipeline_Settings_Option_Fields_Data {
return self::writer_fields();
case 'editorial':
return self::editorial_fields();
case 'curate':
case 'seo':
case 'authority':
return PRAutoBlogger_Pipeline_Settings_Option_Fields_Data_Authority::fields_for( $context );
default:
return array();
}
@@ -61,11 +70,21 @@ class PRAutoBlogger_Pipeline_Settings_Option_Fields_Data {
/**
* Research step context fields (moved from Sources Settings tab).
* P2b.5: prepends Research Agent Count for the Authority fan-out.
*
* @return array<int, array<string, mixed>>
*/
private static function research_fields(): array {
return array(
array(
'id' => 'prautoblogger_research_agent_count',
'label' => __( 'Research Agent Count', 'prautoblogger' ),
'type' => 'number',
'default' => 3,
'min' => 1,
'max' => 5,
'description' => __( 'Number of specialist research agents in the Authority fan-out (1–5). Default 3. Economy tier ignores this setting.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_enabled_sources',
'label' => __( 'Enabled Sources', 'prautoblogger' ),
@@ -226,11 +245,21 @@ class PRAutoBlogger_Pipeline_Settings_Option_Fields_Data {
/**
* Editorial step context fields (moved from Content Settings tab).
* P2b.5: prepends Max Editorial Rounds for the Authority bounded loop.
*
* @return array<int, array<string, mixed>>
*/
private static function editorial_fields(): array {
return array(
array(
'id' => 'prautoblogger_editorial_max_rounds',
'label' => __( 'Max Editorial Rounds', 'prautoblogger' ),
'type' => 'number',
'default' => 3,
'min' => 1,
'max' => 5,
'description' => __( 'Maximum editor↔writer iterations in the Authority bounded loop. Default 3. Economy tier ignores this setting.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_editor_instructions',
'label' => __( 'Editor Instructions', 'prautoblogger' ),

View File

@@ -29,7 +29,7 @@ class PRAutoBlogger_Pipeline_Settings_Option_Fields {
* @return string[]
*/
public static function contexts(): array {
return array( 'global', 'research', 'analysis', 'writer', 'editorial' );
return array( 'global', 'research', 'analysis', 'writer', 'editorial', 'curate', 'seo', 'authority' );
}
/**

View File

@@ -15,6 +15,9 @@ declare(strict_types=1);
* 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.
* P2b.5 addition: build_option_field_values() has a special case for
* prautoblogger_category_tiers_input — converts the stored serialised
* array back to "slug: tier" lines for the textarea.
* 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),
@@ -79,12 +82,27 @@ class PRAutoBlogger_Pipeline_Settings_Renderer {
/**
* Build current option values for all fields in a context.
*
* Special case: prautoblogger_category_tiers_input is a virtual field whose
* value is derived from the serialised prautoblogger_category_tiers array —
* not a stored string option. The renderer converts the array back to
* "slug: tier" textarea lines here so the template needs no extra logic.
*
* @param string $context Step context identifier.
* @return array<int, array<string, mixed>>
*/
public function build_option_field_values( string $context ): array {
$fields = PRAutoBlogger_Pipeline_Settings_Option_Fields::get_fields_for_context( $context );
foreach ( $fields as &$field ) {
if ( 'prautoblogger_category_tiers_input' === $field['id'] ) {
// Convert the stored array back to "slug: tier" lines for the textarea.
$tiers = get_option( 'prautoblogger_category_tiers', array() );
$lines = array();
foreach ( $tiers as $slug => $tier ) {
$lines[] = esc_attr( $slug ) . ': ' . esc_attr( $tier );
}
$field['current'] = implode( "\n", $lines );
continue;
}
$field['current'] = get_option( $field['id'], $field['default'] ?? '' );
}
unset( $field );

View File

@@ -13,7 +13,10 @@ declare(strict_types=1);
* - save_prompt — creates a new immutable prompt version in the registry.
* - reset_prompt — creates a new version with the canonical default body.
* - save_step_settings — persists one or more relocated option fields for
* a step context (global/research/analysis/writer/editorial).
* a step context (global/research/analysis/writer/editorial/curate/seo/authority).
* For the authority context, also parses the tier-map textarea into
* prautoblogger_category_tiers (serialised array) via
* parse_and_save_category_tiers().
* Saving a prompt NEVER mutates the existing version — the registry's core
* invariant. Model + step-option values read from POST keys matching the
* option name. Prompt keys are slug-matched against the allowlist.
@@ -89,6 +92,7 @@ class PRAutoBlogger_Pipeline_Settings_Save_Handler {
* context reads the corresponding POST value, sanitizes via
* PRAutoBlogger_Pipeline_Settings_Option_Fields::sanitize_option(), and
* calls update_option(). Only allowlisted option names are written.
* For the authority context, also parses the tier-map textarea.
*
* @return array{status: string, message: string}
*/
@@ -116,12 +120,49 @@ class PRAutoBlogger_Pipeline_Settings_Save_Handler {
update_option( $id, $sanitized );
}
// For the authority context, also parse the category-tier textarea into the tiers array.
if ( 'authority' === $context ) {
self::parse_and_save_category_tiers();
}
return array(
'status' => 'saved',
'message' => __( 'Settings saved.', 'prautoblogger' ),
);
}
/**
* Parse the prautoblogger_category_tiers_input textarea and persist
* the result as the prautoblogger_category_tiers serialised array.
*
* Format: one line per category, "slug: authority|economy".
* Invalid tier values default to 'authority' (additive-safety rule).
*
* @return void
*/
private static function parse_and_save_category_tiers(): void {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified by caller
$raw = isset( $_POST['prautoblogger_category_tiers_input'] )
? wp_unslash( $_POST['prautoblogger_category_tiers_input'] )
: '';
$raw = sanitize_textarea_field( $raw );
$lines = array_filter( array_map( 'trim', explode( "\n", $raw ) ) );
$tiers = array();
foreach ( $lines as $line ) {
if ( ! str_contains( $line, ':' ) ) {
continue;
}
[ $slug, $tier ] = explode( ':', $line, 2 );
$slug = sanitize_key( trim( $slug ) );
$tier = sanitize_key( trim( $tier ) );
if ( '' === $slug ) {
continue;
}
$tiers[ $slug ] = ( 'economy' === $tier ) ? 'economy' : 'authority';
}
update_option( 'prautoblogger_category_tiers', $tiers );
}
/**
* Persist a model selection for one step.
*

View File

@@ -48,6 +48,16 @@ class PRAutoBlogger_Pipeline_Settings_Step_Map {
'agent_keys' => array(),
'description' => __( 'LLM deep-research call. The user-side research brief is a setting below, not a registry prompt.', 'prautoblogger' ),
),
array(
'id' => 'curate',
'label' => __( 'Curate', 'prautoblogger' ),
'icon' => 'dashicons-filter',
'model_option' => 'prautoblogger_curate_model',
'capability' => 'text→text',
'system_key' => 'curate.system',
'agent_keys' => array(),
'description' => __( 'Research judge: deduplicates and scores fan-out results. Authority tier only.', 'prautoblogger' ),
),
array(
'id' => 'analysis',
'label' => __( 'Analysis', 'prautoblogger' ),
@@ -78,6 +88,16 @@ class PRAutoBlogger_Pipeline_Settings_Step_Map {
'agent_keys' => array( 'editor.review' ),
'description' => __( 'Single-pass editorial review — approves, revises, or rejects the draft.', 'prautoblogger' ),
),
array(
'id' => 'seo',
'label' => __( 'SEO', 'prautoblogger' ),
'icon' => 'dashicons-chart-line',
'model_option' => 'prautoblogger_seo_model',
'capability' => 'text→text',
'system_key' => 'seo.system',
'agent_keys' => array(),
'description' => __( 'Writes post-meta SEO fields and computes citation score for the publish gate. Authority tier only.', 'prautoblogger' ),
),
array(
'id' => 'image',
'label' => __( 'Image', 'prautoblogger' ),
@@ -88,6 +108,16 @@ class PRAutoBlogger_Pipeline_Settings_Step_Map {
'agent_keys' => array( 'image.style_template' ),
'description' => __( 'Image prompt rewriter + diffusion generation. The rewriter LLM uses the Analysis model.', 'prautoblogger' ),
),
array(
'id' => 'authority',
'label' => __( 'Authority', 'prautoblogger' ),
'icon' => 'dashicons-admin-site-alt3',
'model_option' => null,
'capability' => 'settings',
'system_key' => null,
'agent_keys' => array(),
'description' => __( 'Master switch and per-category tier assignments for the Authority pipeline.', 'prautoblogger' ),
),
);
}

View File

@@ -8,6 +8,10 @@ declare(strict_types=1);
*
* What: After wp_insert_post, assigns categories/tags, links generation logs,
* generates and attaches images, and sanitizes raw LLM content.
* P2-2: attach_generated_images() checks _prautoblogger_imagery_suppressed
* post meta and returns early when set, preventing imagery leaking on
* held Authority articles (citation gate miss, quorum miss, editorial
* escalation, or cost ceiling halt).
* Who calls it: PRAutoBlogger_Publisher::create_post().
* Dependencies: WordPress taxonomy/meta APIs, PRAutoBlogger_Image_Pipeline, PRAutoBlogger_Logger.
*
@@ -235,6 +239,10 @@ class PRAutoBlogger_Post_Assembler {
/**
* Generate and attach images to a published post.
* Non-blocking — logs errors but doesn't re-throw since the post is already created.
* P2-2: Checks _prautoblogger_imagery_suppressed before entering the image
* pipeline. When set, skips image generation entirely so held Authority
* articles (citation gate miss, quorum miss, editorial escalation, or cost
* ceiling halt) don't accidentally receive images.
*
* @param int $post_id Post ID.
* @param PRAutoBlogger_Article_Idea $idea Article idea (contains source IDs).
@@ -242,6 +250,16 @@ class PRAutoBlogger_Post_Assembler {
* @param ?PRAutoBlogger_Cost_Tracker $cost_tracker Optional cost tracker for image generation cost logging.
*/
public static function attach_generated_images( int $post_id, PRAutoBlogger_Article_Idea $idea, array $post_data, ?PRAutoBlogger_Cost_Tracker $cost_tracker = null ): void {
// Honour the imagery-suppressed flag: skip image generation for held articles
// (citation gate miss, quorum miss, editorial escalation, or cost ceiling halt).
if ( '1' === get_post_meta( $post_id, '_prautoblogger_imagery_suppressed', true ) ) {
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Image generation skipped: post %d has _prautoblogger_imagery_suppressed set.', $post_id ),
'publisher'
);
return;
}
// Fetch the original Reddit source data for Image B's source-driven prompt.
// The idea's source_ids point to rows in the source_data table collected
// during the pipeline's collection step.