diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7ebdb17..3ed1a2e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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. + --- diff --git a/CHANGELOG.md b/CHANGELOG.md index d0afcd0..bd32630 100644 --- a/CHANGELOG.md +++ b/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 · " 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 diff --git a/INFORMATION-ARCHITECTURE.md b/INFORMATION-ARCHITECTURE.md index a1237c3..55415f8 100644 --- a/INFORMATION-ARCHITECTURE.md +++ b/INFORMATION-ARCHITECTURE.md @@ -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 | diff --git a/assets/css/pipeline-settings.css b/assets/css/pipeline-settings.css index 02d4b88..04402d4 100644 --- a/assets/css/pipeline-settings.css +++ b/assets/css/pipeline-settings.css @@ -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; +} diff --git a/assets/js/pipeline-settings.js b/assets/js/pipeline-settings.js index cad1bbc..8f31f8b 100644 --- a/assets/js/pipeline-settings.js +++ b/assets/js/pipeline-settings.js @@ -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( '…' ); + + $.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 = $( '
' ).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 ) ); diff --git a/includes/admin/class-pipeline-preview-source.php b/includes/admin/class-pipeline-preview-source.php new file mode 100644 index 0000000..83b3669 --- /dev/null +++ b/includes/admin/class-pipeline-preview-source.php @@ -0,0 +1,199 @@ +]" 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> $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; + } +} diff --git a/includes/admin/class-pipeline-settings-page.php b/includes/admin/class-pipeline-settings-page.php index 86f9768..2e5111e 100644 --- a/includes/admin/class-pipeline-settings-page.php +++ b/includes/admin/class-pipeline-settings-page.php @@ -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(); diff --git a/includes/admin/class-pipeline-settings-renderer.php b/includes/admin/class-pipeline-settings-renderer.php index 7f3464b..1199798 100644 --- a/includes/admin/class-pipeline-settings-renderer.php +++ b/includes/admin/class-pipeline-settings-renderer.php @@ -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> */ @@ -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}> $defs All defs. * @param array $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 $field_args Field args forwarded to the picker. diff --git a/includes/ajax/class-pipeline-history-handler.php b/includes/ajax/class-pipeline-history-handler.php new file mode 100644 index 0000000..32f84e4 --- /dev/null +++ b/includes/ajax/class-pipeline-history-handler.php @@ -0,0 +1,291 @@ + __( '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 + */ + 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; + } +} diff --git a/includes/ajax/class-pipeline-preview-handler.php b/includes/ajax/class-pipeline-preview-handler.php new file mode 100644 index 0000000..750dc9d --- /dev/null +++ b/includes/ajax/class-pipeline-preview-handler.php @@ -0,0 +1,116 @@ + 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; + } +} diff --git a/includes/class-prautoblogger.php b/includes/class-prautoblogger.php index a945907..cac799a 100644 --- a/includes/class-prautoblogger.php +++ b/includes/class-prautoblogger.php @@ -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(); } /** diff --git a/prautoblogger.php b/prautoblogger.php index f852f69..3b6dbf0 100644 --- a/prautoblogger.php +++ b/prautoblogger.php @@ -9,7 +9,7 @@ * Plugin Name: PRAutoBlogger * Plugin URI: https://peptiderepo.com/prautoblogger * Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics. - * Version: 0.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 ); diff --git a/templates/admin/pipeline-settings-page.php b/templates/admin/pipeline-settings-page.php index 306f776..608993c 100644 --- a/templates/admin/pipeline-settings-page.php +++ b/templates/admin/pipeline-settings-page.php @@ -1,24 +1,30 @@ 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; ?>

@@ -76,32 +87,159 @@ if ( isset( $is_writer ) && $is_writer ) {

-
- - - - - - -
- - - -
-
+ + + +

+ + +
+
+ + + + +
+
+ + + +
+ +
+ +
+ + + + +
+
+
+ + + + + + 0 ) : ?> +
+ + +
+ + + + +
diff --git a/tests/unit/Ajax/PipelineHistoryHandlerTest.php b/tests/unit/Ajax/PipelineHistoryHandlerTest.php new file mode 100644 index 0000000..dcc078c --- /dev/null +++ b/tests/unit/Ajax/PipelineHistoryHandlerTest.php @@ -0,0 +1,131 @@ +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' ); + } +} diff --git a/tests/unit/Ajax/PipelinePreviewSourceTest.php b/tests/unit/Ajax/PipelinePreviewSourceTest.php new file mode 100644 index 0000000..afcefdf --- /dev/null +++ b/tests/unit/Ajax/PipelinePreviewSourceTest.php @@ -0,0 +1,123 @@ + '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 ); + } +}