From 6dd23351cbec08873663ebb12c9b6aee3c1755fc Mon Sep 17 00:00:00 2001 From: peptiderepo <273788462+peptiderepo@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:06:27 +0700 Subject: [PATCH] =?UTF-8?q?feat(phase2b):=20P2b.5=20=E2=80=94=20Authority?= =?UTF-8?q?=20admin=20controls=20+=20new-stage=20panels=20(#182,=20v0.32.0?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ARCHITECTURE.md | 14 ++- CHANGELOG.md | 10 ++ CONVENTIONS.md | 42 +++++++ ...-settings-option-fields-data-authority.php | 113 ++++++++++++++++++ ...s-pipeline-settings-option-fields-data.php | 39 +++++- .../class-pipeline-settings-option-fields.php | 2 +- .../class-pipeline-settings-renderer.php | 18 +++ .../class-pipeline-settings-save-handler.php | 43 ++++++- .../class-pipeline-settings-step-map.php | 30 +++++ includes/core/class-post-assembler.php | 18 +++ prautoblogger.php | 6 +- .../unit/Core/AuthorityStageOrderStubTest.php | 63 ++++++++++ tests/unit/Core/PostAssemblerImageryTest.php | 88 ++++++++++++++ tests/unit/Core/PublisherSideEffectsTest.php | 4 + tests/unit/Core/PublisherTest.php | 4 + 15 files changed, 482 insertions(+), 12 deletions(-) create mode 100644 includes/admin/class-pipeline-settings-option-fields-data-authority.php create mode 100644 tests/unit/Core/AuthorityStageOrderStubTest.php create mode 100644 tests/unit/Core/PostAssemblerImageryTest.php diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0086410..4da3c07 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 95ab09a..c2ff404 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/CONVENTIONS.md b/CONVENTIONS.md index a2d12a5..d2b1ed9 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -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. diff --git a/includes/admin/class-pipeline-settings-option-fields-data-authority.php b/includes/admin/class-pipeline-settings-option-fields-data-authority.php new file mode 100644 index 0000000..78edf34 --- /dev/null +++ b/includes/admin/class-pipeline-settings-option-fields-data-authority.php @@ -0,0 +1,113 @@ +> + */ + 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> + */ + 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> + */ + 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> + */ + 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' ), + ), + ); + } +} diff --git a/includes/admin/class-pipeline-settings-option-fields-data.php b/includes/admin/class-pipeline-settings-option-fields-data.php index a2cb67b..425c22f 100644 --- a/includes/admin/class-pipeline-settings-option-fields-data.php +++ b/includes/admin/class-pipeline-settings-option-fields-data.php @@ -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> */ 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> */ 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> */ 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' ), diff --git a/includes/admin/class-pipeline-settings-option-fields.php b/includes/admin/class-pipeline-settings-option-fields.php index 6fe2aee..0b52353 100644 --- a/includes/admin/class-pipeline-settings-option-fields.php +++ b/includes/admin/class-pipeline-settings-option-fields.php @@ -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' ); } /** diff --git a/includes/admin/class-pipeline-settings-renderer.php b/includes/admin/class-pipeline-settings-renderer.php index c42e56c..87686ea 100644 --- a/includes/admin/class-pipeline-settings-renderer.php +++ b/includes/admin/class-pipeline-settings-renderer.php @@ -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> */ 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 ); diff --git a/includes/admin/class-pipeline-settings-save-handler.php b/includes/admin/class-pipeline-settings-save-handler.php index 1f0977e..4f8ee43 100644 --- a/includes/admin/class-pipeline-settings-save-handler.php +++ b/includes/admin/class-pipeline-settings-save-handler.php @@ -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. * diff --git a/includes/admin/class-pipeline-settings-step-map.php b/includes/admin/class-pipeline-settings-step-map.php index 23c919b..94b27dc 100644 --- a/includes/admin/class-pipeline-settings-step-map.php +++ b/includes/admin/class-pipeline-settings-step-map.php @@ -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' ), + ), ); } diff --git a/includes/core/class-post-assembler.php b/includes/core/class-post-assembler.php index 6e299e8..1eb77a3 100644 --- a/includes/core/class-post-assembler.php +++ b/includes/core/class-post-assembler.php @@ -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. diff --git a/prautoblogger.php b/prautoblogger.php index 0f51d48..a2f5aa5 100644 --- a/prautoblogger.php +++ b/prautoblogger.php @@ -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. diff --git a/tests/unit/Core/AuthorityStageOrderStubTest.php b/tests/unit/Core/AuthorityStageOrderStubTest.php new file mode 100644 index 0000000..7e5c560 --- /dev/null +++ b/tests/unit/Core/AuthorityStageOrderStubTest.php @@ -0,0 +1,63 @@ +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 ); + } +} diff --git a/tests/unit/Core/PostAssemblerImageryTest.php b/tests/unit/Core/PostAssemblerImageryTest.php new file mode 100644 index 0000000..481b8c3 --- /dev/null +++ b/tests/unit/Core/PostAssemblerImageryTest.php @@ -0,0 +1,88 @@ +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.' ); + } +} diff --git a/tests/unit/Core/PublisherSideEffectsTest.php b/tests/unit/Core/PublisherSideEffectsTest.php index 20cf8c9..33afaf1 100644 --- a/tests/unit/Core/PublisherSideEffectsTest.php +++ b/tests/unit/Core/PublisherSideEffectsTest.php @@ -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( '' ); } /** diff --git a/tests/unit/Core/PublisherTest.php b/tests/unit/Core/PublisherTest.php index 5886650..64f6b30 100644 --- a/tests/unit/Core/PublisherTest.php +++ b/tests/unit/Core/PublisherTest.php @@ -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( '' ); } /**