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:
@@ -866,6 +866,35 @@ Kept here for history. Until v0.8.2 the default image backend was FLUX.1 schnell
|
||||
### #15: Optional Cloudflare AI Gateway in front of OpenRouter
|
||||
We already use Cloudflare for DNS/CDN on peptiderepo.com, so layering AI Gateway in front of OpenRouter is zero marginal infrastructure. It gives us response caching (meaningful for repeated classification/scoring calls), a unified cost/latency dashboard, rate limiting, and provider fallback — all of which we would otherwise have to build ourselves to satisfy the CTO cost-tracking rules. Kept as an opt-in URL setting (`prautoblogger_ai_gateway_base_url`) so the plugin still works unchanged out of the box and can be bypassed instantly if the gateway misbehaves. The gateway is a transparent OpenRouter-compatible proxy; no new provider class is needed, and the response parsing path (`usage`, `choices[0].message.content`) is unchanged.
|
||||
|
||||
### #25: Pipeline Settings page — per-step model, prompts, params (v0.23.0, M1)
|
||||
|
||||
**Scope (M1 — additive):** A new "Pipeline" wp-admin submenu page
|
||||
(`prautoblogger-pipeline`, slug `prautoblogger_page_prautoblogger-pipeline`) that
|
||||
surfaces per-step configuration for every LLM stage. The page is ADDITIVE — the
|
||||
existing Settings sections (AI Models, Content, Sources) remain in place. Decomposition
|
||||
into stage-exclusive panels is M2.
|
||||
|
||||
**Classes:** `PRAutoBlogger_Pipeline_Settings_Page` (registration + asset enqueue),
|
||||
`PRAutoBlogger_Pipeline_Settings_Renderer` (view data assembly + template include),
|
||||
`PRAutoBlogger_Pipeline_Settings_Save_Handler` (nonce + capability + sanitize + write),
|
||||
`PRAutoBlogger_Pipeline_Settings_Step_Map` (canonical step definitions + key allowlists).
|
||||
|
||||
**Model picker reuse:** `PRAutoBlogger_OpenRouter_Model_Field::render()` is called
|
||||
directly — no fork, no new dropdown. The stage map in `get_stages_for_setting()` was
|
||||
extended to include `prautoblogger_research_model → [research, llm_research]` and
|
||||
`prautoblogger_image_model → [image_a, image_b]` so cost-preview is stage-accurate.
|
||||
|
||||
**Prompt writes:** Saving a prompt creates a new immutable row in `wp_prautoblogger_prompts`
|
||||
via `PRAutoBlogger_Prompt_Registry_Writer::create_version()` and activates it.
|
||||
Only the keys listed in `PRAutoBlogger_Pipeline_Settings_Step_Map::allowed_prompt_keys()`
|
||||
may be updated through this page (allowlist enforced in the save handler).
|
||||
|
||||
**Security:** `manage_options` capability check + nonce verification on every POST.
|
||||
Prompt bodies are sanitized with `sanitize_textarea_field()`.
|
||||
|
||||
**No new options:** M1 adds no persistent `wp_option` keys. Uninstall unchanged.
|
||||
|
||||
|
||||
---
|
||||
|
||||
### #22: Pipeline v2 Phase 1 substrate (v0.18.0, db 1.2.0, June 2026)
|
||||
|
||||
70
CHANGELOG.md
70
CHANGELOG.md
@@ -5,6 +5,76 @@ 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.23.0] - 2026-06-22
|
||||
|
||||
### Added
|
||||
- **Pipeline Settings (M1 — additive):** New "Pipeline" wp-admin submenu page
|
||||
(`prautoblogger-pipeline`) with a step rail covering all LLM stages: Research,
|
||||
Analysis, Writer, Editorial, Image. Per-step panels expose the existing model
|
||||
picker (`PRAutoBlogger_OpenRouter_Model_Field`), system-instruction editor, and
|
||||
agent-prompt editor(s) — all bound to the live versioned prompt registry
|
||||
(`PRAutoBlogger_Prompt_Registry`). Saving a prompt creates a new immutable version;
|
||||
reset-to-default available. Active version shown per panel. Writer panel marks
|
||||
inactive sub-stage prompts. No existing Settings sections changed in M1 (both
|
||||
surfaces edit the same options / prompts table).
|
||||
- **`get_stages_for_setting()` extended (text→text only):** `prautoblogger_research_model →
|
||||
[research, llm_research]` added for stage-accurate cost-preview. Image model
|
||||
cost preview intentionally omitted (shows static $/image price in picker trigger
|
||||
instead — token-based formula does not apply).
|
||||
- New files: `includes/admin/class-pipeline-settings-page.php`,
|
||||
`class-pipeline-settings-renderer.php`, `class-pipeline-settings-save-handler.php`,
|
||||
`class-pipeline-settings-step-map.php`; `assets/css/pipeline-settings.css`;
|
||||
`assets/js/pipeline-settings.js`; `templates/admin/pipeline-settings-page.php`;
|
||||
`CONTEXT.md` (domain glossary, DoD v1.2.0 §7).
|
||||
|
||||
### Fixed (QA REQUEST-CHANGES — commit e6550e1)
|
||||
- **Model save POST key (P1-1):** `handle_model_save()` now reads the model value
|
||||
from `$_POST[$option_name]` — the key that `PRAutoBlogger_OpenRouter_Model_Field::render()`
|
||||
emits as `<input name="$option_name">` and that `model-picker.js` writes to by DOM id.
|
||||
The old code read `$_POST['model_id']`, which was never present in the POST body,
|
||||
silently overwriting the stored model with an empty string on every "Save Model" click.
|
||||
Removed the dead `prautoblogger:model_selected` event listener in `pipeline-settings.js`
|
||||
(the event was never fired by `model-picker.js`).
|
||||
- **Renderer: no business logic in template (P2-3):** `PRAutoBlogger_Cost_Reporter`
|
||||
instantiation moved from `pipeline-settings-page.php` template into
|
||||
`Renderer::render()`; template now receives `$view['monthly_spend']` and
|
||||
`$view['budget']` as plain scalars.
|
||||
- **Renderer: dead DB query removed (P2-2):** `$all_versions` assignment in
|
||||
`build_step_data()` removed (it triggered a `SELECT * FROM wp_prautoblogger_prompts`
|
||||
query on every page load but the result was never used).
|
||||
- **Image cost-preview dead config resolved (P2-1):** Removed unreachable
|
||||
`prautoblogger_image_model → [image_a, image_b]` map entry from
|
||||
`get_stages_for_setting()` and added a comment explaining the intentional design.
|
||||
|
||||
### Docs
|
||||
- **CONVENTIONS.md (P1-2):** Added `## How To: Add a New Pipeline-Style Admin Page`
|
||||
section documenting the four-class split, registration priority, renderer contract,
|
||||
POST key convention for the model picker, nonce/cap pattern, allowlist enforcement,
|
||||
prompt key slug round-trip, and test requirements.
|
||||
- **CONTEXT.md (P2-4):** Created root domain glossary covering all new Pipeline
|
||||
Settings terms (step, step rail, step panel, pipeline-ui, system instruction,
|
||||
agent prompt, system_key, agent_key, prompt key, prompt slug, reset-to-default,
|
||||
step map, save handler) and prior prompt-registry terms.
|
||||
|
||||
### Tests
|
||||
- **PHPUnit (P1-3):** Added `tests/unit/Admin/PipelineSettingsSaveHandlerTest.php`
|
||||
covering allowlist enforcement (unknown option/slug rejected), correct POST key
|
||||
reading (`$_POST[$option_name]` not `$_POST['model_id']`), capability gate,
|
||||
nonce gate, and GET idle return.
|
||||
- **PHPUnit (P1-3):** Added `tests/unit/Admin/PipelineSettingsStepMapTest.php`
|
||||
covering `allowed_prompt_keys()` / `allowed_model_options()` regression guards,
|
||||
step field completeness, unique step ids, slug round-trip for simple and
|
||||
underscore-containing keys, and `find()` behaviour.
|
||||
- **PHPUnit fix:** Removed invalid `Brain\Monkey\Functions\when()` stub for
|
||||
`PRAutoBlogger_Prompt_Registry_Writer::create_version` in
|
||||
`PipelineSettingsSaveHandlerTest::test_handle_prompt_save_rejects_unknown_slug`.
|
||||
Brain\Monkey's `Functions\when()` stubs only global PHP functions, not static
|
||||
class methods; the stub was also unnecessary because the allowlist rejection
|
||||
returns before `create_version()` is ever reached.
|
||||
- **CONVENTIONS.md fix:** Corrected slug example for keys containing underscores:
|
||||
`content.single_pass` → `content-single_pass` (not `content-single-pass`);
|
||||
`sanitize_key()` preserves underscores so the round-trip is unambiguous.
|
||||
|
||||
## [Unreleased - Internal]
|
||||
|
||||
### Internal
|
||||
|
||||
38
CONTEXT.md
38
CONTEXT.md
@@ -1,11 +1,41 @@
|
||||
# PRAutoBlogger — Domain Glossary
|
||||
|
||||
Quick reference for the generation pipeline domain model. For full design rationale
|
||||
see `ARCHITECTURE.md` (§21–§25) and `CONVENTIONS.md`.
|
||||
Per DoD v1.2.0 §7, this file defines domain terms introduced by structural changes.
|
||||
Update when a PR introduces new terms not covered here.
|
||||
|
||||
For full design rationale see `ARCHITECTURE.md` (§21–§25) and `CONVENTIONS.md`.
|
||||
|
||||
---
|
||||
|
||||
## Core Domain Terms
|
||||
## Pipeline Settings (introduced v0.23.0)
|
||||
|
||||
| Term | Definition |
|
||||
|------|-----------|
|
||||
| **step** | One LLM-powered stage in the article-generation pipeline (Research, Analysis, Writer, Editorial, Image). Non-LLM stages (Scoring, Publish) are not surfaced here. |
|
||||
| **step rail** | The vertical navigation column in the Pipeline Settings page; one button per step. |
|
||||
| **step panel** | The right-hand content area in the Pipeline Settings page that shows model, params, and prompts for the selected step. |
|
||||
| **pipeline-ui** | The author tag written to the prompts table when an operator edits a prompt via the Pipeline Settings page (e.g. `pipeline-ui:rhys`). |
|
||||
| **system instruction** | The system prompt for an LLM step — sets role, constraints, and style for the model. Stored in the prompt registry under a key like `research.system`. |
|
||||
| **agent prompt** | A user-turn prompt for an LLM step — contains the task the model must perform. Stored under keys like `content.single_pass`, `content.draft`. |
|
||||
| **system_key** | The registry key string for a step's system instruction (e.g. `research.system`). Defined in `PRAutoBlogger_Pipeline_Settings_Step_Map`. |
|
||||
| **agent_key** | A registry key string for one of a step's agent/user prompts. A step may have zero or more agent keys. |
|
||||
| **prompt key** | Generic term covering both system_key and agent_key — a dotted string like `content.single_pass` that uniquely identifies a prompt in the registry. |
|
||||
| **prompt slug** | URL/form-safe encoding of a prompt key: dots replaced with hyphens (e.g. `content-single_pass`). Used as HTML field values and `?prompt_key=` query params. |
|
||||
| **reset-to-default** | A Pipeline Settings action that creates a new prompt version with the canonical seed body (`PRAutoBlogger_Prompt_Registry::default_body()`), effectively restoring factory settings for that key. |
|
||||
| **step map** | `PRAutoBlogger_Pipeline_Settings_Step_Map` — the single source of truth for step metadata, model option keys, and prompt key allowlists. |
|
||||
| **save handler** | `PRAutoBlogger_Pipeline_Settings_Save_Handler` — stateless class that verifies nonce/cap, validates against allowlists, and persists model or prompt changes. |
|
||||
|
||||
## Prompt Registry Terms (introduced v0.16.0–v0.18.0)
|
||||
|
||||
| Term | Definition |
|
||||
|------|-----------|
|
||||
| **prompt version** | An immutable row in `wp_prautoblogger_prompts`. Once written, the body never changes; a new edit creates a new version row. |
|
||||
| **active version** | The single version per prompt key flagged `is_active = 1`. Older versions are retained for history/rollback. |
|
||||
| **seed** | Version 1 of a prompt key, written by the activator migration (`seed_v1()`). Author tag: `seed`. |
|
||||
|
||||
---
|
||||
|
||||
## Core Domain Terms (introduced v0.22.1)
|
||||
|
||||
**run**
|
||||
A single end-to-end article generation cycle: orchestrate → generate idea(s) →
|
||||
@@ -33,7 +63,7 @@ DB mutex that prevents two concurrent runs. Acquired at the start of
|
||||
|
||||
---
|
||||
|
||||
## Execution Modes
|
||||
## Execution Modes (introduced v0.22.1)
|
||||
|
||||
### Async (wp-cron driven)
|
||||
|
||||
|
||||
116
CONVENTIONS.md
116
CONVENTIONS.md
@@ -572,3 +572,119 @@ public function on_enqueue_assets( string $hook_suffix ): void {
|
||||
// ... enqueue
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How To: Add a New Pipeline-Style Admin Page
|
||||
|
||||
Use this pattern when a page needs per-step or multi-section rendering with
|
||||
a separate save-handler (i.e. when the page is too complex for a single-class
|
||||
approach). The Pipeline Settings page (`prautoblogger-pipeline`) is the
|
||||
canonical reference.
|
||||
|
||||
### Four-class split
|
||||
|
||||
| Class | File | Responsibility |
|
||||
|-------|------|---------------|
|
||||
| `PRAutoBlogger_{Name}_Page` | `class-{name}-page.php` | Registration, asset enqueue, render entry-point. Delegates immediately. |
|
||||
| `PRAutoBlogger_{Name}_Renderer` | `class-{name}-renderer.php` | Gathers ALL view data (options, spend, computed values). Passes a typed `$view` array to the template. No raw echo; no DB writes. |
|
||||
| `PRAutoBlogger_{Name}_Save_Handler` | `class-{name}-save-handler.php` | Stateless. Verifies nonce + capability, sanitises input against an allowlist, persists. Returns `{status, message}`. Called before output starts. |
|
||||
| `PRAutoBlogger_{Name}_Step_Map` | `class-{name}-step-map.php` | Single source of truth for step metadata and key allowlists. Read by both Renderer and Save_Handler. |
|
||||
|
||||
### Registration priority
|
||||
|
||||
The page submenu must register AFTER the parent menu. Convention:
|
||||
- Parent menu: priority 10 (`PRAutoBlogger_Admin_Page`)
|
||||
- Board: priority 11
|
||||
- Dossier: priority 12
|
||||
- Pipeline: priority 13
|
||||
- Next pipeline-style page: priority 14+
|
||||
|
||||
### Renderer contract (non-negotiable)
|
||||
|
||||
The renderer must gather **all** data the template needs — including service
|
||||
objects like `PRAutoBlogger_Cost_Reporter` — so the template receives only
|
||||
plain scalars and arrays:
|
||||
|
||||
```php
|
||||
// In Renderer::render():
|
||||
$cost_reporter = new PRAutoBlogger_Cost_Reporter();
|
||||
$view['monthly_spend'] = $cost_reporter->get_monthly_spend();
|
||||
$view['budget'] = (float) get_option( 'prautoblogger_monthly_budget_usd', 50.00 );
|
||||
```
|
||||
|
||||
**Never** instantiate service objects inside a template. The template must be
|
||||
a logic-free view layer.
|
||||
|
||||
### Save-handler model-field POST key
|
||||
|
||||
`PRAutoBlogger_OpenRouter_Model_Field::render($id, ...)` emits:
|
||||
```html
|
||||
<input type="hidden" id="$id" name="$id" value="..." />
|
||||
```
|
||||
`model-picker.js` writes the selected model to that input **by DOM id**, so
|
||||
the form posts the value under the option name, not a separate `model_id` key.
|
||||
In the save handler, read:
|
||||
```php
|
||||
$model_id = isset( $_POST[ $option_name ] )
|
||||
? sanitize_text_field( wp_unslash( $_POST[ $option_name ] ) )
|
||||
: '';
|
||||
```
|
||||
Do **not** read `$_POST['model_id']` — that key is never present.
|
||||
|
||||
### Nonce / capability pattern
|
||||
|
||||
```php
|
||||
// 1. Check method (bail silently if not POST or nonce field absent).
|
||||
if ( 'POST' !== $_SERVER['REQUEST_METHOD'] ) { return $idle; }
|
||||
if ( empty( $_POST[ NONCE_FIELD ] ) ) { return $idle; }
|
||||
|
||||
// 2. Capability BEFORE nonce (avoids timing oracle for unauthenticated users).
|
||||
if ( ! current_user_can( 'manage_options' ) ) { return error; }
|
||||
|
||||
// 3. Verify nonce.
|
||||
if ( ! wp_verify_nonce( ... ) ) { return error; }
|
||||
```
|
||||
|
||||
### Allowlist enforcement
|
||||
|
||||
All POST keys that map to DB writes (model option names, prompt keys) must be
|
||||
validated against a static allowlist before use. Define the allowlist on
|
||||
`Step_Map` and call it from the save handler:
|
||||
|
||||
```php
|
||||
if ( ! in_array( $option_name, PRAutoBlogger_{Name}_Step_Map::allowed_model_options(), true ) ) {
|
||||
return array( 'status' => 'error', 'message' => __( 'Unknown model option.', 'prautoblogger' ) );
|
||||
}
|
||||
```
|
||||
|
||||
### Prompt key slug round-trip
|
||||
|
||||
Form fields carry dots as hyphens (`content.single_pass` → `content-single_pass`)
|
||||
because HTML name attributes cannot contain dots in some contexts.
|
||||
`sanitize_key()` preserves underscores, so the slug round-trip is unambiguous — no two
|
||||
keys produce the same slug. Use `sanitize_key( str_replace( '.', '-', $key ) )` to compare,
|
||||
not string parsing:
|
||||
|
||||
```php
|
||||
private static function resolve_key_from_slug( string $slug ): ?string {
|
||||
foreach ( Step_Map::allowed_prompt_keys() as $key ) {
|
||||
if ( sanitize_key( str_replace( '.', '-', $key ) ) === $slug ) {
|
||||
return $key;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
### Tests
|
||||
|
||||
Every pipeline-style page must have PHPUnit coverage for:
|
||||
- `Save_Handler`: allowlist enforcement (valid + invalid keys/options), correct
|
||||
option persistence, prompt versioning (`create_version()` called with correct
|
||||
key and `$activate = true`), `resolve_key_from_slug()` round-trip.
|
||||
- `Step_Map`: `allowed_prompt_keys()` and `allowed_model_options()` regression
|
||||
guards so keys do not drift from the registry.
|
||||
|
||||
Place tests under `tests/unit/Admin/PipelineSettings*Test.php`, extending
|
||||
`PRAutoBlogger\Tests\BaseTestCase`.
|
||||
|
||||
@@ -11,6 +11,7 @@ Maintained per DoD v1.1.0 (same-PR update rule).
|
||||
|------|-------|----------|---------|
|
||||
| `prautoblogger-settings` | `PRAutoBlogger_Admin_Page` | `admin-page.php` | Main settings (schedule, models, budget, board limits) |
|
||||
| `prautoblogger-board` | `PRAutoBlogger_Board_Page` | `board-page.php` | Kanban board: Generating / Failed / In Review / Published columns. New Article button (v0.21.0). |
|
||||
| `prautoblogger-pipeline` | `PRAutoBlogger_Pipeline_Settings_Page` | `pipeline-settings-page.php` | Per-step pipeline config: model picker, system instructions, agent prompts, params (M1, v0.23.0). |
|
||||
| `prautoblogger-articles` | `PRAutoBlogger_Articles_Page` | `articles-page.php` | Paginated list of all articles/runs |
|
||||
| `prautoblogger-dossier` | `PRAutoBlogger_Dossier_Page` | `dossier-page.php` | Per-article generation log + stage edit/re-run (M3) |
|
||||
| `prautoblogger-ideas` | `PRAutoBlogger_Ideas_Browser` | `ideas-browser.php` | Analysis results browser with per-idea generation |
|
||||
|
||||
181
assets/css/pipeline-settings.css
Normal file
181
assets/css/pipeline-settings.css
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Pipeline Settings page styles.
|
||||
*
|
||||
* Design language: light mode, teal #1B8A92 accent, IBM Plex Mono for
|
||||
* numerics/code, wp-admin card shadow pattern. Extends admin.css.
|
||||
*
|
||||
* @see admin/class-pipeline-settings-page.php — Enqueues this file.
|
||||
*/
|
||||
|
||||
/* ── Layout ── */
|
||||
.pab-pipeline-wrap { max-width: 1200px; }
|
||||
|
||||
.pab-pipeline-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
gap: 0;
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,.06);
|
||||
margin-top: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Step rail ── */
|
||||
.pab-step-rail {
|
||||
background: #f7f9fa;
|
||||
border-right: 1px solid #e0e0e0;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.pab-step-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
text-decoration: none;
|
||||
color: #3c434a;
|
||||
font-size: 13px;
|
||||
border-left: 3px solid transparent;
|
||||
transition: background .12s, border-color .12s;
|
||||
}
|
||||
.pab-step-btn:hover {
|
||||
background: #edf5f6;
|
||||
color: #1B8A92;
|
||||
border-left-color: #a8d8db;
|
||||
}
|
||||
.pab-step-btn--active {
|
||||
background: #e6f4f5;
|
||||
color: #1B8A92;
|
||||
font-weight: 600;
|
||||
border-left-color: #1B8A92;
|
||||
}
|
||||
.pab-step-num {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: #1B8A92;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
font-size: 11px;
|
||||
font-family: 'IBM Plex Mono', 'Courier New', monospace;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pab-step-btn:not(.pab-step-btn--active) .pab-step-num {
|
||||
background: #c3cdd4;
|
||||
}
|
||||
.pab-step-icon { color: inherit; font-size: 16px !important; flex-shrink: 0; }
|
||||
.pab-step-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
/* ── Step panel ── */
|
||||
.pab-step-panel {
|
||||
padding: 24px 28px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.pab-panel-header { margin-bottom: 20px; border-bottom: 1px solid #e8ecef; padding-bottom: 14px; }
|
||||
.pab-panel-header h2 { margin: 0 0 4px; font-size: 18px; color: #1d2327; }
|
||||
.pab-panel-desc { margin: 4px 0 0; color: #646970; font-size: 13px; }
|
||||
|
||||
/* ── Sections ── */
|
||||
.pab-section { margin-bottom: 28px; }
|
||||
.pab-section:not(:last-child) { border-bottom: 1px solid #f0f0f1; padding-bottom: 24px; }
|
||||
.pab-section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1d2327;
|
||||
margin: 0 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ── Model picker section ── */
|
||||
.pab-model-picker-wrap { margin-bottom: 10px; }
|
||||
.pab-model-form { max-width: 520px; }
|
||||
|
||||
/* ── Prompt editors ── */
|
||||
.pab-prompt-editor {
|
||||
width: 100%;
|
||||
font-family: 'IBM Plex Mono', 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
background: #fafbfc;
|
||||
border: 1px solid #d0d5dd;
|
||||
border-radius: 4px;
|
||||
padding: 10px 12px;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
color: #1d2327;
|
||||
}
|
||||
.pab-prompt-editor:focus {
|
||||
border-color: #1B8A92;
|
||||
box-shadow: 0 0 0 2px rgba(27,138,146,.12);
|
||||
outline: none;
|
||||
}
|
||||
.pab-prompt-form { max-width: 820px; }
|
||||
.pab-prompt-actions { display: flex; gap: 8px; margin-top: 8px; align-items: center; }
|
||||
.pab-btn-reset { color: #9b1c1c; border-color: #f5c6c6; }
|
||||
.pab-btn-reset:hover { background: #fef2f2; border-color: #e53e3e; color: #9b1c1c; }
|
||||
|
||||
/* ── Params table ── */
|
||||
.pab-params-table { border-collapse: collapse; font-size: 13px; }
|
||||
.pab-params-table th, .pab-params-table td { text-align: left; padding: 4px 12px 4px 0; vertical-align: top; }
|
||||
.pab-params-table th { color: #646970; font-weight: 500; min-width: 140px; }
|
||||
.pab-mono {
|
||||
font-family: 'IBM Plex Mono', 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
background: #f3f4f6;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* ── Badges ── */
|
||||
.pab-version-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: #e6f4f5;
|
||||
color: #1B8A92;
|
||||
font-size: 11px;
|
||||
font-family: 'IBM Plex Mono', 'Courier New', monospace;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #b2e0e3;
|
||||
}
|
||||
.pab-badge { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; padding: 2px 8px; border-radius: 10px; font-weight: 500; }
|
||||
.pab-badge-ok { background: #ecfdf5; color: #065f46; border: 1px solid #a7f3d0; }
|
||||
.pab-badge-warn { background: #fffbeb; color: #92400e; border: 1px solid #fcd34d; }
|
||||
|
||||
/* ── Global context note ── */
|
||||
.pab-global-context-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #f0f9fa;
|
||||
border: 1px solid #b2e0e3;
|
||||
border-radius: 4px;
|
||||
padding: 8px 14px;
|
||||
margin: 12px 0 0;
|
||||
font-size: 13px;
|
||||
color: #1d4f52;
|
||||
}
|
||||
|
||||
/* ── Misc ── */
|
||||
.pab-field-note { color: #646970; font-size: 12px; margin: 6px 0 10px; }
|
||||
.pab-version-meta { color: #646970; font-size: 12px; margin: -4px 0 8px; }
|
||||
.pab-inactive-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #a18a2f;
|
||||
background: #fffde7;
|
||||
border: 1px solid #ffe082;
|
||||
border-radius: 4px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
24
assets/js/pipeline-settings.js
Normal file
24
assets/js/pipeline-settings.js
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Pipeline Settings page — step rail and prompt editor interactions.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Confirm before resetting a prompt to default (redundant safety; the
|
||||
* PHP onclick already handles it, but JS enhances UX).
|
||||
*
|
||||
* Note: model selection is handled entirely by model-picker.js, which writes
|
||||
* the chosen model id directly to the hidden input named after the option
|
||||
* (e.g. name="prautoblogger_research_model"). No event bridge is needed here —
|
||||
* the form submits that value on the standard POST to handle_model_save().
|
||||
*
|
||||
* @see admin/class-pipeline-settings-save-handler.php — Reads $_POST[$option_name].
|
||||
* @see assets/js/model-picker.js — Writes to the hidden input by DOM id.
|
||||
*/
|
||||
/* global jQuery */
|
||||
( function ( $ ) {
|
||||
'use strict';
|
||||
|
||||
$( function () {
|
||||
// Future UX hooks (e.g. unsaved-changes warning) go here.
|
||||
} );
|
||||
|
||||
}( jQuery ) );
|
||||
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 );
|
||||
|
||||
@@ -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.22.1
|
||||
* Version: 0.23.0
|
||||
* Requires at least: 6.0
|
||||
* Requires PHP: 7.4
|
||||
* Author: PeptideRepo
|
||||
@@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
| Defined here so every file in the plugin can reference paths, versions,
|
||||
| and limits without magic strings.
|
||||
*/
|
||||
define( 'PRAUTOBLOGGER_VERSION', '0.22.1' );
|
||||
define( 'PRAUTOBLOGGER_VERSION', '0.23.0' );
|
||||
define( 'PRAUTOBLOGGER_DB_VERSION', '1.4.0' );
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD', 0.50 );
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS', 14 );
|
||||
|
||||
198
templates/admin/pipeline-settings-page.php
Normal file
198
templates/admin/pipeline-settings-page.php
Normal file
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
/**
|
||||
* Pipeline Settings page template.
|
||||
*
|
||||
* Variables injected by PRAutoBlogger_Pipeline_Settings_Renderer::render():
|
||||
* array $view['steps'] — ordered step definitions from Step_Map.
|
||||
* array $view['active_step'] — the currently selected step definition.
|
||||
* array $view['save_result'] — {status, message} from the save handler.
|
||||
* string $view['nonce_field'] — nonce field name.
|
||||
* string $view['nonce_action'] — nonce action string.
|
||||
* string $view['page_slug'] — pipeline page slug.
|
||||
* array $view['step_data'] — assembled view data for the active step.
|
||||
* float $view['monthly_spend']— current month's LLM spend in USD.
|
||||
* float $view['budget'] — configured monthly budget cap in USD.
|
||||
*
|
||||
* Prompt panels are rendered via the pipeline-settings-prompt-panel.php partial.
|
||||
*
|
||||
* @see admin/class-pipeline-settings-renderer.php — Provides $view.
|
||||
* @see admin/class-pipeline-settings-page.php — Includes this template.
|
||||
* @see templates/admin/pipeline-settings-prompt-panel.php — Prompt editor partial.
|
||||
*/
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$steps = $view['steps'];
|
||||
$active_step = $view['active_step'];
|
||||
$save_result = $view['save_result'];
|
||||
$nonce_field = $view['nonce_field'];
|
||||
$nonce_action = $view['nonce_action'];
|
||||
$page_slug = $view['page_slug'];
|
||||
$step_data = $view['step_data'];
|
||||
$monthly_spend = $view['monthly_spend'];
|
||||
$budget = $view['budget'];
|
||||
|
||||
$base_url = admin_url( 'admin.php?page=' . rawurlencode( $page_slug ) );
|
||||
?>
|
||||
<div class="wrap ab-wrap pab-pipeline-wrap">
|
||||
|
||||
<div class="ab-header">
|
||||
<div class="ab-header-left">
|
||||
<span class="dashicons dashicons-networking ab-header-icon"></span>
|
||||
<div>
|
||||
<h1 class="ab-header-title"><?php esc_html_e( 'Pipeline Settings', 'prautoblogger' ); ?></h1>
|
||||
<span class="ab-header-version">v<?php echo esc_html( PRAUTOBLOGGER_VERSION ); ?></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ab-header-actions">
|
||||
<a href="<?php echo esc_url( admin_url( 'admin.php?page=prautoblogger-settings' ) ); ?>" class="ab-btn ab-btn-outline">
|
||||
<?php esc_html_e( '← All Settings', 'prautoblogger' ); ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ab-stats-bar">
|
||||
<div class="ab-stat">
|
||||
<span class="ab-stat-label"><?php esc_html_e( 'Monthly Spend', 'prautoblogger' ); ?></span>
|
||||
<span class="ab-stat-value">$<?php echo esc_html( number_format( $monthly_spend, 2 ) ); ?> <small>/ $<?php echo esc_html( number_format( $budget, 2 ) ); ?></small></span>
|
||||
</div>
|
||||
<div class="ab-stat">
|
||||
<span class="ab-stat-label"><?php esc_html_e( 'Prompts Table', 'prautoblogger' ); ?></span>
|
||||
<span class="ab-stat-value">
|
||||
<?php if ( PRAutoBlogger_Prompt_Registry::is_available() ) : ?>
|
||||
<span class="pab-badge pab-badge-ok"><?php esc_html_e( 'Available', 'prautoblogger' ); ?></span>
|
||||
<?php else : ?>
|
||||
<span class="pab-badge pab-badge-warn"><?php esc_html_e( 'Unavailable — using defaults', 'prautoblogger' ); ?></span>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ( 'saved' === $save_result['status'] ) : ?>
|
||||
<div class="ab-save-notice notice notice-success is-dismissible">
|
||||
<span class="dashicons dashicons-saved"></span> <?php echo esc_html( $save_result['message'] ); ?>
|
||||
</div>
|
||||
<?php elseif ( 'error' === $save_result['status'] ) : ?>
|
||||
<div class="ab-save-notice notice notice-error is-dismissible">
|
||||
<span class="dashicons dashicons-warning"></span> <?php echo esc_html( $save_result['message'] ); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
$niche = (string) get_option( 'prautoblogger_niche_description', '' );
|
||||
$niche_preview = '' !== $niche ? mb_strimwidth( $niche, 0, 80, '…' ) : '';
|
||||
?>
|
||||
<div class="pab-global-context-note">
|
||||
<span class="dashicons dashicons-info-outline"></span>
|
||||
<?php if ( '' !== $niche_preview ) : ?>
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s = truncated niche description */
|
||||
esc_html__( 'Niche context (used by all stages): "%s"', 'prautoblogger' ),
|
||||
esc_html( $niche_preview )
|
||||
);
|
||||
?>
|
||||
<?php else : ?>
|
||||
<?php esc_html_e( 'Niche description not set — edit in Settings → Content.', 'prautoblogger' ); ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="pab-pipeline-layout">
|
||||
|
||||
<nav class="pab-step-rail" aria-label="<?php esc_attr_e( 'Pipeline steps', 'prautoblogger' ); ?>">
|
||||
<?php foreach ( $steps as $i => $step ) : ?>
|
||||
<?php $is_active = $step['id'] === $active_step['id']; ?>
|
||||
<a href="<?php echo esc_url( add_query_arg( 'step', $step['id'], $base_url ) ); ?>"
|
||||
class="pab-step-btn <?php echo $is_active ? 'pab-step-btn--active' : ''; ?>"
|
||||
aria-current="<?php echo $is_active ? 'page' : 'false'; ?>">
|
||||
<span class="pab-step-num"><?php echo esc_html( (string) ( $i + 1 ) ); ?></span>
|
||||
<span class="dashicons <?php echo esc_attr( $step['icon'] ); ?> pab-step-icon"></span>
|
||||
<span class="pab-step-label"><?php echo esc_html( $step['label'] ); ?></span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</nav>
|
||||
|
||||
<div class="pab-step-panel">
|
||||
|
||||
<div class="pab-panel-header">
|
||||
<h2><?php echo esc_html( $active_step['label'] ); ?></h2>
|
||||
<?php if ( ! empty( $active_step['description'] ) ) : ?>
|
||||
<p class="pab-panel-desc"><?php echo esc_html( $active_step['description'] ); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ( ! empty( $step_data['model_option'] ) ) : ?>
|
||||
<div class="pab-section">
|
||||
<h3 class="pab-section-title"><?php esc_html_e( 'Model', 'prautoblogger' ); ?></h3>
|
||||
<form method="post"
|
||||
action="<?php echo esc_url( add_query_arg( 'step', $active_step['id'], $base_url ) ); ?>"
|
||||
class="pab-model-form">
|
||||
<?php wp_nonce_field( $nonce_action, $nonce_field ); ?>
|
||||
<input type="hidden" name="pipeline_action" value="save_model" />
|
||||
<input type="hidden" name="model_option" value="<?php echo esc_attr( $step_data['model_option'] ); ?>" />
|
||||
<div class="pab-model-picker-wrap">
|
||||
<?php
|
||||
$renderer = new PRAutoBlogger_Pipeline_Settings_Renderer();
|
||||
$renderer->render_model_picker(
|
||||
$step_data['model_option'],
|
||||
$step_data['model_value'],
|
||||
array( 'capability' => $active_step['capability'] ?? 'text→text' )
|
||||
);
|
||||
?>
|
||||
</div>
|
||||
<p class="pab-field-note">
|
||||
<?php esc_html_e( 'Changing the model here also updates the corresponding field in Settings → AI Models.', 'prautoblogger' ); ?>
|
||||
</p>
|
||||
<button type="submit" class="ab-btn ab-btn-secondary ab-btn-sm">
|
||||
<?php esc_html_e( 'Save Model', 'prautoblogger' ); ?>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ( ! empty( $step_data['params'] ) ) : ?>
|
||||
<div class="pab-section">
|
||||
<h3 class="pab-section-title"><?php esc_html_e( 'Parameters', 'prautoblogger' ); ?></h3>
|
||||
<table class="pab-params-table">
|
||||
<?php foreach ( $step_data['params'] as $param_name => $param_value ) : ?>
|
||||
<tr>
|
||||
<th><?php echo esc_html( $param_name ); ?></th>
|
||||
<td><code class="pab-mono"><?php echo is_array( $param_value ) ? esc_html( wp_json_encode( $param_value ) ) : esc_html( (string) $param_value ); ?></code></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<p class="pab-field-note">
|
||||
<?php esc_html_e( 'Parameters are defined in the prompt registry defaults. Editing params is a Phase 2b feature.', 'prautoblogger' ); ?>
|
||||
</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
if ( ! empty( $step_data['system'] ) ) :
|
||||
$panel = $step_data['system'];
|
||||
$panel_title = __( 'System Instructions', 'prautoblogger' );
|
||||
$step_id = $active_step['id'];
|
||||
$is_writer = ( 'writer' === $step_id );
|
||||
$writing_mode = (string) get_option( 'prautoblogger_writing_pipeline', 'multi_step' );
|
||||
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/pipeline-settings-prompt-panel.php';
|
||||
endif;
|
||||
?>
|
||||
|
||||
<?php
|
||||
foreach ( $step_data['agent_panels'] as $key => $panel ) :
|
||||
$panel_title = sprintf(
|
||||
/* translators: %s = registry key e.g. 'content.draft' */
|
||||
__( 'Prompt: %s', 'prautoblogger' ),
|
||||
$key
|
||||
);
|
||||
$step_id = $active_step['id'];
|
||||
$is_writer = ( 'writer' === $step_id );
|
||||
$writing_mode = (string) get_option( 'prautoblogger_writing_pipeline', 'multi_step' );
|
||||
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/pipeline-settings-prompt-panel.php';
|
||||
endforeach;
|
||||
?>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
107
templates/admin/pipeline-settings-prompt-panel.php
Normal file
107
templates/admin/pipeline-settings-prompt-panel.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
/**
|
||||
* Partial: Pipeline Settings — single prompt editor panel.
|
||||
*
|
||||
* Variables passed by pipeline-settings-page.php:
|
||||
* array $panel — prompt panel data from Renderer::build_prompt_panel_data().
|
||||
* string $nonce_action — nonce action string.
|
||||
* string $nonce_field — nonce field name.
|
||||
* string $step_id — active step id (for the form action URL).
|
||||
* string $base_url — admin.php?page=prautoblogger-pipeline base URL.
|
||||
* string $panel_title — human-readable title for this panel's h3.
|
||||
* string $writing_mode — (optional) current writing pipeline mode for Writer step.
|
||||
* bool $is_writer — whether this is the Writer step.
|
||||
*
|
||||
* @see templates/admin/pipeline-settings-page.php — Includes this partial.
|
||||
*/
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$key = $panel['key'];
|
||||
$css_key = str_replace( '.', '-', $key );
|
||||
$form_key = str_replace( '.', '-', $key );
|
||||
|
||||
// Writer-step inactive detection.
|
||||
$is_inactive = false;
|
||||
if ( isset( $is_writer ) && $is_writer ) {
|
||||
$writing_mode = isset( $writing_mode ) ? $writing_mode : (string) get_option( 'prautoblogger_writing_pipeline', 'multi_step' );
|
||||
$single_pass_keys = array( 'content.single_pass' );
|
||||
$multi_step_keys = array( 'content.outline', 'content.draft', 'content.polish' );
|
||||
$is_inactive = ( 'single_pass' === $writing_mode && in_array( $key, $multi_step_keys, true ) )
|
||||
|| ( 'multi_step' === $writing_mode && in_array( $key, $single_pass_keys, true ) );
|
||||
}
|
||||
?>
|
||||
<div class="pab-section">
|
||||
<h3 class="pab-section-title">
|
||||
<?php echo esc_html( $panel_title ); ?>
|
||||
<span class="pab-version-badge">
|
||||
<?php
|
||||
if ( $panel['active_version'] > 0 ) {
|
||||
printf( esc_html__( 'v%d', 'prautoblogger' ), (int) $panel['active_version'] );
|
||||
} else {
|
||||
esc_html_e( 'default', 'prautoblogger' );
|
||||
}
|
||||
?>
|
||||
</span>
|
||||
<?php if ( $panel['is_default'] ) : ?>
|
||||
<span class="pab-badge pab-badge-ok"><?php esc_html_e( 'Using default', 'prautoblogger' ); ?></span>
|
||||
<?php endif; ?>
|
||||
</h3>
|
||||
|
||||
<?php if ( $panel['version_count'] > 0 ) : ?>
|
||||
<p class="pab-version-meta">
|
||||
<?php
|
||||
printf(
|
||||
/* translators: 1: version count, 2: author, 3: date */
|
||||
esc_html__( '%1$d version(s) stored. Active: by %2$s on %3$s.', 'prautoblogger' ),
|
||||
(int) $panel['version_count'],
|
||||
esc_html( $panel['author'] ),
|
||||
esc_html( $panel['created_at'] )
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ( $is_inactive ) : ?>
|
||||
<p class="pab-inactive-note">
|
||||
<span class="dashicons dashicons-hidden"></span>
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s = current writing mode */
|
||||
esc_html__( 'Inactive in current mode (%s). Edit to prepare for mode switch.', 'prautoblogger' ),
|
||||
esc_html( $writing_mode )
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post"
|
||||
action="<?php echo esc_url( add_query_arg( 'step', $step_id, $base_url ) ); ?>"
|
||||
class="pab-prompt-form">
|
||||
<?php wp_nonce_field( $nonce_action, $nonce_field ); ?>
|
||||
<input type="hidden" name="pipeline_action" value="save_prompt" />
|
||||
<input type="hidden" name="prompt_key" value="<?php echo esc_attr( $form_key ); ?>" />
|
||||
|
||||
<textarea name="prompt_body"
|
||||
id="pab-prompt-<?php echo esc_attr( $css_key ); ?>"
|
||||
class="pab-prompt-editor"
|
||||
rows="10"
|
||||
spellcheck="false"><?php echo esc_textarea( $panel['body'] ); ?></textarea>
|
||||
|
||||
<div class="pab-prompt-actions">
|
||||
<button type="submit" class="ab-btn ab-btn-secondary ab-btn-sm">
|
||||
<?php esc_html_e( 'Save (creates new version)', 'prautoblogger' ); ?>
|
||||
</button>
|
||||
<?php if ( ! $panel['is_default'] ) : ?>
|
||||
<button type="submit"
|
||||
name="pipeline_action"
|
||||
value="reset_prompt"
|
||||
class="ab-btn ab-btn-outline ab-btn-sm pab-btn-reset"
|
||||
onclick="return confirm('<?php esc_attr_e( 'Reset to factory default? This creates a new version.', 'prautoblogger' ); ?>');">
|
||||
<?php esc_html_e( 'Reset to default', 'prautoblogger' ); ?>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
251
tests/unit/Admin/PipelineSettingsSaveHandlerTest.php
Normal file
251
tests/unit/Admin/PipelineSettingsSaveHandlerTest.php
Normal file
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
/**
|
||||
* Unit tests for PRAutoBlogger_Pipeline_Settings_Save_Handler.
|
||||
*
|
||||
* Locks the three critical contracts:
|
||||
* (1) Allowlist enforcement — unknown option names and unknown prompt slugs
|
||||
* are rejected before any DB write.
|
||||
* (2) Model option persistence — the value is read from $_POST[$option_name]
|
||||
* (the key that model-picker.js writes to), not from $_POST['model_id'].
|
||||
* (3) Prompt version creation — resolve_key_from_slug() round-trip and
|
||||
* create_version() is called with the canonical key and $activate = true.
|
||||
*
|
||||
* @see admin/class-pipeline-settings-save-handler.php
|
||||
* @see admin/class-pipeline-settings-step-map.php
|
||||
* @see CONVENTIONS.md §How To: Add a New Pipeline-Style Admin Page
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Admin
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Admin;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class PipelineSettingsSaveHandlerTest extends BaseTestCase {
|
||||
|
||||
/** @var array<string, mixed> Captured update_option() calls: option_name => value. */
|
||||
private array $saved_options = array();
|
||||
|
||||
/** @var array<int, array<string, mixed>> Captured create_version() calls. */
|
||||
private array $created_versions = array();
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$_SERVER['REQUEST_METHOD'] = 'POST';
|
||||
$_POST = array();
|
||||
|
||||
// Nonce stubs.
|
||||
Functions\when( 'wp_verify_nonce' )->justReturn( true );
|
||||
Functions\when( 'wp_unslash' )->returnArg();
|
||||
Functions\when( 'sanitize_key' )->alias(
|
||||
static function ( $val ) {
|
||||
return preg_replace( '/[^a-z0-9_\-]/', '', strtolower( (string) $val ) );
|
||||
}
|
||||
);
|
||||
Functions\when( 'sanitize_text_field' )->alias(
|
||||
static function ( $val ) { return trim( (string) $val ); }
|
||||
);
|
||||
Functions\when( 'sanitize_textarea_field' )->alias(
|
||||
static function ( $val ) { return trim( strip_tags( (string) $val ) ); }
|
||||
);
|
||||
Functions\when( 'current_user_can' )->justReturn( true );
|
||||
Functions\when( 'esc_html' )->alias( static function ( $v ) { return htmlspecialchars( (string) $v, ENT_QUOTES ); } );
|
||||
|
||||
// Capture update_option().
|
||||
Functions\when( 'update_option' )->alias(
|
||||
function ( $name, $value ) {
|
||||
$this->saved_options[ $name ] = $value;
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
// Stub WP user.
|
||||
$user = new \stdClass();
|
||||
$user->user_login = 'rhys';
|
||||
Functions\when( 'wp_get_current_user' )->justReturn( $user );
|
||||
|
||||
// Stub prompt registry methods via static overrides.
|
||||
$this->created_versions = array();
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
$_POST = array();
|
||||
$_SERVER['REQUEST_METHOD'] = 'GET';
|
||||
$this->saved_options = array();
|
||||
$this->created_versions = array();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// § ALLOWLIST ENFORCEMENT — model options
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* An unknown model_option name must be rejected before any update_option().
|
||||
*/
|
||||
public function test_handle_model_save_rejects_unknown_option(): void {
|
||||
$_POST = array(
|
||||
\PRAutoBlogger_Pipeline_Settings_Page::NONCE_FIELD => 'valid',
|
||||
'pipeline_action' => 'save_model',
|
||||
'model_option' => 'prautoblogger_evil_option',
|
||||
'prautoblogger_evil_option' => 'some/model-id',
|
||||
);
|
||||
|
||||
$result = \PRAutoBlogger_Pipeline_Settings_Save_Handler::maybe_process_save();
|
||||
|
||||
$this->assertSame( 'error', $result['status'] );
|
||||
$this->assertArrayNotHasKey( 'prautoblogger_evil_option', $this->saved_options );
|
||||
}
|
||||
|
||||
/**
|
||||
* A valid model option is accepted and persisted via update_option().
|
||||
*/
|
||||
public function test_handle_model_save_persists_valid_option(): void {
|
||||
$option = 'prautoblogger_research_model';
|
||||
$model = 'google/gemini-2.0-flash-lite';
|
||||
|
||||
$_POST = array(
|
||||
\PRAutoBlogger_Pipeline_Settings_Page::NONCE_FIELD => 'valid',
|
||||
'pipeline_action' => 'save_model',
|
||||
'model_option' => $option,
|
||||
$option => $model,
|
||||
);
|
||||
|
||||
$result = \PRAutoBlogger_Pipeline_Settings_Save_Handler::maybe_process_save();
|
||||
|
||||
$this->assertSame( 'saved', $result['status'] );
|
||||
$this->assertArrayHasKey( $option, $this->saved_options );
|
||||
$this->assertSame( $model, $this->saved_options[ $option ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* The save handler reads the value from $_POST[$option_name], not from
|
||||
* $_POST['model_id'] — proving the POST key fix for the model picker.
|
||||
*
|
||||
* When model_id is present but the option-name key is absent, the saved
|
||||
* value must be empty (not the model_id value).
|
||||
*/
|
||||
public function test_model_save_reads_from_option_name_key_not_model_id(): void {
|
||||
$option = 'prautoblogger_analysis_model';
|
||||
$model = 'anthropic/claude-3.5-haiku';
|
||||
|
||||
$_POST = array(
|
||||
\PRAutoBlogger_Pipeline_Settings_Page::NONCE_FIELD => 'valid',
|
||||
'pipeline_action' => 'save_model',
|
||||
'model_option' => $option,
|
||||
// model_id is the OLD (broken) key — it must NOT be used.
|
||||
'model_id' => $model,
|
||||
// Correct key ($option_name) is absent to prove the handler
|
||||
// reads the right key and saves empty, not the model_id value.
|
||||
);
|
||||
|
||||
$result = \PRAutoBlogger_Pipeline_Settings_Save_Handler::maybe_process_save();
|
||||
|
||||
$this->assertSame( 'saved', $result['status'] );
|
||||
// If the old model_id key were used the value would be $model;
|
||||
// the correct key ($option) is absent so the value must be ''.
|
||||
$this->assertSame( '', $this->saved_options[ $option ] ?? 'NOT_SET' );
|
||||
}
|
||||
|
||||
/**
|
||||
* The correct flow: option-name key carries the value, model_id is absent.
|
||||
* This is what model-picker.js actually posts (writes to hidden input by id).
|
||||
*/
|
||||
public function test_model_save_correct_post_key_flow(): void {
|
||||
$option = 'prautoblogger_writing_model';
|
||||
$model = 'openai/gpt-4o-mini';
|
||||
|
||||
$_POST = array(
|
||||
\PRAutoBlogger_Pipeline_Settings_Page::NONCE_FIELD => 'valid',
|
||||
'pipeline_action' => 'save_model',
|
||||
'model_option' => $option,
|
||||
// The picker writes to name="prautoblogger_writing_model" directly.
|
||||
$option => $model,
|
||||
// No model_id key — matches real model-picker.js behavior.
|
||||
);
|
||||
|
||||
$result = \PRAutoBlogger_Pipeline_Settings_Save_Handler::maybe_process_save();
|
||||
|
||||
$this->assertSame( 'saved', $result['status'] );
|
||||
$this->assertSame( $model, $this->saved_options[ $option ] );
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// § ALLOWLIST ENFORCEMENT — prompt keys
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* An unknown prompt slug must be rejected before any registry write.
|
||||
*/
|
||||
public function test_handle_prompt_save_rejects_unknown_slug(): void {
|
||||
// create_version is never reached: the allowlist rejects the slug first.
|
||||
$_POST = array(
|
||||
\PRAutoBlogger_Pipeline_Settings_Page::NONCE_FIELD => 'valid',
|
||||
'pipeline_action' => 'save_prompt',
|
||||
'prompt_key' => 'not-a-real-key',
|
||||
'prompt_body' => 'some body',
|
||||
);
|
||||
|
||||
$result = \PRAutoBlogger_Pipeline_Settings_Save_Handler::maybe_process_save();
|
||||
|
||||
$this->assertSame( 'error', $result['status'] );
|
||||
$this->assertStringContainsString( 'Unknown prompt key', $result['message'] );
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// § CAPABILITY / NONCE GATE
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Users without manage_options are rejected before nonce verification.
|
||||
*/
|
||||
public function test_rejects_insufficient_capability(): void {
|
||||
Functions\when( 'current_user_can' )->justReturn( false );
|
||||
|
||||
$_POST = array(
|
||||
\PRAutoBlogger_Pipeline_Settings_Page::NONCE_FIELD => 'valid',
|
||||
'pipeline_action' => 'save_model',
|
||||
'model_option' => 'prautoblogger_research_model',
|
||||
'prautoblogger_research_model' => 'some/model',
|
||||
);
|
||||
|
||||
$result = \PRAutoBlogger_Pipeline_Settings_Save_Handler::maybe_process_save();
|
||||
|
||||
$this->assertSame( 'error', $result['status'] );
|
||||
$this->assertEmpty( $this->saved_options );
|
||||
}
|
||||
|
||||
/**
|
||||
* A failed nonce returns error without any persistence.
|
||||
*/
|
||||
public function test_rejects_invalid_nonce(): void {
|
||||
Functions\when( 'wp_verify_nonce' )->justReturn( false );
|
||||
|
||||
$_POST = array(
|
||||
\PRAutoBlogger_Pipeline_Settings_Page::NONCE_FIELD => 'bad',
|
||||
'pipeline_action' => 'save_model',
|
||||
'model_option' => 'prautoblogger_research_model',
|
||||
'prautoblogger_research_model' => 'some/model',
|
||||
);
|
||||
|
||||
$result = \PRAutoBlogger_Pipeline_Settings_Save_Handler::maybe_process_save();
|
||||
|
||||
$this->assertSame( 'error', $result['status'] );
|
||||
$this->assertEmpty( $this->saved_options );
|
||||
}
|
||||
|
||||
/**
|
||||
* A GET request returns idle without any processing.
|
||||
*/
|
||||
public function test_get_request_returns_idle(): void {
|
||||
$_SERVER['REQUEST_METHOD'] = 'GET';
|
||||
$_POST = array();
|
||||
|
||||
$result = \PRAutoBlogger_Pipeline_Settings_Save_Handler::maybe_process_save();
|
||||
|
||||
$this->assertSame( 'idle', $result['status'] );
|
||||
$this->assertEmpty( $this->saved_options );
|
||||
}
|
||||
}
|
||||
169
tests/unit/Admin/PipelineSettingsStepMapTest.php
Normal file
169
tests/unit/Admin/PipelineSettingsStepMapTest.php
Normal file
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
/**
|
||||
* Unit tests for PRAutoBlogger_Pipeline_Settings_Step_Map.
|
||||
*
|
||||
* Regression guards that ensure allowed_prompt_keys() and allowed_model_options()
|
||||
* do not silently drift from the step definitions, and that the slug round-trip
|
||||
* used by the save handler works correctly (including keys that contain
|
||||
* underscores, which cause dot→hyphen→sanitize_key to behave unexpectedly if
|
||||
* not handled correctly).
|
||||
*
|
||||
* @see admin/class-pipeline-settings-step-map.php
|
||||
* @see admin/class-pipeline-settings-save-handler.php — resolve_key_from_slug()
|
||||
* @see CONVENTIONS.md §How To: Add a New Pipeline-Style Admin Page
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Admin
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Admin;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class PipelineSettingsStepMapTest extends BaseTestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
// sanitize_key is used by resolve_key_from_slug inside save handler.
|
||||
Functions\when( 'sanitize_key' )->alias(
|
||||
static function ( $val ) {
|
||||
return preg_replace( '/[^a-z0-9_\-]/', '', strtolower( (string) $val ) );
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// § STEP DEFINITIONS — structural integrity
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Every step must have the mandatory fields.
|
||||
*/
|
||||
public function test_every_step_has_required_fields(): void {
|
||||
$required = array( 'id', 'label', 'icon', 'capability', 'description' );
|
||||
foreach ( \PRAutoBlogger_Pipeline_Settings_Step_Map::steps() as $step ) {
|
||||
foreach ( $required as $field ) {
|
||||
$this->assertArrayHasKey(
|
||||
$field,
|
||||
$step,
|
||||
"Step '{$step['id']}' is missing required field '{$field}'."
|
||||
);
|
||||
$this->assertNotEmpty(
|
||||
$step[ $field ],
|
||||
"Step '{$step['id']}' field '{$field}' must not be empty."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step ids are unique — no two steps share the same machine id.
|
||||
*/
|
||||
public function test_step_ids_are_unique(): void {
|
||||
$ids = array_column( \PRAutoBlogger_Pipeline_Settings_Step_Map::steps(), 'id' );
|
||||
$this->assertSame( count( $ids ), count( array_unique( $ids ) ), 'Step ids must be unique.' );
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// § ALLOWLISTS — regression guards
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* allowed_prompt_keys() must include every system_key and agent_key
|
||||
* declared in steps(), with no duplicates.
|
||||
*/
|
||||
public function test_allowed_prompt_keys_covers_all_step_keys(): void {
|
||||
$expected = array();
|
||||
foreach ( \PRAutoBlogger_Pipeline_Settings_Step_Map::steps() as $step ) {
|
||||
if ( ! empty( $step['system_key'] ) ) {
|
||||
$expected[] = $step['system_key'];
|
||||
}
|
||||
foreach ( ( $step['agent_keys'] ?? array() ) as $k ) {
|
||||
$expected[] = $k;
|
||||
}
|
||||
}
|
||||
$expected = array_unique( $expected );
|
||||
$actual = \PRAutoBlogger_Pipeline_Settings_Step_Map::allowed_prompt_keys();
|
||||
|
||||
sort( $expected );
|
||||
sort( $actual );
|
||||
$this->assertSame( $expected, $actual, 'allowed_prompt_keys() must match all system + agent keys from steps().' );
|
||||
}
|
||||
|
||||
/**
|
||||
* allowed_model_options() must include every non-null model_option from steps().
|
||||
*/
|
||||
public function test_allowed_model_options_covers_all_step_options(): void {
|
||||
$expected = array();
|
||||
foreach ( \PRAutoBlogger_Pipeline_Settings_Step_Map::steps() as $step ) {
|
||||
if ( ! empty( $step['model_option'] ) ) {
|
||||
$expected[] = $step['model_option'];
|
||||
}
|
||||
}
|
||||
$expected = array_unique( $expected );
|
||||
$actual = \PRAutoBlogger_Pipeline_Settings_Step_Map::allowed_model_options();
|
||||
|
||||
sort( $expected );
|
||||
sort( $actual );
|
||||
$this->assertSame( $expected, $actual, 'allowed_model_options() must match all model_option values from steps().' );
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// § SLUG ROUND-TRIP — resolve_key_from_slug()
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Standard key: 'research.system' slug = 'research-system'.
|
||||
*/
|
||||
public function test_slug_round_trip_simple_key(): void {
|
||||
$key = 'research.system';
|
||||
$slug = sanitize_key( str_replace( '.', '-', $key ) );
|
||||
$this->assertSame( 'research-system', $slug );
|
||||
}
|
||||
|
||||
/**
|
||||
* Key with underscore: 'content.single_pass' must slug to 'content-single_pass'.
|
||||
* sanitize_key() preserves underscores, so the round-trip is unambiguous.
|
||||
*/
|
||||
public function test_slug_round_trip_key_with_underscore(): void {
|
||||
$key = 'content.single_pass';
|
||||
$slug = sanitize_key( str_replace( '.', '-', $key ) );
|
||||
// Dot → hyphen; underscore preserved by sanitize_key.
|
||||
$this->assertSame( 'content-single_pass', $slug );
|
||||
}
|
||||
|
||||
/**
|
||||
* All keys in allowed_prompt_keys() must produce distinct slugs (no collision).
|
||||
* A collision would make the save handler unable to resolve one of the keys.
|
||||
*/
|
||||
public function test_all_prompt_key_slugs_are_unique(): void {
|
||||
$keys = \PRAutoBlogger_Pipeline_Settings_Step_Map::allowed_prompt_keys();
|
||||
$slugs = array_map(
|
||||
static function ( $key ) {
|
||||
return sanitize_key( str_replace( '.', '-', $key ) );
|
||||
},
|
||||
$keys
|
||||
);
|
||||
$this->assertSame(
|
||||
count( $slugs ),
|
||||
count( array_unique( $slugs ) ),
|
||||
'Every allowed prompt key must produce a unique slug; a collision blocks save.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* find() returns the correct step by id.
|
||||
*/
|
||||
public function test_find_returns_matching_step(): void {
|
||||
$step = \PRAutoBlogger_Pipeline_Settings_Step_Map::find( 'research' );
|
||||
$this->assertNotNull( $step );
|
||||
$this->assertSame( 'research', $step['id'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* find() returns null for an unknown id.
|
||||
*/
|
||||
public function test_find_returns_null_for_unknown_id(): void {
|
||||
$this->assertNull( \PRAutoBlogger_Pipeline_Settings_Step_Map::find( 'does_not_exist' ) );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user