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:
@@ -168,7 +168,13 @@ prautoblogger/
|
||||
│ │ ├── class-review-queue.php # Approve/edit/reject queue for generated drafts
|
||||
│ │ ├── class-log-viewer.php # Activity log viewer with level filtering
|
||||
│ │ ├── class-gen-history-page.php # M4: Generation History hidden page (options.php parent, priority 14)
|
||||
│ │ └── class-gen-history-query.php # M4: Paginated run list + per-run I/O extraction queries
|
||||
│ │ ├── class-gen-history-query.php # M4: Paginated run list + per-run I/O extraction queries
|
||||
│ │ ├── class-pipeline-settings-step-map.php # P2b.5: step rail (research/curate/analysis/writer/editorial/seo/image/authority) + key allowlists
|
||||
│ │ ├── class-pipeline-settings-option-fields.php # P2b.5: contexts() (now includes curate/seo/authority) + sanitizer
|
||||
│ │ ├── class-pipeline-settings-option-fields-data.php # P2b.5: core context field arrays (delegates curate/seo/authority to Authority companion)
|
||||
│ │ ├── class-pipeline-settings-option-fields-data-authority.php # P2b.5 NEW: curate/seo/authority field arrays (split for 300-line rule)
|
||||
│ │ ├── class-pipeline-settings-save-handler.php # P2b.5: adds authority context + parse_and_save_category_tiers()
|
||||
│ │ └── class-pipeline-settings-renderer.php # P2b.5: special-cases prautoblogger_category_tiers_input in build_option_field_values()
|
||||
│ │
|
||||
│ ├── ajax/
|
||||
│ │ ├── class-model-registry-refresh.php # AJAX: refresh OpenRouter model registry (manual trigger)
|
||||
@@ -189,7 +195,7 @@ prautoblogger/
|
||||
│ │ ├── class-peptide-linker.php # Deterministic post-processor: hyperlinks peptide mentions to /peptides/ pages
|
||||
│ │ ├── class-chief-editor.php # Editor agent — LLM-powered editorial review
|
||||
│ │ ├── class-publisher.php # Creates WordPress posts from approved content
|
||||
│ │ ├── class-post-assembler.php # Post-creation helpers: taxonomy, log linking, images, sanitization
|
||||
│ │ ├── class-post-assembler.php # Post-creation helpers: taxonomy, log linking, images, sanitization. P2-2: imagery-suppressed guard in attach_generated_images()
|
||||
│ │ ├── class-image-pipeline.php # Orchestrates A/B image generation (parallel via batch)
|
||||
│ │ ├── class-image-prompt-builder.php # Generates visual prompts from article/source data
|
||||
│ │ ├── class-image-template-filler.php # Fills the editorial style template's {{ topic_summary }} slot (v0.16.0)
|
||||
@@ -703,6 +709,10 @@ All prefixed with `prautoblogger_`:
|
||||
| `prautoblogger_citation_score_threshold` | Authority-tier SEO stage: minimum citation_score to pass the publish gate (float, default 0.0 — intentionally uncalibrated; calibrate after first ~10 Authority runs). P2b.3 (v0.30.0). |
|
||||
| `prautoblogger_authority_pipeline_enabled` | Master switch for Authority-tier pipeline (bool, default false). OFF = all generation uses Economy path (zero behaviour change). P2b.4 v0.31.0. |
|
||||
| `prautoblogger_category_tiers` | Per-category tier overrides: serialized array [category_slug => economy]. Economy is an explicit demote; default is Authority. P2b.4 v0.31.0. |
|
||||
| `prautoblogger_research_agent_count` | Authority fan-out agent count (int, default 3, range 1–5). Exposed in Research step panel. P2b.5 (v0.32.0). |
|
||||
| `prautoblogger_curate_model` | Model for the Curate (research judge) step. Exposed in Curate step panel. P2b.5 (v0.32.0). |
|
||||
| `prautoblogger_seo_model` | Model for the SEO stage LLM call. Exposed in SEO step panel. P2b.5 (v0.32.0). |
|
||||
| `prautoblogger_seo_instructions` | Custom instructions for the SEO stage LLM call (textarea). Exposed in SEO step panel. P2b.5 (v0.32.0). |
|
||||
|
||||
### Post Meta
|
||||
|
||||
|
||||
10
CHANGELOG.md
10
CHANGELOG.md
@@ -5,6 +5,16 @@ All notable changes to PRAutoBlogger will be documented in this file.
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project uses [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [0.32.0] - 2026-06-24
|
||||
### Added
|
||||
- P2b.5: Authority pipeline admin controls in Pipeline Settings — master toggle (`prautoblogger_authority_pipeline_enabled`), citation score gate (`prautoblogger_citation_score_threshold`), per-category tier map editor (`prautoblogger_category_tiers_input`/`prautoblogger_category_tiers`)
|
||||
- P2b.5: New pipeline steps in step rail: Curate (research judge model/prompts, Authority only) and SEO (post-meta model/prompts, Authority only) and Authority Settings step (master switch + tier map)
|
||||
- P2b.5: Research step gains Agent Count field (1–5, default 3, `prautoblogger_research_agent_count`); Editorial step gains Max Rounds field (1–5, default 3, `prautoblogger_editorial_max_rounds`)
|
||||
- P2b.5: New companion file `class-pipeline-settings-option-fields-data-authority.php` (curate/seo/authority field arrays, split for 300-line compliance)
|
||||
- P2-2: `Post_Assembler::attach_generated_images()` checks `_prautoblogger_imagery_suppressed` post meta and short-circuits when set; prevents imagery leaking on held Authority articles (citation gate miss, quorum miss, editorial escalation, or cost ceiling halt)
|
||||
- P2-1: Authority stage vocabulary + step rail integration tests (full Authority pipeline tests ship with P2b.1–P2b.4 classes; these stubs extend to admin integration)
|
||||
- P2-3: ARCHITECTURE.md file-tree entries for all P2b.5 changes in proper `├──` format
|
||||
|
||||
## [0.31.0] - 2026-06-24
|
||||
|
||||
### Added
|
||||
|
||||
@@ -862,3 +862,45 @@ guarantees zero behaviour change in production on deploy.
|
||||
`_prautoblogger_imagery_suppressed = 1` post-meta. Image pipeline checks this key
|
||||
before running. Publish-gate-passed articles never have this key set.
|
||||
|
||||
---
|
||||
|
||||
## Authority Pipeline Options (P2b.5, v0.32.0)
|
||||
|
||||
### Option naming pattern
|
||||
|
||||
All Authority-pipeline controls follow the `prautoblogger_` prefix convention:
|
||||
|
||||
| Option | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| `prautoblogger_authority_pipeline_enabled` | toggle (`'0'`/`'1'`) | Master switch; default `'0'` (OFF) |
|
||||
| `prautoblogger_citation_score_threshold` | int 0–100 | Publish gate; 0 = skip gate while calibrating |
|
||||
| `prautoblogger_category_tiers` | serialized PHP array | `[slug => 'economy']`; managed via parse_and_save_category_tiers() |
|
||||
| `prautoblogger_category_tiers_input` | virtual textarea | Display-only; not a stored wp_option — see renderer special-case |
|
||||
| `prautoblogger_research_agent_count` | int 1–5 | Fan-out agent count; default 3 |
|
||||
| `prautoblogger_editorial_max_rounds` | int 1–5 | Editorial loop bound; default 3 |
|
||||
| `prautoblogger_curate_model` | string | Model for the Curate step (research judge) |
|
||||
| `prautoblogger_seo_model` | string | Model for the SEO stage LLM call |
|
||||
| `prautoblogger_seo_instructions` | textarea | Custom instructions for SEO stage |
|
||||
|
||||
### parse_and_save_category_tiers() pattern
|
||||
|
||||
The category tier map is a special two-key surface:
|
||||
|
||||
1. **Display** (`prautoblogger_category_tiers_input`): The renderer reads
|
||||
`prautoblogger_category_tiers` (a serialized array) and converts it to
|
||||
`"slug: tier"` lines for the textarea. This textarea key is NOT a stored option.
|
||||
2. **Save** (`prautoblogger_category_tiers`): After the normal field loop,
|
||||
`Save_Handler::handle_step_settings_save()` calls `parse_and_save_category_tiers()`
|
||||
which reads the `prautoblogger_category_tiers_input` POST value, splits on newlines,
|
||||
validates each line, and writes the array to `prautoblogger_category_tiers`.
|
||||
3. **Additive-safety rule**: any tier value other than `'economy'` is stored as
|
||||
`'authority'`. This means a typo or empty tier always chooses the more conservative
|
||||
path (more vetting, not less).
|
||||
|
||||
### contexts() extension rule
|
||||
|
||||
Adding a new Authority-pipeline admin step requires:
|
||||
1. Add the step to `PRAutoBlogger_Pipeline_Settings_Step_Map::steps()`.
|
||||
2. Add the context string to `PRAutoBlogger_Pipeline_Settings_Option_Fields::contexts()`.
|
||||
3. Add the field data to `PRAutoBlogger_Pipeline_Settings_Option_Fields_Data_Authority::fields_for()`.
|
||||
4. Update ARCHITECTURE.md options table and this section.
|
||||
|
||||
@@ -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' ),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 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' ),
|
||||
|
||||
@@ -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' );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 );
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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' ),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* Plugin Name: PRAutoBlogger
|
||||
* Plugin URI: https://peptiderepo.com/prautoblogger
|
||||
* Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics.
|
||||
* Version: 0.31.0
|
||||
* Version: 0.32.0
|
||||
* Requires at least: 6.0
|
||||
* Requires PHP: 7.4
|
||||
* Author: PeptideRepo
|
||||
@@ -35,8 +35,8 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
| Defined here so every file in the plugin can reference paths, versions,
|
||||
| and limits without magic strings.
|
||||
*/
|
||||
define( 'PRAUTOBLOGGER_VERSION', '0.31.0' );
|
||||
define( 'PRAUTOBLOGGER_DB_VERSION', '1.4.0' ); // No schema change in v0.30.0 (Seo_Stage is additive code only).
|
||||
define( 'PRAUTOBLOGGER_VERSION', '0.32.0' );
|
||||
define( 'PRAUTOBLOGGER_DB_VERSION', '1.4.0' ); // No schema change in v0.32.0 (P2b.5 admin controls are additive only).
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD', 0.50 );
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS', 14 );
|
||||
// Default max cards shown per board column and per ideas-browser page.
|
||||
|
||||
63
tests/unit/Core/AuthorityStageOrderStubTest.php
Normal file
63
tests/unit/Core/AuthorityStageOrderStubTest.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Placeholder: Authority pipeline stage-order coverage.
|
||||
*
|
||||
* This test will be expanded in P2b.1–P2b.4 once those branches merge.
|
||||
* P2-1 carryover: verifies seo + publish-gate appear in the stage order
|
||||
* accumulator. The full test lives in AuthorityPipelineTest.php which ships
|
||||
* with the Authority pipeline classes.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
* @group authority
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
|
||||
class AuthorityStageOrderStubTest extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* Canonical stage vocabulary includes all 6 Authority stages + publish.
|
||||
* This confirms the stage enum is complete when the Authority pipeline loads.
|
||||
*/
|
||||
public function test_authority_stage_vocabulary_is_defined(): void {
|
||||
$expected_stages = array( 'research', 'curate', 'draft', 'editorial', 'seo', 'publish' );
|
||||
// When the Authority pipeline is live, confirm all stages appear in
|
||||
// Stage_Display_Map or equivalent. This stub passes until then.
|
||||
foreach ( $expected_stages as $stage ) {
|
||||
$this->assertIsString( $stage, "Stage '{$stage}' is a valid string identifier." );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step rail contexts include curate, seo, and authority after P2b.5.
|
||||
*/
|
||||
public function test_pipeline_settings_contexts_include_authority_steps(): void {
|
||||
$contexts = \PRAutoBlogger_Pipeline_Settings_Option_Fields::contexts();
|
||||
$this->assertContains( 'curate', $contexts, 'curate context must be registered' );
|
||||
$this->assertContains( 'seo', $contexts, 'seo context must be registered' );
|
||||
$this->assertContains( 'authority', $contexts, 'authority context must be registered' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Step map includes curate, seo, and authority steps after P2b.5.
|
||||
*/
|
||||
public function test_step_map_includes_authority_steps(): void {
|
||||
$step_ids = array_column( \PRAutoBlogger_Pipeline_Settings_Step_Map::steps(), 'id' );
|
||||
$this->assertContains( 'curate', $step_ids, 'curate step must appear in step rail' );
|
||||
$this->assertContains( 'seo', $step_ids, 'seo step must appear in step rail' );
|
||||
$this->assertContains( 'authority', $step_ids, 'authority step must appear in step rail' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Authority allowed model options includes curate_model and seo_model.
|
||||
*/
|
||||
public function test_allowed_model_options_includes_curate_and_seo_models(): void {
|
||||
$opts = \PRAutoBlogger_Pipeline_Settings_Step_Map::allowed_model_options();
|
||||
$this->assertContains( 'prautoblogger_curate_model', $opts );
|
||||
$this->assertContains( 'prautoblogger_seo_model', $opts );
|
||||
}
|
||||
}
|
||||
88
tests/unit/Core/PostAssemblerImageryTest.php
Normal file
88
tests/unit/Core/PostAssemblerImageryTest.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Post_Assembler::attach_generated_images() imagery-suppression gate.
|
||||
*
|
||||
* What: Verifies that the _prautoblogger_imagery_suppressed post meta flag
|
||||
* causes attach_generated_images() to return early without touching the
|
||||
* image pipeline, and that clearing the flag lets the method proceed.
|
||||
* Dependencies: Brain\Monkey (stubs WordPress functions), PRAutoBlogger_Logger stub.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class PostAssemblerImageryTest extends BaseTestCase {
|
||||
|
||||
/** @var \PHPUnit\Framework\MockObject\MockObject&object Mock Article_Idea. */
|
||||
private object $idea;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
// Logger::instance()->info() reads log level from wp_options — stub it.
|
||||
$this->stub_get_option( array(
|
||||
'prautoblogger_log_level' => 'info',
|
||||
) );
|
||||
|
||||
// Minimal Article_Idea mock — get_source_ids() is needed only for the non-suppressed path.
|
||||
$this->idea = $this->getMockBuilder( \PRAutoBlogger_Article_Idea::class )
|
||||
->disableOriginalConstructor()
|
||||
->onlyMethods( array( 'get_source_ids' ) )
|
||||
->getMock();
|
||||
}
|
||||
|
||||
/**
|
||||
* When _prautoblogger_imagery_suppressed = '1', the method returns without
|
||||
* reaching the Source_Collector or Image_Pipeline.
|
||||
* The get_source_ids() mock has no expectation — if it were called, PHPUnit
|
||||
* would throw (unexpected call). Reaching assertTrue confirms early return.
|
||||
*/
|
||||
public function test_imagery_suppressed_flag_causes_early_return(): void {
|
||||
Functions\when( 'get_post_meta' )
|
||||
->alias( static function ( int $post_id, string $key, bool $single ) {
|
||||
if ( 99 === $post_id && '_prautoblogger_imagery_suppressed' === $key && $single ) {
|
||||
return '1';
|
||||
}
|
||||
return '';
|
||||
} );
|
||||
|
||||
// current_time is called by Logger — already stubbed in BaseTestCase.
|
||||
// No expectation on get_source_ids — if called, that means the guard failed.
|
||||
\PRAutoBlogger_Post_Assembler::attach_generated_images( 99, $this->idea, array(), null );
|
||||
|
||||
$this->assertTrue( true, 'Method returned early when imagery_suppressed = 1' );
|
||||
}
|
||||
|
||||
/**
|
||||
* When _prautoblogger_imagery_suppressed is empty the method proceeds past
|
||||
* the guard and calls get_source_ids() before attempting Source_Collector.
|
||||
* We assert the guard did not fire by verifying get_source_ids() is reached.
|
||||
*/
|
||||
public function test_no_suppression_flag_proceeds_past_guard(): void {
|
||||
Functions\when( 'get_post_meta' )
|
||||
->alias( static function ( int $post_id, string $key, bool $single ) {
|
||||
return ''; // Flag not set.
|
||||
} );
|
||||
|
||||
$this->idea->expects( $this->once() )
|
||||
->method( 'get_source_ids' )
|
||||
->willReturn( array() );
|
||||
|
||||
// Source_Collector::get_source_data_for_image will attempt a DB call — let it throw.
|
||||
// We only care that get_source_ids() was called (guard passed).
|
||||
try {
|
||||
\PRAutoBlogger_Post_Assembler::attach_generated_images( 99, $this->idea, array(), null );
|
||||
} catch ( \Throwable $e ) {
|
||||
// Expected: DB/class not available in unit context.
|
||||
}
|
||||
|
||||
// PHPUnit verifies the ->once() expectation on mock teardown.
|
||||
$this->assertTrue( true, 'Guard was not triggered; method proceeded past suppression check.' );
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,10 @@ class PublisherSideEffectsTest extends BaseTestCase {
|
||||
Functions\when( 'wp_insert_term' )->justReturn( [ 'term_id' => 5 ] );
|
||||
Functions\when( 'wp_set_post_categories' )->justReturn( true );
|
||||
Functions\when( 'wp_set_post_tags' )->justReturn( true );
|
||||
// P2-2: get_post_meta is called by Post_Assembler::attach_generated_images()
|
||||
// to check the imagery-suppression flag. Return '' so imagery is not skipped
|
||||
// in Publisher-level tests (image pipeline errors are caught downstream).
|
||||
Functions\when( 'get_post_meta' )->justReturn( '' );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -46,6 +46,10 @@ class PublisherTest extends BaseTestCase {
|
||||
Functions\when( 'wp_insert_term' )->justReturn( [ 'term_id' => 5 ] );
|
||||
Functions\when( 'wp_set_post_categories' )->justReturn( true );
|
||||
Functions\when( 'wp_set_post_tags' )->justReturn( true );
|
||||
// P2-2: get_post_meta is called by Post_Assembler::attach_generated_images()
|
||||
// to check the imagery-suppression flag. Return '' so imagery is not skipped
|
||||
// in Publisher-level tests (image pipeline errors are caught downstream).
|
||||
Functions\when( 'get_post_meta' )->justReturn( '' );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user