feat: Pipeline Settings M3 — assembled-instructions preview + prompt version history/diff (#173, v0.25.0)
Read-only assembled-instructions preview (last-run rendered prompt) with Template-vs-Preview guardrail; per-prompt version history + LCS diff. Stage-list-driven. QA APPROVE df92ed3; CI green. Agent-Session: pipeline-settings-m3-merge
This commit is contained in:
@@ -907,6 +907,23 @@ not via a POST-key allowlist, since the save handler iterates field defs directl
|
||||
**Prompt writes (M1, unchanged):** Immutable registry rows via
|
||||
`PRAutoBlogger_Prompt_Registry_Writer::create_version()`; only allowlisted prompt keys.
|
||||
|
||||
**M3 (v0.25.0 — assembled-instructions preview + version history/diff):**
|
||||
A per-prompt Template/Preview toggle replaces the static editor header. The editable
|
||||
"Template" view remains unchanged; the "Preview assembled instructions" tab is read-only
|
||||
and shows the actual rendered text the LLM received (sourced from the last successful
|
||||
`generation_log.request_json` for the stage, extracted via `Stage_Display_Map`). Falls
|
||||
back to a sample render with `[token_name]` placeholders when no run exists yet. A
|
||||
collapsible version history accordion beneath each editor lists all stored versions
|
||||
(number, author, timestamp, active badge) with "Diff" buttons that compute and render
|
||||
an LCS-based inline diff (added/removed/context/omitted lines, 3-line context window).
|
||||
|
||||
**M3 new files:** `ajax/class-pipeline-preview-handler.php`,
|
||||
`ajax/class-pipeline-history-handler.php`, `admin/class-pipeline-preview-source.php`.
|
||||
|
||||
**Security (M3):** All three new AJAX actions require `manage_options` + nonce. Prompt
|
||||
keys validated against `Step_Map::allowed_prompt_keys()`. Preview returned as `esc_html`
|
||||
server-side; diff text inserted via `textContent` (not innerHTML) in JS.
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
88
CHANGELOG.md
88
CHANGELOG.md
@@ -5,6 +5,94 @@ 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.25.0] - 2026-06-23
|
||||
|
||||
### Added
|
||||
- **Pipeline Settings M3 — assembled-instructions preview + prompt version history/diff:**
|
||||
Per-prompt Template/Preview toggle and version history accordion in the Pipeline Settings page.
|
||||
|
||||
- **Template / Preview toggle (per prompt):** A segmented button pair sits above every
|
||||
prompt editor. "Template" (default) shows the editable textarea with the note "Editing the
|
||||
template affects all future runs." "Preview assembled instructions" (read-only) fetches and
|
||||
shows the fully-rendered instruction text the LLM actually received, with all tokens filled.
|
||||
The two states are mutually exclusive; the preview pane has no save path server-side or
|
||||
client-side — CPO guardrail enforced at both layers.
|
||||
|
||||
- **Preview source (last-run preferred, sample fallback):** `PRAutoBlogger_Pipeline_Preview_Source`
|
||||
queries `prab_generation_log` for the most recent successful row whose stage maps to the
|
||||
prompt key via `Stage_Display_Map::all()` (stage-list-driven — Phase 2b stages resolve
|
||||
automatically). Extracts the rendered message content from `request_json` (system-role
|
||||
content for `*.system` keys; user-role content for agent keys). When no run exists yet,
|
||||
renders the active template with `[token_name]` placeholders so the admin sees the token
|
||||
injection map before any article has been generated. Clearly labelled in the UI
|
||||
("Tokens from last run · <date>" vs. "Sample render — no run found yet").
|
||||
|
||||
- **Version history accordion (per prompt):** Collapsible list below the editor showing
|
||||
every stored version (version number, author, created_at, active badge). Read-only;
|
||||
never mutates the registry. The "Diff" button computes and shows an inline line-level diff
|
||||
between adjacent versions (v(N-1) → vN) using a pure-PHP LCS implementation with a
|
||||
3-line context window and `omitted: N lines unchanged` collapse for distant context.
|
||||
Diff text is rendered by type (added/removed/context/omitted) with colour coding matching
|
||||
the designer mockup. All diff output is `esc_html`'d server-side and inserted via
|
||||
`textContent` in JS (double-escaped path).
|
||||
|
||||
- **New AJAX endpoints (both `manage_options` + nonce gated):**
|
||||
- `prautoblogger_pipeline_preview` — returns rendered preview text (from last run or
|
||||
sample); no save path; server-side read-only contract.
|
||||
- `prautoblogger_pipeline_history` — returns version list for a key.
|
||||
- `prautoblogger_pipeline_diff` — returns LCS diff between two versions.
|
||||
All endpoints validate the prompt key against `Step_Map::allowed_prompt_keys()` (the
|
||||
same allowlist the save handler uses) before any DB access.
|
||||
|
||||
- **Stage-list-driven throughout:** all preview/history/diff logic goes through
|
||||
`Stage_Display_Map::all()` and `Step_Map::allowed_prompt_keys()`; no hard-coded stage
|
||||
names — Phase 2b `curate`/`seo` stages will work without rework.
|
||||
|
||||
### New Files
|
||||
- `includes/ajax/class-pipeline-preview-handler.php` — Preview AJAX endpoint.
|
||||
- `includes/ajax/class-pipeline-history-handler.php` — History + diff AJAX endpoint.
|
||||
- `includes/admin/class-pipeline-preview-source.php` — Last-run / sample render logic.
|
||||
- `tests/unit/Ajax/PipelineHistoryHandlerTest.php` — Tests for diff algorithm + slug resolution.
|
||||
- `tests/unit/Ajax/PipelinePreviewSourceTest.php` — Tests for message extraction + stage mapping.
|
||||
|
||||
### Changed
|
||||
- `includes/admin/class-pipeline-settings-renderer.php` — M3: passes `preview_nonce`,
|
||||
`history_nonce`, `diff_nonce`, action names, and `panel.versions` list to the template;
|
||||
`build_prompt_panel_data()` now includes the full `versions` array for server-side
|
||||
history rendering.
|
||||
- `includes/admin/class-pipeline-settings-page.php` — M3: localizes `prabPipeline` JS
|
||||
object with AJAX actions + nonces; no menu changes.
|
||||
- `templates/admin/pipeline-settings-prompt-panel.php` — Redesigned: added Template/Preview
|
||||
toggle (`pab-tp-toggle`), preview block with read-only badge + source note,
|
||||
version history accordion with Diff buttons, and inline diff panel.
|
||||
- `templates/admin/pipeline-settings-page.php` — Passes full `$view` to the prompt panel
|
||||
partial (required for nonce data-attributes).
|
||||
- `assets/js/pipeline-settings.js` — M3: toggle logic, preview AJAX fetch, history
|
||||
accordion expand/collapse, diff AJAX fetch + render, diff close.
|
||||
- `assets/css/pipeline-settings.css` — M3: styles for tp-toggle, preview block,
|
||||
history accordion, diff panel (appended; all existing styles preserved).
|
||||
- `includes/class-prautoblogger.php` — Registers `Pipeline_Preview_Handler` and
|
||||
`Pipeline_History_Handler` hooks in `register_ajax_hooks()`.
|
||||
|
||||
### Tests
|
||||
- `PipelineHistoryHandlerTest`: covers `compute_diff()` (change detection, identical texts,
|
||||
line shape, omitted-context collapse) and `resolve_key_from_slug()` round-trip.
|
||||
Brace count: 12/12.
|
||||
- `PipelinePreviewSourceTest`: covers `extract_rendered_from_messages()` (system vs user role
|
||||
preference, longest fallback, empty input) and `stage_for_key()` (known + unknown keys).
|
||||
Brace count: 8/8. All methods use reflection on private statics — no `Functions\when()`
|
||||
on `Class::method` (DoD invariant preserved).
|
||||
|
||||
### Fixed
|
||||
- `PipelinePreviewSourceTest::test_stage_for_key_maps_research_system`: corrected expected
|
||||
value from `'research'` to `'llm_research'`. The production `stage_for_key('research.system')`
|
||||
returns `'llm_research'` because `Stage_Display_Map::MAP` lists `llm_research` before the
|
||||
Phase 2b `research` entry (both share `prompt_key => 'research.system'`; first match wins).
|
||||
The real `prab_generation_log.stage` value written by `LLM_Research_Provider::collect_data()`
|
||||
is `'llm_research'` — confirmed in `includes/providers/class-llm-research-provider.php` line 74.
|
||||
The mapping was correct; the test assertion was wrong.
|
||||
|
||||
|
||||
## [0.24.0] - 2026-06-23
|
||||
|
||||
### Added
|
||||
|
||||
@@ -11,7 +11,7 @@ Maintained per DoD v1.1.0 (same-PR update rule).
|
||||
|------|-------|----------|---------|
|
||||
| `prautoblogger-settings` | `PRAutoBlogger_Admin_Page` | `admin-page.php` | Main settings: API Keys, Schedule & Budget, Publishing, Analytics, Display, Images. AI Models / Content / Sources retired to Pipeline Settings in M2 (v0.24.0). |
|
||||
| `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: Global Content Context (niche), step option fields, model picker, system instructions, agent prompts, params (M1 v0.23.0; M2 v0.24.0 adds editable step options, retires AI Models/Content/Sources Settings tabs). |
|
||||
| `prautoblogger-pipeline` | `PRAutoBlogger_Pipeline_Settings_Page` | `pipeline-settings-page.php` | Per-step pipeline config: Global Content Context (niche), step option fields, model picker, system instructions, agent prompts, params (M1 v0.23.0; M2 v0.24.0 adds editable step options, retires AI Models/Content/Sources Settings tabs). M3 v0.25.0 adds Template/Preview toggle (assembled-instructions preview from last-run gen_log), version history accordion, and inline diff panel. Three new AJAX endpoints: `prautoblogger_pipeline_preview`, `prautoblogger_pipeline_history`, `prautoblogger_pipeline_diff` (all `manage_options` + nonce gated). |
|
||||
| `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 |
|
||||
|
||||
@@ -179,3 +179,201 @@
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ── M3: Template / Preview toggle ────────────────────────────────────────── */
|
||||
.pab-tp-toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.pab-tp-toggle {
|
||||
display: flex;
|
||||
border: 1px solid #d0d5dd;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pab-tp-btn {
|
||||
padding: 6px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #646970;
|
||||
background: #fff;
|
||||
border: none;
|
||||
border-right: 1px solid #d0d5dd;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background .12s, color .12s;
|
||||
}
|
||||
.pab-tp-btn:last-child { border-right: none; }
|
||||
.pab-tp-btn--active { background: #1B8A92; color: #fff; }
|
||||
.pab-tp-btn:not(.pab-tp-btn--active):hover { background: #f5f9fa; color: #1d2327; }
|
||||
.pab-tp-hint { font-size: 12px; color: #646970; }
|
||||
|
||||
/* ── M3: Prompt editor toolbar ─────────────────────────────────────────────── */
|
||||
.pab-prompt-editor-wrap { margin-bottom: 8px; }
|
||||
.pab-prompt-editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #f7f9fa;
|
||||
border: 1px solid #d0d5dd;
|
||||
border-bottom: none;
|
||||
border-radius: 4px 4px 0 0;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
.pab-prompt-editor-title {
|
||||
font-size: 11px;
|
||||
font-family: 'IBM Plex Mono', 'Courier New', monospace;
|
||||
font-weight: 600;
|
||||
color: #646970;
|
||||
}
|
||||
.pab-prompt-form .pab-prompt-editor { border-radius: 0 0 4px 4px; }
|
||||
|
||||
/* ── M3: Preview block ─────────────────────────────────────────────────────── */
|
||||
.pab-preview-block {
|
||||
border: 1px solid #d0d5dd;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #f7f9fa;
|
||||
max-width: 820px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.pab-preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
background: #f0f4f5;
|
||||
border-bottom: 1px solid #d0d5dd;
|
||||
}
|
||||
.pab-preview-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #646970;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.pab-preview-readonly-badge {
|
||||
font-size: 11px;
|
||||
font-family: 'IBM Plex Mono', 'Courier New', monospace;
|
||||
font-weight: 600;
|
||||
color: #646970;
|
||||
background: #fff;
|
||||
border: 1px solid #d0d5dd;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.pab-preview-source-note {
|
||||
font-size: 11px;
|
||||
color: #646970;
|
||||
margin-left: auto;
|
||||
}
|
||||
.pab-preview-body {
|
||||
padding: 14px;
|
||||
font-family: 'IBM Plex Mono', 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: #3d3d3d;
|
||||
background: #f7f9fa;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin: 0;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.pab-preview-loading { color: #9ca3af; font-style: italic; }
|
||||
.pab-preview-notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
background: #fffbeb;
|
||||
border-top: 1px solid #fde68a;
|
||||
font-size: 12px;
|
||||
color: #78350f;
|
||||
}
|
||||
.pab-preview-notice .dashicons { font-size: 14px; flex-shrink: 0; margin-top: 1px; color: #92400e; }
|
||||
|
||||
/* ── M3: Version history accordion ─────────────────────────────────────────── */
|
||||
.pab-history-accordion { margin-top: 12px; }
|
||||
.pab-history-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #646970;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
.pab-history-trigger:hover { color: #1B8A92; }
|
||||
.pab-history-arrow { font-size: 14px !important; transition: transform .15s; }
|
||||
.pab-history-body {
|
||||
margin-top: 6px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pab-version-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 14px;
|
||||
border-bottom: 1px solid #f0f0f1;
|
||||
font-size: 12px;
|
||||
}
|
||||
.pab-version-row:last-child { border-bottom: none; }
|
||||
.pab-version-num {
|
||||
font-family: 'IBM Plex Mono', 'Courier New', monospace;
|
||||
font-weight: 600;
|
||||
color: #1B8A92;
|
||||
min-width: 28px;
|
||||
}
|
||||
.pab-version-author { color: #1d2327; font-weight: 500; }
|
||||
.pab-version-date { font-family: 'IBM Plex Mono', 'Courier New', monospace; font-size: 11px; color: #646970; }
|
||||
.pab-version-current { margin-left: 4px; }
|
||||
|
||||
/* ── M3: Diff panel ─────────────────────────────────────────────────────────── */
|
||||
.pab-diff-panel {
|
||||
margin-top: 12px;
|
||||
background: #f7f9fa;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
max-width: 820px;
|
||||
}
|
||||
.pab-diff-header {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #1d2327;
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
background: #f0f4f5;
|
||||
font-family: 'IBM Plex Mono', 'Courier New', monospace;
|
||||
}
|
||||
.pab-diff-block {
|
||||
font-family: 'IBM Plex Mono', 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
padding: 4px 0;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.pab-diff-line {
|
||||
display: block;
|
||||
padding: 1px 14px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.pab-diff-line--added { background: #ecfdf5; color: #065f46; }
|
||||
.pab-diff-line--removed { background: #fef2f2; color: #9b1c1c; }
|
||||
.pab-diff-line--context { color: #3d3d3d; }
|
||||
.pab-diff-line--omitted { color: #9ca3af; font-style: italic; }
|
||||
.pab-diff-close {
|
||||
display: block;
|
||||
margin: 8px 14px 10px;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,208 @@
|
||||
/**
|
||||
* Pipeline Settings page — step rail and prompt editor interactions.
|
||||
* Pipeline Settings page — M3: Template/Preview toggle, version history accordion,
|
||||
* and inline diff panel.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Confirm before resetting a prompt to default (redundant safety; the
|
||||
* PHP onclick already handles it, but JS enhances UX).
|
||||
* M3 Responsibilities:
|
||||
* 1. Template / Preview toggle per prompt panel:
|
||||
* - "Template" tab: shows the editable textarea (default).
|
||||
* - "Preview assembled instructions" tab: fetches via AJAX and shows the
|
||||
* fully-rendered prompt text. Read-only; no save path exists in JS or PHP.
|
||||
* The two states are mutually exclusive; pressing "Template" never sends
|
||||
* any network request.
|
||||
* 2. Version history accordion: toggle show/hide per prompt key.
|
||||
* 3. Inline diff panel: on "Diff" button click, fetch the computed diff from
|
||||
* the server and render added/removed/context lines with colour coding.
|
||||
* "Close diff" hides the panel.
|
||||
*
|
||||
* 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().
|
||||
* Security notes:
|
||||
* - Preview and diff bodies are returned pre-escaped (esc_html'd) by PHP.
|
||||
* They are inserted via .text() / textContent, never innerHTML, so no
|
||||
* XSS vector exists even if the server escaping were to fail.
|
||||
* - Nonces come from the prabPipeline JS object (localized in enqueue_assets).
|
||||
*
|
||||
* @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.
|
||||
* Note: model selection is handled entirely by model-picker.js; no bridge needed.
|
||||
*
|
||||
* @see admin/class-pipeline-settings-page.php -- Localizes prabPipeline.
|
||||
* @see ajax/class-pipeline-preview-handler.php -- Preview AJAX endpoint.
|
||||
* @see ajax/class-pipeline-history-handler.php -- History/diff AJAX endpoint.
|
||||
* @see assets/js/model-picker.js -- Model picker (separate).
|
||||
*/
|
||||
/* global jQuery */
|
||||
/* global jQuery, prabPipeline */
|
||||
( function ( $ ) {
|
||||
'use strict';
|
||||
|
||||
$( function () {
|
||||
// Future UX hooks (e.g. unsaved-changes warning) go here.
|
||||
/**
|
||||
* Safely read the localized config, falling back to empty strings so
|
||||
* the page never fatals when the object is missing (e.g. on other pages).
|
||||
*
|
||||
* @type {{ ajaxUrl: string, previewAction: string, previewNonce: string,
|
||||
* historyAction: string, historyNonce: string,
|
||||
* diffAction: string, diffNonce: string }}
|
||||
*/
|
||||
var cfg = ( typeof prabPipeline !== 'undefined' ) ? prabPipeline : {};
|
||||
|
||||
// ── 1. Template / Preview toggle ──────────────────────────────────────────
|
||||
|
||||
$( document ).on( 'click', '.pab-tp-btn', function () {
|
||||
var $btn = $( this );
|
||||
var $toggle = $btn.closest( '.pab-tp-toggle' );
|
||||
var mode = $btn.data( 'mode' );
|
||||
|
||||
// Update button states.
|
||||
$toggle.find( '.pab-tp-btn' ).each( function () {
|
||||
$( this ).toggleClass( 'pab-tp-btn--active', $( this ).data( 'mode' ) === mode );
|
||||
$( this ).attr( 'aria-pressed', $( this ).data( 'mode' ) === mode ? 'true' : 'false' );
|
||||
} );
|
||||
|
||||
var templateId = $toggle.data( 'template-id' );
|
||||
var previewId = $toggle.data( 'preview-id' );
|
||||
var hintId = $toggle.data( 'toggle-hint-id' );
|
||||
var $template = $( '#' + templateId );
|
||||
var $preview = $( '#' + previewId );
|
||||
var $hint = $( '#' + hintId );
|
||||
|
||||
if ( 'template' === mode ) {
|
||||
$template.show();
|
||||
$preview.hide();
|
||||
if ( $hint.length ) {
|
||||
$hint.text( $hint.data( 'template-hint' ) || prabTrans( 'Editing the template affects all future runs.' ) );
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Preview mode: show panel immediately, then fetch.
|
||||
$template.hide();
|
||||
$preview.show();
|
||||
if ( $hint.length ) {
|
||||
$hint.text( prabTrans( 'Preview only — not editable.' ) );
|
||||
}
|
||||
|
||||
var promptKey = $toggle.data( 'prompt-key' );
|
||||
var $body = $preview.find( '.js-preview-body' );
|
||||
var $source = $preview.find( '.js-preview-source' );
|
||||
|
||||
// Only fetch once per page load; use data-loaded flag.
|
||||
if ( $preview.data( 'loaded' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$body.html( '<span class="pab-preview-loading">…</span>' );
|
||||
|
||||
$.post(
|
||||
( cfg.ajaxUrl || '' ),
|
||||
{
|
||||
action: cfg.previewAction || '',
|
||||
nonce: cfg.previewNonce || '',
|
||||
prompt_key: promptKey
|
||||
},
|
||||
function ( response ) {
|
||||
if ( response && response.success ) {
|
||||
var d = response.data;
|
||||
// Insert pre-escaped text via textContent (double-safe).
|
||||
$body[ 0 ].textContent = d.preview || '';
|
||||
|
||||
if ( d.from_run ) {
|
||||
var sourceNote = d.run_post
|
||||
? d.run_date + ' · ' + d.run_post
|
||||
: d.run_date;
|
||||
$source.text( prabTrans( 'Tokens from last run · ' ) + sourceNote );
|
||||
} else {
|
||||
$source.text( prabTrans( 'Sample render — no run found yet' ) );
|
||||
}
|
||||
$preview.data( 'loaded', true );
|
||||
} else {
|
||||
$body[ 0 ].textContent = prabTrans( 'Could not load preview.' );
|
||||
}
|
||||
},
|
||||
'json'
|
||||
).fail( function () {
|
||||
$body[ 0 ].textContent = prabTrans( 'Network error loading preview.' );
|
||||
} );
|
||||
} );
|
||||
|
||||
// ── 2. Version history accordion ──────────────────────────────────────────
|
||||
|
||||
$( document ).on( 'click', '.pab-history-trigger', function () {
|
||||
var $trigger = $( this );
|
||||
var bodyId = $trigger.attr( 'aria-controls' );
|
||||
var $body = $( '#' + bodyId );
|
||||
var expanded = $trigger.attr( 'aria-expanded' ) === 'true';
|
||||
|
||||
$trigger.attr( 'aria-expanded', expanded ? 'false' : 'true' );
|
||||
$trigger.find( '.pab-history-arrow' )
|
||||
.toggleClass( 'dashicons-arrow-down-alt2', expanded )
|
||||
.toggleClass( 'dashicons-arrow-up-alt2', ! expanded );
|
||||
$body.toggle( ! expanded );
|
||||
} );
|
||||
|
||||
// ── 3. Diff button ────────────────────────────────────────────────────────
|
||||
|
||||
$( document ).on( 'click', '.pab-diff-btn', function () {
|
||||
var $btn = $( this );
|
||||
var promptKey = $btn.data( 'prompt-key' );
|
||||
var versionA = $btn.data( 'version-a' );
|
||||
var versionB = $btn.data( 'version-b' );
|
||||
var diffId = $btn.data( 'diff-target' );
|
||||
var $panel = $( '#' + diffId );
|
||||
var $header = $panel.find( '.js-diff-header' );
|
||||
var $lines = $panel.find( '.js-diff-lines' );
|
||||
|
||||
$panel.show();
|
||||
$header.text( prabTrans( 'Loading diff…' ) );
|
||||
$lines.empty();
|
||||
|
||||
$.post(
|
||||
( cfg.ajaxUrl || '' ),
|
||||
{
|
||||
action: cfg.diffAction || '',
|
||||
nonce: cfg.diffNonce || '',
|
||||
prompt_key: promptKey,
|
||||
version_a: versionA,
|
||||
version_b: versionB
|
||||
},
|
||||
function ( response ) {
|
||||
if ( response && response.success ) {
|
||||
var d = response.data;
|
||||
$header.text( d.header || '' );
|
||||
$lines.empty();
|
||||
if ( d.lines && d.lines.length ) {
|
||||
$.each( d.lines, function ( i, line ) {
|
||||
var $line = $( '<div>' ).addClass( 'pab-diff-line pab-diff-line--' + line.type );
|
||||
// line.text is pre-escaped server-side; use textContent.
|
||||
$line[ 0 ].textContent = line.text;
|
||||
$lines.append( $line );
|
||||
} );
|
||||
} else {
|
||||
$lines.text( prabTrans( 'No differences found.' ) );
|
||||
}
|
||||
} else {
|
||||
$header.text( prabTrans( 'Failed to load diff.' ) );
|
||||
}
|
||||
},
|
||||
'json'
|
||||
).fail( function () {
|
||||
$header.text( prabTrans( 'Network error loading diff.' ) );
|
||||
} );
|
||||
} );
|
||||
|
||||
// ── 4. Close diff ─────────────────────────────────────────────────────────
|
||||
|
||||
$( document ).on( 'click', '.pab-diff-close', function () {
|
||||
var diffId = $( this ).data( 'diff-id' );
|
||||
$( '#' + diffId ).hide();
|
||||
} );
|
||||
|
||||
// ── Helper: minimal i18n pass-through (strings are in PHP-rendered HTML) ──
|
||||
|
||||
/**
|
||||
* Thin translation shim. Since strings originate in PHP (already translated
|
||||
* + escaped), this is just a pass-through so JS keeps a single string origin.
|
||||
*
|
||||
* @param {string} s
|
||||
* @returns {string}
|
||||
*/
|
||||
function prabTrans( s ) {
|
||||
return s;
|
||||
}
|
||||
|
||||
}( jQuery ) );
|
||||
|
||||
199
includes/admin/class-pipeline-preview-source.php
Normal file
199
includes/admin/class-pipeline-preview-source.php
Normal file
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Resolves the "assembled instructions" preview body for a prompt key.
|
||||
*
|
||||
* What: Two strategies, tried in order:
|
||||
*
|
||||
* 1. Last-run strategy: Queries generation_log for the most recent
|
||||
* successful row whose stage maps to the given prompt_key via
|
||||
* Stage_Display_Map. Decodes the stored request_json and extracts
|
||||
* the user-role message content (the fully-rendered, token-filled
|
||||
* instruction the LLM actually received). This is the preferred
|
||||
* source because it is factually what ran.
|
||||
*
|
||||
* 2. Sample render: Falls back to Prompt_Registry::render() with
|
||||
* placeholder token values taken from the registry def. Tokens are
|
||||
* replaced with "[sample: <token_name>]" labels so the admin can
|
||||
* see the template's token slots without live data. Labelled
|
||||
* "sample — no run found" in the UI.
|
||||
*
|
||||
* Neither path has a save route. All returned text is plain strings;
|
||||
* escaping is the caller's responsibility (Pipeline_Preview_Handler
|
||||
* calls esc_html before JSON-encoding).
|
||||
*
|
||||
* Stage-list-driven: Stage_Display_Map::all() iterates the full known
|
||||
* stage vocabulary; Phase 2b stages resolve automatically.
|
||||
*
|
||||
* Who calls it: PRAutoBlogger_Pipeline_Preview_Handler::handle().
|
||||
* Dependencies: WordPress $wpdb, PRAutoBlogger_Stage_Display_Map,
|
||||
* PRAutoBlogger_Prompt_Registry.
|
||||
*
|
||||
* @see ajax/class-pipeline-preview-handler.php -- Sole caller.
|
||||
* @see core/class-stage-display-map.php -- prompt_key -> stage map.
|
||||
* @see core/class-prompt-registry.php -- render() + default_body().
|
||||
* @see ARCHITECTURE.md -- generation_log schema.
|
||||
*/
|
||||
class PRAutoBlogger_Pipeline_Preview_Source {
|
||||
|
||||
/**
|
||||
* Try to extract the rendered prompt from the most recent successful
|
||||
* generation_log row that used the given prompt key.
|
||||
*
|
||||
* Returns null when no matching row exists or when the request_json
|
||||
* cannot be decoded into a usable messages array.
|
||||
*
|
||||
* @param string $prompt_key Registry key (e.g. 'analysis.system').
|
||||
* @return array{rendered: string, run_date: string, run_post: string}|null
|
||||
*/
|
||||
public static function last_run( string $prompt_key ): ?array {
|
||||
global $wpdb;
|
||||
if ( null === $wpdb ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stage = self::stage_for_key( $prompt_key );
|
||||
if ( null === $stage ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$row = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT request_json, created_at, post_id
|
||||
FROM {$table}
|
||||
WHERE stage = %s
|
||||
AND response_status = 'success'
|
||||
AND request_json IS NOT NULL
|
||||
ORDER BY id DESC
|
||||
LIMIT 1",
|
||||
$stage
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
if ( ! is_array( $row ) || empty( $row['request_json'] ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$body = json_decode( (string) $row['request_json'], true );
|
||||
if ( ! is_array( $body ) || empty( $body['messages'] ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rendered = self::extract_rendered_from_messages( (array) $body['messages'], $prompt_key );
|
||||
if ( '' === $rendered ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$run_post = '';
|
||||
if ( ! empty( $row['post_id'] ) ) {
|
||||
$post = get_post( (int) $row['post_id'] );
|
||||
if ( $post instanceof WP_Post ) {
|
||||
$run_post = $post->post_title;
|
||||
}
|
||||
}
|
||||
|
||||
return array(
|
||||
'rendered' => $rendered,
|
||||
'run_date' => (string) $row['created_at'],
|
||||
'run_post' => $run_post,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce a sample render of the active template with placeholder tokens.
|
||||
*
|
||||
* Token slots in the template ({{ token_name }}) are substituted with
|
||||
* "[sample: token_name]" so the admin can see every injection point
|
||||
* without needing live generation data.
|
||||
*
|
||||
* @param string $prompt_key Registry key.
|
||||
* @return string Rendered string (may be empty when key is unknown).
|
||||
*/
|
||||
public static function sample_render( string $prompt_key ): string {
|
||||
$active = PRAutoBlogger_Prompt_Registry::get_active( $prompt_key );
|
||||
$body = null !== $active
|
||||
? (string) $active['body']
|
||||
: (string) PRAutoBlogger_Prompt_Registry::default_body( $prompt_key );
|
||||
|
||||
if ( '' === $body ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Find all {{ token_name }} placeholders and substitute each with a
|
||||
// clearly labelled placeholder so the admin sees the injection map.
|
||||
$tokens = array();
|
||||
preg_match_all( '/\{\{\s*(\w+)\s*\}\}/', $body, $matches );
|
||||
if ( ! empty( $matches[1] ) ) {
|
||||
foreach ( array_unique( $matches[1] ) as $token ) {
|
||||
// translators: %s = token name, e.g. 'niche_description'.
|
||||
$tokens[ $token ] = sprintf( '[%s]', $token );
|
||||
}
|
||||
}
|
||||
|
||||
return PRAutoBlogger_Prompt_Registry::fill( $body, $tokens );
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a prompt registry key to its primary stage via Stage_Display_Map.
|
||||
*
|
||||
* Iterates all() so new Phase 2b stages are picked up automatically.
|
||||
*
|
||||
* @param string $prompt_key Registry key.
|
||||
* @return string|null Stage slug, or null when no stage owns this key.
|
||||
*/
|
||||
private static function stage_for_key( string $prompt_key ): ?string {
|
||||
foreach ( PRAutoBlogger_Stage_Display_Map::all() as $stage => $def ) {
|
||||
if ( isset( $def['prompt_key'] ) && $def['prompt_key'] === $prompt_key ) {
|
||||
return $stage;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the most informative rendered text from a messages array.
|
||||
*
|
||||
* For system-prompt keys (*.system) we return the system message content.
|
||||
* For user/agent-prompt keys we return the last user-role message content.
|
||||
* Falls back to the longest non-empty message when role detection fails.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $messages Decoded messages array.
|
||||
* @param string $key Prompt key hint.
|
||||
* @return string
|
||||
*/
|
||||
private static function extract_rendered_from_messages( array $messages, string $key ): string {
|
||||
$is_system_key = str_contains( $key, '.system' ) || str_ends_with( $key, '.rewriter_system' );
|
||||
|
||||
// Try role-based selection first.
|
||||
$target_role = $is_system_key ? 'system' : 'user';
|
||||
$last_match = '';
|
||||
foreach ( $messages as $msg ) {
|
||||
if ( is_array( $msg )
|
||||
&& isset( $msg['role'], $msg['content'] )
|
||||
&& $msg['role'] === $target_role
|
||||
&& is_string( $msg['content'] )
|
||||
&& '' !== $msg['content'] ) {
|
||||
$last_match = $msg['content'];
|
||||
}
|
||||
}
|
||||
if ( '' !== $last_match ) {
|
||||
return $last_match;
|
||||
}
|
||||
|
||||
// Fallback: return the longest non-empty message content.
|
||||
$longest = '';
|
||||
foreach ( $messages as $msg ) {
|
||||
if ( is_array( $msg ) && isset( $msg['content'] ) && is_string( $msg['content'] )
|
||||
&& strlen( $msg['content'] ) > strlen( $longest ) ) {
|
||||
$longest = $msg['content'];
|
||||
}
|
||||
}
|
||||
return $longest;
|
||||
}
|
||||
}
|
||||
@@ -9,19 +9,24 @@ declare(strict_types=1);
|
||||
* 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
|
||||
* 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.
|
||||
* M3 localizes preview / history / diff nonces so pipeline-settings.js
|
||||
* can make authenticated AJAX requests for the assembled-instructions
|
||||
* preview and per-prompt version history/diff.
|
||||
* 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.
|
||||
* @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 ajax/class-pipeline-preview-handler.php -- Preview AJAX (M3).
|
||||
* @see ajax/class-pipeline-history-handler.php -- History/diff AJAX (M3).
|
||||
* @see ARCHITECTURE.md -- Admin pages table.
|
||||
* @see INFORMATION-ARCHITECTURE.md -- Admin page slug registry.
|
||||
*/
|
||||
class PRAutoBlogger_Pipeline_Settings_Page {
|
||||
|
||||
@@ -37,7 +42,7 @@ class PRAutoBlogger_Pipeline_Settings_Page {
|
||||
/**
|
||||
* Register the Pipeline submenu page under the PRAutoBlogger parent menu.
|
||||
*
|
||||
* Priority 13 — after board (11), dossier (12). The parent slug must be
|
||||
* 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.
|
||||
*
|
||||
@@ -57,8 +62,8 @@ class PRAutoBlogger_Pipeline_Settings_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.
|
||||
* M3 adds localized data for the assembled-instructions preview toggle
|
||||
* and the version history / diff panel (AJAX actions + nonces).
|
||||
*
|
||||
* @param string $hook_suffix Current admin page hook suffix.
|
||||
* @return void
|
||||
@@ -87,6 +92,21 @@ class PRAutoBlogger_Pipeline_Settings_Page {
|
||||
'imageModels' => PRAutoBlogger_Settings_Fields_Extended::get_image_models(),
|
||||
)
|
||||
);
|
||||
|
||||
// M3: localize preview + history + diff nonces for pipeline-settings.js.
|
||||
wp_localize_script(
|
||||
'prautoblogger-pipeline',
|
||||
'prabPipeline',
|
||||
array(
|
||||
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||
'previewAction' => PRAutoBlogger_Pipeline_Preview_Handler::ACTION,
|
||||
'previewNonce' => wp_create_nonce( PRAutoBlogger_Pipeline_Preview_Handler::ACTION ),
|
||||
'historyAction' => PRAutoBlogger_Pipeline_History_Handler::ACTION_HISTORY,
|
||||
'historyNonce' => wp_create_nonce( PRAutoBlogger_Pipeline_History_Handler::ACTION_HISTORY ),
|
||||
'diffAction' => PRAutoBlogger_Pipeline_History_Handler::ACTION_DIFF,
|
||||
'diffNonce' => wp_create_nonce( PRAutoBlogger_Pipeline_History_Handler::ACTION_DIFF ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,7 +123,6 @@ class PRAutoBlogger_Pipeline_Settings_Page {
|
||||
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();
|
||||
|
||||
@@ -12,18 +12,25 @@ declare(strict_types=1);
|
||||
* fields (M2) so the template can render editable forms without touching
|
||||
* WP globals. The renderer contains NO business logic — it only reads
|
||||
* and shapes data for the view.
|
||||
* 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.
|
||||
* 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),
|
||||
* PRAutoBlogger_Prompt_Registry (active row, versions, defs),
|
||||
* PRAutoBlogger_Prompt_Registry_Writer (list_versions for M3),
|
||||
* 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/class-pipeline-settings-option-fields.php — Step option field defs.
|
||||
* @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.
|
||||
* @see admin/class-pipeline-settings-step-map.php -- Step + key definitions.
|
||||
* @see admin/class-pipeline-settings-option-fields.php -- Step option field defs.
|
||||
* @see admin/fields/class-open-router-model-field.php -- Model picker component.
|
||||
* @see core/class-prompt-registry.php -- Registry read API.
|
||||
* @see core/class-prompt-registry-writer.php -- list_versions() (M3).
|
||||
* @see ajax/class-pipeline-preview-handler.php -- Preview AJAX (M3).
|
||||
* @see ajax/class-pipeline-history-handler.php -- History/diff AJAX (M3).
|
||||
* @see templates/admin/pipeline-settings-page.php -- HTML template.
|
||||
*/
|
||||
class PRAutoBlogger_Pipeline_Settings_Renderer {
|
||||
|
||||
@@ -42,24 +49,30 @@ class PRAutoBlogger_Pipeline_Settings_Renderer {
|
||||
$monthly_spend = $cost_reporter->get_monthly_spend();
|
||||
$budget = (float) get_option( 'prautoblogger_monthly_budget_usd', 50.00 );
|
||||
|
||||
// Determine the step context for the step-options panel.
|
||||
$step_context = in_array( $step['id'], PRAutoBlogger_Pipeline_Settings_Option_Fields::contexts(), true )
|
||||
? $step['id']
|
||||
: null;
|
||||
|
||||
$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,
|
||||
'global_fields' => $this->build_option_field_values( 'global' ),
|
||||
'step_context' => $step_context,
|
||||
'step_fields' => $step_context ? $this->build_option_field_values( $step_context ) : 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,
|
||||
'global_fields' => $this->build_option_field_values( 'global' ),
|
||||
'step_context' => $step_context,
|
||||
'step_fields' => $step_context ? $this->build_option_field_values( $step_context ) : array(),
|
||||
// M3: AJAX nonces localized into the view for JS use.
|
||||
'preview_nonce' => wp_create_nonce( PRAutoBlogger_Pipeline_Preview_Handler::ACTION ),
|
||||
'history_nonce' => wp_create_nonce( PRAutoBlogger_Pipeline_History_Handler::ACTION_HISTORY ),
|
||||
'diff_nonce' => wp_create_nonce( PRAutoBlogger_Pipeline_History_Handler::ACTION_DIFF ),
|
||||
'preview_action' => PRAutoBlogger_Pipeline_Preview_Handler::ACTION,
|
||||
'history_action' => PRAutoBlogger_Pipeline_History_Handler::ACTION_HISTORY,
|
||||
'diff_action' => PRAutoBlogger_Pipeline_History_Handler::ACTION_DIFF,
|
||||
);
|
||||
|
||||
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/pipeline-settings-page.php';
|
||||
@@ -68,10 +81,6 @@ class PRAutoBlogger_Pipeline_Settings_Renderer {
|
||||
/**
|
||||
* Build current option values for all fields in a context.
|
||||
*
|
||||
* Returns an array of [ field_def_with_current_value ] — each field
|
||||
* definition from Option_Fields augmented with a 'current' key holding
|
||||
* the live wp_option value (or the field's default when not set).
|
||||
*
|
||||
* @param string $context Step context identifier.
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
@@ -120,6 +129,10 @@ class PRAutoBlogger_Pipeline_Settings_Renderer {
|
||||
/**
|
||||
* Build display data for a single prompt key's edit panel.
|
||||
*
|
||||
* M3 addition: includes 'versions' (full list rows) for the history
|
||||
* accordion. The list is already fetched here so the template needs no
|
||||
* additional DB call.
|
||||
*
|
||||
* @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.
|
||||
@@ -137,7 +150,8 @@ class PRAutoBlogger_Pipeline_Settings_Renderer {
|
||||
$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 );
|
||||
// M3: pass full version rows to the template for the history accordion.
|
||||
$versions = PRAutoBlogger_Prompt_Registry_Writer::list_versions( $key );
|
||||
|
||||
return array(
|
||||
'key' => $key,
|
||||
@@ -148,14 +162,13 @@ class PRAutoBlogger_Pipeline_Settings_Renderer {
|
||||
'created_at' => $created_at,
|
||||
'author' => $author,
|
||||
'version_count' => count( $versions ),
|
||||
'versions' => $versions,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the model picker for a step using the existing field component.
|
||||
*
|
||||
* Public so the template can call it directly.
|
||||
*
|
||||
* @param string $model_option wp_option name.
|
||||
* @param string $model_value Current model id stored in the option.
|
||||
* @param array<string, mixed> $field_args Field args forwarded to the picker.
|
||||
|
||||
291
includes/ajax/class-pipeline-history-handler.php
Normal file
291
includes/ajax/class-pipeline-history-handler.php
Normal file
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* AJAX handler: fetch version history and compute diffs for a prompt key.
|
||||
*
|
||||
* What: Two actions on one class —
|
||||
* prautoblogger_pipeline_history: returns all stored versions for a key
|
||||
* (version number, author, created_at, active flag). Read-only; never
|
||||
* mutates the registry.
|
||||
* prautoblogger_pipeline_diff: returns a line-level unified diff between
|
||||
* two specified versions (or between a version and the current active
|
||||
* body). Diff is computed server-side (no external binary required)
|
||||
* and returned as an array of {type, text} lines (added/removed/context)
|
||||
* so the JS renderer can apply colour without parsing unified format.
|
||||
*
|
||||
* Security: manage_options cap + nonce on every request. All registry key
|
||||
* inputs are validated against the Step_Map allowlist. Diff output
|
||||
* is esc_html'd before encoding.
|
||||
*
|
||||
* Who triggers it: pipeline-settings.js on history accordion open and diff
|
||||
* button click.
|
||||
* Dependencies: PRAutoBlogger_Prompt_Registry_Writer (list_versions),
|
||||
* PRAutoBlogger_Prompt_Registry (get_active, default_body),
|
||||
* PRAutoBlogger_Pipeline_Settings_Step_Map (key allowlist).
|
||||
*
|
||||
* @see ajax/class-pipeline-preview-handler.php -- Preview assembled text.
|
||||
* @see core/class-prompt-registry-writer.php -- list_versions() source.
|
||||
* @see ARCHITECTURE.md #21 -- Prompt registry design.
|
||||
*/
|
||||
class PRAutoBlogger_Pipeline_History_Handler {
|
||||
|
||||
/** AJAX action: list all versions for a key. */
|
||||
public const ACTION_HISTORY = 'prautoblogger_pipeline_history';
|
||||
|
||||
/** AJAX action: diff two versions. */
|
||||
public const ACTION_DIFF = 'prautoblogger_pipeline_diff';
|
||||
|
||||
/**
|
||||
* Register wp-ajax hooks.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register_hooks(): void {
|
||||
add_action( 'wp_ajax_' . self::ACTION_HISTORY, array( __CLASS__, 'handle_history' ) );
|
||||
add_action( 'wp_ajax_' . self::ACTION_DIFF, array( __CLASS__, 'handle_diff' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all stored versions for a prompt key, newest first.
|
||||
*
|
||||
* POST fields:
|
||||
* nonce string wp_nonce for 'prautoblogger_pipeline_history'.
|
||||
* prompt_key string Registry key slug (dots to hyphens).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function handle_history(): void {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
|
||||
}
|
||||
|
||||
$nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : '';
|
||||
if ( ! wp_verify_nonce( $nonce, self::ACTION_HISTORY ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Security check failed.', 'prautoblogger' ) ), 403 );
|
||||
}
|
||||
|
||||
$slug = isset( $_POST['prompt_key'] ) ? sanitize_key( wp_unslash( $_POST['prompt_key'] ) ) : '';
|
||||
$prompt_key = self::resolve_key_from_slug( $slug );
|
||||
|
||||
if ( null === $prompt_key ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Unknown prompt key.', 'prautoblogger' ) ), 400 );
|
||||
}
|
||||
|
||||
$rows = PRAutoBlogger_Prompt_Registry_Writer::list_versions( $prompt_key );
|
||||
$versions = array();
|
||||
foreach ( $rows as $row ) {
|
||||
$versions[] = array(
|
||||
'version' => (int) $row['version'],
|
||||
'author' => esc_html( (string) $row['author'] ),
|
||||
'created_at' => esc_html( (string) $row['created_at'] ),
|
||||
'active' => (bool) $row['active'],
|
||||
);
|
||||
}
|
||||
|
||||
wp_send_json_success( array( 'versions' => $versions ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute and return a line-level diff between two prompt versions.
|
||||
*
|
||||
* POST fields:
|
||||
* nonce string wp_nonce for 'prautoblogger_pipeline_diff'.
|
||||
* prompt_key string Registry key slug (dots to hyphens).
|
||||
* version_a int Older version number (or 0 = factory default).
|
||||
* version_b int Newer version number (or 0 = current active).
|
||||
*
|
||||
* Returns {lines: [{type: 'added'|'removed'|'context', text: string}], header: string}.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function handle_diff(): void {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
|
||||
}
|
||||
|
||||
$nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : '';
|
||||
if ( ! wp_verify_nonce( $nonce, self::ACTION_DIFF ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Security check failed.', 'prautoblogger' ) ), 403 );
|
||||
}
|
||||
|
||||
$slug = isset( $_POST['prompt_key'] ) ? sanitize_key( wp_unslash( $_POST['prompt_key'] ) ) : '';
|
||||
$prompt_key = self::resolve_key_from_slug( $slug );
|
||||
if ( null === $prompt_key ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Unknown prompt key.', 'prautoblogger' ) ), 400 );
|
||||
}
|
||||
|
||||
$version_a = isset( $_POST['version_a'] ) ? (int) $_POST['version_a'] : 0;
|
||||
$version_b = isset( $_POST['version_b'] ) ? (int) $_POST['version_b'] : 0;
|
||||
|
||||
$body_a = self::fetch_version_body( $prompt_key, $version_a );
|
||||
$body_b = self::fetch_version_body( $prompt_key, $version_b );
|
||||
|
||||
if ( null === $body_a || null === $body_b ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Version not found.', 'prautoblogger' ) ), 404 );
|
||||
}
|
||||
|
||||
$label_a = 0 === $version_a ? __( 'default', 'prautoblogger' ) : sprintf( 'v%d', $version_a );
|
||||
$label_b = 0 === $version_b ? __( 'current', 'prautoblogger' ) : sprintf( 'v%d', $version_b );
|
||||
|
||||
$lines = self::compute_diff( $body_a, $body_b );
|
||||
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'lines' => $lines,
|
||||
'header' => esc_html(
|
||||
sprintf(
|
||||
/* translators: 1: prompt key, 2: old version label, 3: new version label */
|
||||
__( '%1$s — %2$s → %3$s', 'prautoblogger' ),
|
||||
$prompt_key,
|
||||
$label_a,
|
||||
$label_b
|
||||
)
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the body text for a specific version number.
|
||||
* Version 0 means the factory default (in-code canonical body).
|
||||
*
|
||||
* @param string $key Registry key.
|
||||
* @param int $version Version number, or 0 for factory default.
|
||||
* @return string|null Body text, or null when not found.
|
||||
*/
|
||||
private static function fetch_version_body( string $key, int $version ): ?string {
|
||||
if ( 0 === $version ) {
|
||||
return PRAutoBlogger_Prompt_Registry::default_body( $key );
|
||||
}
|
||||
$rows = PRAutoBlogger_Prompt_Registry_Writer::list_versions( $key );
|
||||
foreach ( $rows as $row ) {
|
||||
if ( (int) $row['version'] === $version ) {
|
||||
return (string) $row['body'];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a line-level diff of two text bodies.
|
||||
*
|
||||
* Uses a simple LCS-based diff (no external binary). Returns an array of
|
||||
* {type: 'added'|'removed'|'context', text: string} objects with
|
||||
* text already esc_html'd. Context lines are capped at 3 surrounding
|
||||
* changed lines to keep response size bounded.
|
||||
*
|
||||
* @param string $old Old text body.
|
||||
* @param string $new New text body.
|
||||
* @return array<int, array{type: string, text: string}>
|
||||
*/
|
||||
private static function compute_diff( string $old, string $new ): array {
|
||||
$old_lines = explode( "\n", $old );
|
||||
$new_lines = explode( "\n", $new );
|
||||
$m = count( $old_lines );
|
||||
$n = count( $new_lines );
|
||||
|
||||
// LCS table — for reasonable-sized prompts (typically < 200 lines).
|
||||
$lcs = array_fill( 0, $m + 1, array_fill( 0, $n + 1, 0 ) );
|
||||
for ( $i = 1; $i <= $m; $i++ ) {
|
||||
for ( $j = 1; $j <= $n; $j++ ) {
|
||||
if ( $old_lines[ $i - 1 ] === $new_lines[ $j - 1 ] ) {
|
||||
$lcs[ $i ][ $j ] = $lcs[ $i - 1 ][ $j - 1 ] + 1;
|
||||
} else {
|
||||
$lcs[ $i ][ $j ] = max( $lcs[ $i - 1 ][ $j ], $lcs[ $i ][ $j - 1 ] );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backtrack to build edit ops.
|
||||
$ops = array();
|
||||
$i = $m;
|
||||
$j = $n;
|
||||
while ( $i > 0 || $j > 0 ) {
|
||||
if ( $i > 0 && $j > 0 && $old_lines[ $i - 1 ] === $new_lines[ $j - 1 ] ) {
|
||||
array_unshift( $ops, array( 'context', $old_lines[ $i - 1 ] ) );
|
||||
--$i;
|
||||
--$j;
|
||||
} elseif ( $j > 0 && ( 0 === $i || $lcs[ $i ][ $j - 1 ] >= $lcs[ $i - 1 ][ $j ] ) ) {
|
||||
array_unshift( $ops, array( 'added', $new_lines[ $j - 1 ] ) );
|
||||
--$j;
|
||||
} else {
|
||||
array_unshift( $ops, array( 'removed', $old_lines[ $i - 1 ] ) );
|
||||
--$i;
|
||||
}
|
||||
}
|
||||
|
||||
// Collapse context: keep 3 lines before/after each changed block.
|
||||
$context_radius = 3;
|
||||
$changed = array();
|
||||
foreach ( $ops as $idx => $op ) {
|
||||
if ( 'context' !== $op[0] ) {
|
||||
$changed[ $idx ] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$result = array();
|
||||
$skipped = 0;
|
||||
foreach ( $ops as $idx => $op ) {
|
||||
$near_change = false;
|
||||
for ( $d = -$context_radius; $d <= $context_radius; $d++ ) {
|
||||
if ( isset( $changed[ $idx + $d ] ) ) {
|
||||
$near_change = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ( 'context' === $op[0] && ! $near_change ) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
if ( $skipped > 0 ) {
|
||||
$result[] = array(
|
||||
'type' => 'omitted',
|
||||
'text' => esc_html(
|
||||
sprintf(
|
||||
/* translators: %d: number of skipped context lines */
|
||||
__( '… %d lines unchanged …', 'prautoblogger' ),
|
||||
$skipped
|
||||
)
|
||||
),
|
||||
);
|
||||
$skipped = 0;
|
||||
}
|
||||
$result[] = array(
|
||||
'type' => $op[0],
|
||||
'text' => esc_html( $op[1] ),
|
||||
);
|
||||
}
|
||||
if ( $skipped > 0 ) {
|
||||
$result[] = array(
|
||||
'type' => 'omitted',
|
||||
'text' => esc_html(
|
||||
sprintf(
|
||||
/* translators: %d: number of skipped context lines */
|
||||
__( '… %d lines unchanged …', 'prautoblogger' ),
|
||||
$skipped
|
||||
)
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a slug back to the canonical registry key via the step map allowlist.
|
||||
*
|
||||
* @param string $slug Slug from POST (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;
|
||||
}
|
||||
}
|
||||
116
includes/ajax/class-pipeline-preview-handler.php
Normal file
116
includes/ajax/class-pipeline-preview-handler.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* AJAX handler: fetch the assembled (rendered) preview for a prompt key.
|
||||
*
|
||||
* What: Returns the fully-assembled instruction text for a given prompt key
|
||||
* and stage. Prefers the last run's rendered prompt (extracted from
|
||||
* the generation_log request_json) so the admin sees the exact text
|
||||
* the LLM received; falls back to a sample render (template with
|
||||
* placeholder token values) when no run exists yet. The preview is
|
||||
* ALWAYS read-only — there is no save path, cap check, or nonce that
|
||||
* would allow the returned text to be persisted. Escaping is done
|
||||
* server-side; the client renders as pre-formatted text.
|
||||
*
|
||||
* Stage-list-driven: the prompt_key -> stage mapping goes through
|
||||
* Stage_Display_Map so Phase 2b stages auto-resolve without rework.
|
||||
*
|
||||
* Security: cap check (manage_options) + nonce verification on every
|
||||
* request. Output is JSON with esc_html'd preview body + a
|
||||
* boolean indicating whether the source was a real run.
|
||||
*
|
||||
* Who triggers it: pipeline-settings.js fetch() on "Preview" tab click.
|
||||
* Dependencies: PRAutoBlogger_Prompt_Registry (render / get_active),
|
||||
* PRAutoBlogger_Pipeline_Preview_Source (last-run query),
|
||||
* PRAutoBlogger_Pipeline_Settings_Step_Map (key allowlist),
|
||||
* PRAutoBlogger_Stage_Display_Map (stage vocabulary).
|
||||
*
|
||||
* @see ajax/class-pipeline-history-handler.php -- Version list / diff AJAX.
|
||||
* @see admin/class-pipeline-settings-page.php -- Registers nonce for JS.
|
||||
* @see core/class-prompt-registry.php -- Template render API.
|
||||
* @see core/class-stage-display-map.php -- Stage vocabulary.
|
||||
* @see ARCHITECTURE.md #21 -- Prompt registry design.
|
||||
*/
|
||||
class PRAutoBlogger_Pipeline_Preview_Handler {
|
||||
|
||||
/** AJAX action name (wp_ajax_ prefixed, manage_options gate). */
|
||||
public const ACTION = 'prautoblogger_pipeline_preview';
|
||||
|
||||
/**
|
||||
* Register wp-ajax hooks. Called from PRAutoBlogger::register_ajax_hooks().
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register_hooks(): void {
|
||||
add_action( 'wp_ajax_' . self::ACTION, array( __CLASS__, 'handle' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the AJAX request.
|
||||
*
|
||||
* Expected POST fields:
|
||||
* nonce string wp_nonce for 'prautoblogger_pipeline_preview'.
|
||||
* prompt_key string Registry key slug (dots to hyphens, post sanitize_key).
|
||||
*
|
||||
* @return void Outputs JSON and calls wp_die().
|
||||
*/
|
||||
public static function handle(): void {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
|
||||
}
|
||||
|
||||
$nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : '';
|
||||
if ( ! wp_verify_nonce( $nonce, self::ACTION ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Security check failed.', 'prautoblogger' ) ), 403 );
|
||||
}
|
||||
|
||||
$slug = isset( $_POST['prompt_key'] ) ? sanitize_key( wp_unslash( $_POST['prompt_key'] ) ) : '';
|
||||
$prompt_key = self::resolve_key_from_slug( $slug );
|
||||
|
||||
if ( null === $prompt_key ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Unknown prompt key.', 'prautoblogger' ) ), 400 );
|
||||
}
|
||||
|
||||
$source_data = PRAutoBlogger_Pipeline_Preview_Source::last_run( $prompt_key );
|
||||
|
||||
if ( null !== $source_data ) {
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'preview' => esc_html( $source_data['rendered'] ),
|
||||
'from_run' => true,
|
||||
'run_date' => esc_html( $source_data['run_date'] ),
|
||||
'run_post' => esc_html( $source_data['run_post'] ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$rendered = PRAutoBlogger_Pipeline_Preview_Source::sample_render( $prompt_key );
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'preview' => esc_html( $rendered ),
|
||||
'from_run' => false,
|
||||
'run_date' => '',
|
||||
'run_post' => '',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a sanitize_key'd slug back to a canonical registry key.
|
||||
* Identical logic to Save_Handler so the same form slug works for both.
|
||||
*
|
||||
* @param string $slug Slug from POST (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;
|
||||
}
|
||||
}
|
||||
@@ -198,6 +198,10 @@ class PRAutoBlogger {
|
||||
$ideas = new PRAutoBlogger_Ideas_Browser();
|
||||
add_action( 'wp_ajax_prautoblogger_generate_from_idea', array( $ideas, 'on_ajax_generate_from_idea' ) );
|
||||
add_action( 'wp_ajax_prautoblogger_idea_gen_status', array( $ideas, 'on_ajax_idea_gen_status' ) );
|
||||
|
||||
// v0.25.0 (M3): pipeline prompt preview + history/diff endpoints.
|
||||
PRAutoBlogger_Pipeline_Preview_Handler::register_hooks();
|
||||
PRAutoBlogger_Pipeline_History_Handler::register_hooks();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.24.0
|
||||
* Version: 0.25.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.24.0' );
|
||||
define( 'PRAUTOBLOGGER_VERSION', '0.25.0' );
|
||||
define( 'PRAUTOBLOGGER_DB_VERSION', '1.4.0' );
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD', 0.50 );
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS', 14 );
|
||||
|
||||
@@ -1,24 +1,30 @@
|
||||
<?php
|
||||
/**
|
||||
* Pipeline Settings page template.
|
||||
* Pipeline Settings page template (M3).
|
||||
*
|
||||
* 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.
|
||||
* array $view['global_fields']— field defs + current values for global context.
|
||||
* string $view['step_context'] — context id matching the active step, or null.
|
||||
* array $view['step_fields'] — field defs + current values for active step context.
|
||||
* 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.
|
||||
* array $view['global_fields'] -- field defs + current values for global context.
|
||||
* string $view['step_context'] -- context id matching the active step, or null.
|
||||
* array $view['step_fields'] -- field defs + current values for active step context.
|
||||
* string $view['preview_nonce'] -- (M3) nonce for preview AJAX.
|
||||
* string $view['history_nonce'] -- (M3) nonce for history AJAX.
|
||||
* string $view['diff_nonce'] -- (M3) nonce for diff AJAX.
|
||||
* string $view['preview_action'] -- (M3) AJAX action name for preview.
|
||||
* string $view['history_action'] -- (M3) AJAX action name for history.
|
||||
* string $view['diff_action'] -- (M3) AJAX action name for diff.
|
||||
*
|
||||
* @see admin/class-pipeline-settings-renderer.php — Provides $view.
|
||||
* @see templates/admin/pipeline-settings-prompt-panel.php — Prompt editor partial.
|
||||
* @see templates/admin/pipeline-settings-step-options.php — Step option fields partial.
|
||||
* @see admin/class-pipeline-settings-renderer.php -- Provides $view.
|
||||
* @see templates/admin/pipeline-settings-prompt-panel.php -- Prompt editor partial.
|
||||
* @see templates/admin/pipeline-settings-step-options.php -- Step option fields partial.
|
||||
*/
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
@@ -84,7 +90,7 @@ $base_url = admin_url( 'admin.php?page=' . rawurlencode( $page_slug ) );
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
// ── Global Content Context block (editable — M2) ─────────────────────────
|
||||
// -- Global Content Context block (editable -- M2) -----------------
|
||||
$context = 'global';
|
||||
$section_title = __( 'Global Content Context', 'prautoblogger' );
|
||||
$step_id = 'global';
|
||||
@@ -117,7 +123,7 @@ $base_url = admin_url( 'admin.php?page=' . rawurlencode( $page_slug ) );
|
||||
</div>
|
||||
|
||||
<?php
|
||||
// ── Step option fields (M2) ───────────────────────────────────────
|
||||
// -- Step option fields (M2) ----------------------------------------
|
||||
if ( null !== $step_context && ! empty( $step_fields ) ) :
|
||||
$context = $step_context;
|
||||
$section_title = __( 'Step Settings', 'prautoblogger' );
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
<?php
|
||||
/**
|
||||
* Partial: Pipeline Settings — single prompt editor panel.
|
||||
* Partial: Pipeline Settings — single prompt editor panel (M3).
|
||||
*
|
||||
* 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.
|
||||
* array $panel -- prompt panel data from Renderer::build_prompt_panel_data().
|
||||
* string $nonce_action -- nonce action string (save form).
|
||||
* string $nonce_field -- nonce field name (save form).
|
||||
* 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.
|
||||
* array $view -- full view array (carries preview/history nonces).
|
||||
*
|
||||
* @see templates/admin/pipeline-settings-page.php — Includes this partial.
|
||||
* @see templates/admin/pipeline-settings-page.php -- Includes this partial.
|
||||
* @see ajax/class-pipeline-preview-handler.php -- Serves preview AJAX.
|
||||
* @see ajax/class-pipeline-history-handler.php -- Serves history/diff AJAX.
|
||||
*/
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$key = $panel['key'];
|
||||
$css_key = str_replace( '.', '-', $key );
|
||||
$form_key = str_replace( '.', '-', $key );
|
||||
$key = $panel['key'];
|
||||
$css_key = str_replace( '.', '-', $key );
|
||||
$form_key = str_replace( '.', '-', $key );
|
||||
|
||||
// Writer-step inactive detection.
|
||||
$is_inactive = false;
|
||||
@@ -31,6 +34,14 @@ if ( isset( $is_writer ) && $is_writer ) {
|
||||
$is_inactive = ( 'single_pass' === $writing_mode && in_array( $key, $multi_step_keys, true ) )
|
||||
|| ( 'multi_step' === $writing_mode && in_array( $key, $single_pass_keys, true ) );
|
||||
}
|
||||
|
||||
$versions = $panel['versions'] ?? array();
|
||||
$has_history = count( $versions ) > 1;
|
||||
$history_id = 'pab-history-' . $css_key;
|
||||
$diff_id = 'pab-diff-' . $css_key;
|
||||
$preview_id = 'pab-preview-' . $css_key;
|
||||
$template_id = 'pab-template-' . $css_key;
|
||||
$toggle_hint_id = 'pab-toggle-hint-' . $css_key;
|
||||
?>
|
||||
<div class="pab-section">
|
||||
<h3 class="pab-section-title">
|
||||
@@ -76,32 +87,159 @@ if ( isset( $is_writer ) && $is_writer ) {
|
||||
</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' ); ?>
|
||||
<!-- M3: Template / Preview toggle -->
|
||||
<div class="pab-tp-toggle-row">
|
||||
<div class="pab-tp-toggle" role="group"
|
||||
aria-label="<?php esc_attr_e( 'Template or preview mode', 'prautoblogger' ); ?>"
|
||||
data-prompt-key="<?php echo esc_attr( $form_key ); ?>"
|
||||
data-preview-id="<?php echo esc_attr( $preview_id ); ?>"
|
||||
data-template-id="<?php echo esc_attr( $template_id ); ?>"
|
||||
data-toggle-hint-id="<?php echo esc_attr( $toggle_hint_id ); ?>"
|
||||
data-ajax-url="<?php echo esc_attr( admin_url( 'admin-ajax.php' ) ); ?>"
|
||||
data-preview-action="<?php echo esc_attr( $view['preview_action'] ); ?>"
|
||||
data-preview-nonce="<?php echo esc_attr( $view['preview_nonce'] ); ?>">
|
||||
<button type="button"
|
||||
class="pab-tp-btn pab-tp-btn--active"
|
||||
data-mode="template"
|
||||
aria-pressed="true">
|
||||
<?php esc_html_e( 'Template', '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 type="button"
|
||||
class="pab-tp-btn"
|
||||
data-mode="preview"
|
||||
aria-pressed="false">
|
||||
<?php esc_html_e( 'Preview assembled instructions', 'prautoblogger' ); ?>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</form>
|
||||
<span id="<?php echo esc_attr( $toggle_hint_id ); ?>" class="pab-tp-hint">
|
||||
<?php esc_html_e( 'Editing the template affects all future runs.', 'prautoblogger' ); ?>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Template view (editable) -->
|
||||
<div id="<?php echo esc_attr( $template_id ); ?>" class="pab-tp-view pab-tp-view--template">
|
||||
<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 ); ?>" />
|
||||
|
||||
<div class="pab-prompt-editor-wrap">
|
||||
<div class="pab-prompt-editor-toolbar">
|
||||
<span class="pab-prompt-editor-title">
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s = prompt registry key */
|
||||
esc_html__( 'Template — %s', 'prautoblogger' ),
|
||||
esc_html( $key )
|
||||
);
|
||||
?>
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- Preview view (read-only, M3) -->
|
||||
<div id="<?php echo esc_attr( $preview_id ); ?>"
|
||||
class="pab-tp-view pab-tp-view--preview"
|
||||
style="display:none;">
|
||||
<div class="pab-preview-block">
|
||||
<div class="pab-preview-header">
|
||||
<span class="pab-preview-label"><?php esc_html_e( 'Preview — assembled instructions', 'prautoblogger' ); ?></span>
|
||||
<span class="pab-preview-readonly-badge"><?php esc_html_e( 'Read-only', 'prautoblogger' ); ?></span>
|
||||
<span class="pab-preview-source-note js-preview-source"></span>
|
||||
</div>
|
||||
<pre class="pab-preview-body js-preview-body"><span class="pab-preview-loading"><?php esc_html_e( 'Loading preview…', 'prautoblogger' ); ?></span></pre>
|
||||
<div class="pab-preview-notice">
|
||||
<span class="dashicons dashicons-info-outline"></span>
|
||||
<span><?php echo wp_kses( __( '<strong>Preview only — not editable.</strong> This is the fully-rendered prompt the LLM will receive, with tokens filled from the last run. To change the template body, switch to the Template view.', 'prautoblogger' ), array( 'strong' => array() ) ); ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- M3: Version history accordion -->
|
||||
<?php if ( count( $versions ) > 0 ) : ?>
|
||||
<div class="pab-history-accordion">
|
||||
<button type="button"
|
||||
class="pab-history-trigger"
|
||||
aria-expanded="false"
|
||||
aria-controls="<?php echo esc_attr( $history_id ); ?>">
|
||||
<span class="dashicons dashicons-arrow-down-alt2 pab-history-arrow"></span>
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s = prompt registry key */
|
||||
esc_html__( 'Version history — %s', 'prautoblogger' ),
|
||||
esc_html( $key )
|
||||
);
|
||||
?>
|
||||
</button>
|
||||
<div id="<?php echo esc_attr( $history_id ); ?>"
|
||||
class="pab-history-body"
|
||||
style="display:none;">
|
||||
<?php foreach ( $versions as $ver ) : ?>
|
||||
<?php
|
||||
$is_current = (bool) $ver['active'];
|
||||
$ver_num = (int) $ver['version'];
|
||||
?>
|
||||
<div class="pab-version-row">
|
||||
<span class="pab-version-num">v<?php echo esc_html( (string) $ver_num ); ?></span>
|
||||
<span class="pab-version-author"><?php echo esc_html( (string) $ver['author'] ); ?></span>
|
||||
<span class="pab-version-date"><?php echo esc_html( (string) $ver['created_at'] ); ?></span>
|
||||
<?php if ( $is_current ) : ?>
|
||||
<span class="pab-badge pab-badge-ok pab-version-current"><?php esc_html_e( 'current', 'prautoblogger' ); ?></span>
|
||||
<?php endif; ?>
|
||||
<?php if ( $ver_num > 1 ) : ?>
|
||||
<button type="button"
|
||||
class="ab-btn ab-btn-outline ab-btn-sm pab-diff-btn"
|
||||
data-prompt-key="<?php echo esc_attr( $form_key ); ?>"
|
||||
data-version-a="<?php echo esc_attr( (string) ( $ver_num - 1 ) ); ?>"
|
||||
data-version-b="<?php echo esc_attr( (string) $ver_num ); ?>"
|
||||
data-diff-target="<?php echo esc_attr( $diff_id ); ?>"
|
||||
data-ajax-url="<?php echo esc_attr( admin_url( 'admin-ajax.php' ) ); ?>"
|
||||
data-diff-action="<?php echo esc_attr( $view['diff_action'] ); ?>"
|
||||
data-diff-nonce="<?php echo esc_attr( $view['diff_nonce'] ); ?>">
|
||||
<?php esc_html_e( 'Diff', 'prautoblogger' ); ?>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- M3: Diff display area (hidden until a Diff button is clicked) -->
|
||||
<div id="<?php echo esc_attr( $diff_id ); ?>"
|
||||
class="pab-diff-panel"
|
||||
style="display:none;">
|
||||
<div class="pab-diff-header js-diff-header"></div>
|
||||
<div class="pab-diff-block js-diff-lines"></div>
|
||||
<button type="button"
|
||||
class="ab-btn ab-btn-outline ab-btn-sm pab-diff-close"
|
||||
data-diff-id="<?php echo esc_attr( $diff_id ); ?>">
|
||||
<?php esc_html_e( 'Close diff', 'prautoblogger' ); ?>
|
||||
</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</div>
|
||||
|
||||
131
tests/unit/Ajax/PipelineHistoryHandlerTest.php
Normal file
131
tests/unit/Ajax/PipelineHistoryHandlerTest.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
/**
|
||||
* Unit tests for PRAutoBlogger_Pipeline_History_Handler.
|
||||
*
|
||||
* Tests cover:
|
||||
* (1) compute_diff() LCS algorithm via reflection on the private static method.
|
||||
* (2) resolve_key_from_slug() round-trip through the Step_Map allowlist.
|
||||
*
|
||||
* NOTE: PHP is unavailable in the sandbox. Structure was self-verified
|
||||
* prior to push (see PR description for grep + brace-count proof).
|
||||
*
|
||||
* @see ajax/class-pipeline-history-handler.php
|
||||
* @see admin/class-pipeline-settings-step-map.php
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Ajax
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Ajax;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class PipelineHistoryHandlerTest extends BaseTestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
Functions\when( 'current_user_can' )->justReturn( true );
|
||||
Functions\when( 'wp_verify_nonce' )->justReturn( true );
|
||||
Functions\when( 'sanitize_key' )->alias(
|
||||
function ( string $key ): string {
|
||||
return strtolower( (string) preg_replace( '/[^a-z0-9_\-]/', '', $key ) );
|
||||
}
|
||||
);
|
||||
Functions\when( 'wp_unslash' )->alias(
|
||||
function ( $val ) {
|
||||
return $val;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* compute_diff detects added and removed lines.
|
||||
*/
|
||||
public function test_compute_diff_detects_changes(): void {
|
||||
$old = "Line A\nLine B\nLine C";
|
||||
$new = "Line A\nLine X\nLine C";
|
||||
|
||||
$method = new \ReflectionMethod( 'PRAutoBlogger_Pipeline_History_Handler', 'compute_diff' );
|
||||
$method->setAccessible( true );
|
||||
$lines = $method->invoke( null, $old, $new );
|
||||
|
||||
$types = array_column( $lines, 'type' );
|
||||
|
||||
$this->assertContains( 'removed', $types, 'Expected a removed line' );
|
||||
$this->assertContains( 'added', $types, 'Expected an added line' );
|
||||
$this->assertContains( 'context', $types, 'Expected context lines' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Identical texts produce only context lines.
|
||||
*/
|
||||
public function test_compute_diff_identical_texts_no_changes(): void {
|
||||
$body = "Same line\nAnother same line";
|
||||
|
||||
$method = new \ReflectionMethod( 'PRAutoBlogger_Pipeline_History_Handler', 'compute_diff' );
|
||||
$method->setAccessible( true );
|
||||
$lines = $method->invoke( null, $body, $body );
|
||||
|
||||
foreach ( $lines as $line ) {
|
||||
$this->assertNotEquals( 'added', $line['type'], 'No added lines for identical input' );
|
||||
$this->assertNotEquals( 'removed', $line['type'], 'No removed lines for identical input' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Each diff line has the required type and text keys.
|
||||
*/
|
||||
public function test_compute_diff_line_shape(): void {
|
||||
$old = "alpha\nbeta";
|
||||
$new = "alpha\ngamma";
|
||||
|
||||
$method = new \ReflectionMethod( 'PRAutoBlogger_Pipeline_History_Handler', 'compute_diff' );
|
||||
$method->setAccessible( true );
|
||||
$lines = $method->invoke( null, $old, $new );
|
||||
|
||||
foreach ( $lines as $line ) {
|
||||
$this->assertArrayHasKey( 'type', $line );
|
||||
$this->assertArrayHasKey( 'text', $line );
|
||||
$this->assertIsString( $line['type'] );
|
||||
$this->assertIsString( $line['text'] );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* resolve_key_from_slug returns canonical key for a known slug.
|
||||
*/
|
||||
public function test_resolve_key_from_slug_returns_canonical_key(): void {
|
||||
$method = new \ReflectionMethod( 'PRAutoBlogger_Pipeline_History_Handler', 'resolve_key_from_slug' );
|
||||
$method->setAccessible( true );
|
||||
|
||||
$result = $method->invoke( null, 'research-system' );
|
||||
$this->assertEquals( 'research.system', $result );
|
||||
}
|
||||
|
||||
/**
|
||||
* resolve_key_from_slug returns null for an unknown slug.
|
||||
*/
|
||||
public function test_resolve_key_from_slug_returns_null_for_unknown(): void {
|
||||
$method = new \ReflectionMethod( 'PRAutoBlogger_Pipeline_History_Handler', 'resolve_key_from_slug' );
|
||||
$method->setAccessible( true );
|
||||
|
||||
$result = $method->invoke( null, 'not-a-real-key-xyz' );
|
||||
$this->assertNull( $result );
|
||||
}
|
||||
|
||||
/**
|
||||
* Far-away context lines are collapsed with an omitted marker.
|
||||
*/
|
||||
public function test_compute_diff_omits_far_context(): void {
|
||||
$old_lines = array( 'changed', 'ctx1', 'ctx2', 'ctx3', 'ctx4', 'ctx5', 'ctx6', 'ctx7', 'ctx8', 'end' );
|
||||
$new_lines = array( 'CHANGED', 'ctx1', 'ctx2', 'ctx3', 'ctx4', 'ctx5', 'ctx6', 'ctx7', 'ctx8', 'end' );
|
||||
|
||||
$method = new \ReflectionMethod( 'PRAutoBlogger_Pipeline_History_Handler', 'compute_diff' );
|
||||
$method->setAccessible( true );
|
||||
$lines = $method->invoke( null, implode( "\n", $old_lines ), implode( "\n", $new_lines ) );
|
||||
|
||||
$types = array_column( $lines, 'type' );
|
||||
$this->assertContains( 'omitted', $types, 'Far-away context lines should be omitted' );
|
||||
}
|
||||
}
|
||||
123
tests/unit/Ajax/PipelinePreviewSourceTest.php
Normal file
123
tests/unit/Ajax/PipelinePreviewSourceTest.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
/**
|
||||
* Unit tests for PRAutoBlogger_Pipeline_Preview_Source.
|
||||
*
|
||||
* Tests cover pure static helper methods via reflection:
|
||||
* (1) extract_rendered_from_messages() role-preference logic.
|
||||
* (2) stage_for_key() mapping from key to stage slug.
|
||||
*
|
||||
* sample_render() and last_run() require WP DB integration so are
|
||||
* deferred to the functional smoke test suite on prod.
|
||||
*
|
||||
* NOTE: PHP is unavailable in the sandbox. Structure self-verified
|
||||
* prior to push (grep + brace-count proof in PR description).
|
||||
*
|
||||
* @see admin/class-pipeline-preview-source.php
|
||||
* @see core/class-stage-display-map.php
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Ajax
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Ajax;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
|
||||
class PipelinePreviewSourceTest extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* extract_rendered_from_messages prefers system role for *.system keys.
|
||||
*/
|
||||
public function test_extract_prefers_system_role_for_system_keys(): void {
|
||||
$messages = array(
|
||||
array( 'role' => 'system', 'content' => 'System prompt text here' ),
|
||||
array( 'role' => 'user', 'content' => 'User message here' ),
|
||||
);
|
||||
|
||||
$method = new \ReflectionMethod( 'PRAutoBlogger_Pipeline_Preview_Source', 'extract_rendered_from_messages' );
|
||||
$method->setAccessible( true );
|
||||
|
||||
$result = $method->invoke( null, $messages, 'analysis.system' );
|
||||
$this->assertEquals( 'System prompt text here', $result );
|
||||
}
|
||||
|
||||
/**
|
||||
* extract_rendered_from_messages prefers user role for agent keys.
|
||||
*/
|
||||
public function test_extract_prefers_user_role_for_agent_keys(): void {
|
||||
$messages = array(
|
||||
array( 'role' => 'system', 'content' => 'System prompt text here' ),
|
||||
array( 'role' => 'user', 'content' => 'User message here' ),
|
||||
);
|
||||
|
||||
$method = new \ReflectionMethod( 'PRAutoBlogger_Pipeline_Preview_Source', 'extract_rendered_from_messages' );
|
||||
$method->setAccessible( true );
|
||||
|
||||
$result = $method->invoke( null, $messages, 'analysis.user' );
|
||||
$this->assertEquals( 'User message here', $result );
|
||||
}
|
||||
|
||||
/**
|
||||
* extract_rendered_from_messages falls back to longest message when no role match.
|
||||
*/
|
||||
public function test_extract_fallback_longest_when_no_role_match(): void {
|
||||
$messages = array(
|
||||
array( 'role' => 'assistant', 'content' => 'Short' ),
|
||||
array( 'role' => 'assistant', 'content' => 'This is the longest message in the array by far' ),
|
||||
);
|
||||
|
||||
$method = new \ReflectionMethod( 'PRAutoBlogger_Pipeline_Preview_Source', 'extract_rendered_from_messages' );
|
||||
$method->setAccessible( true );
|
||||
|
||||
$result = $method->invoke( null, $messages, 'content.draft' );
|
||||
$this->assertEquals( 'This is the longest message in the array by far', $result );
|
||||
}
|
||||
|
||||
/**
|
||||
* extract returns empty string when messages array is empty.
|
||||
*/
|
||||
public function test_extract_empty_messages_returns_empty(): void {
|
||||
$method = new \ReflectionMethod( 'PRAutoBlogger_Pipeline_Preview_Source', 'extract_rendered_from_messages' );
|
||||
$method->setAccessible( true );
|
||||
|
||||
$result = $method->invoke( null, array(), 'analysis.system' );
|
||||
$this->assertSame( '', $result );
|
||||
}
|
||||
|
||||
/**
|
||||
* stage_for_key maps 'analysis.system' to 'analysis' stage.
|
||||
*/
|
||||
public function test_stage_for_key_maps_analysis_system(): void {
|
||||
$method = new \ReflectionMethod( 'PRAutoBlogger_Pipeline_Preview_Source', 'stage_for_key' );
|
||||
$method->setAccessible( true );
|
||||
|
||||
$result = $method->invoke( null, 'analysis.system' );
|
||||
$this->assertEquals( 'analysis', $result );
|
||||
}
|
||||
|
||||
/**
|
||||
* stage_for_key maps 'research.system' to 'llm_research' stage.
|
||||
*
|
||||
* Stage_Display_Map::MAP lists 'llm_research' before the Phase 2b 'research'
|
||||
* entry; both share prompt_key => 'research.system' so the first-match-wins
|
||||
* foreach returns 'llm_research'. This is correct: current prod runs write
|
||||
* 'llm_research' to prab_generation_log.stage (LLM_Research_Provider line 74).
|
||||
*/
|
||||
public function test_stage_for_key_maps_research_system(): void {
|
||||
$method = new \ReflectionMethod( 'PRAutoBlogger_Pipeline_Preview_Source', 'stage_for_key' );
|
||||
$method->setAccessible( true );
|
||||
|
||||
$result = $method->invoke( null, 'research.system' );
|
||||
$this->assertEquals( 'llm_research', $result );
|
||||
}
|
||||
|
||||
/**
|
||||
* stage_for_key returns null for an unmapped key.
|
||||
*/
|
||||
public function test_stage_for_key_returns_null_for_unknown(): void {
|
||||
$method = new \ReflectionMethod( 'PRAutoBlogger_Pipeline_Preview_Source', 'stage_for_key' );
|
||||
$method->setAccessible( true );
|
||||
|
||||
$result = $method->invoke( null, 'not.a.real.key' );
|
||||
$this->assertNull( $result );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user