Compare commits
13 Commits
claude/pha
...
claude/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10f95f709e | ||
|
|
18cd342634 | ||
|
|
110c681c57 | ||
|
|
d770e8a86a | ||
|
|
326a1af900 | ||
|
|
6ce2cbb94c | ||
|
|
2d641153c7 | ||
|
|
ac2f20de7e | ||
|
|
3b6a302f95 | ||
|
|
5c3328dee2 | ||
|
|
9c8e500244 | ||
|
|
2911c94f57 | ||
|
|
49f17c3d6f |
@@ -35,25 +35,33 @@ add_action( 'rest_api_init', 'prautoblogger_deploy_register_route' );
|
||||
* Register the deploy REST route.
|
||||
*/
|
||||
function prautoblogger_deploy_register_route(): void {
|
||||
register_rest_route( 'prautoblogger-deploy/v1', '/deploy', [
|
||||
'methods' => 'POST',
|
||||
'callback' => 'prautoblogger_deploy_handle',
|
||||
'permission_callback' => 'prautoblogger_deploy_check_auth',
|
||||
] );
|
||||
register_rest_route(
|
||||
'prautoblogger-deploy/v1',
|
||||
'/deploy',
|
||||
array(
|
||||
'methods' => 'POST',
|
||||
'callback' => 'prautoblogger_deploy_handle',
|
||||
'permission_callback' => 'prautoblogger_deploy_check_auth',
|
||||
)
|
||||
);
|
||||
|
||||
// Health check endpoint (GET) — useful for monitoring.
|
||||
register_rest_route( 'prautoblogger-deploy/v1', '/status', [
|
||||
'methods' => 'GET',
|
||||
'callback' => function () {
|
||||
return [
|
||||
'status' => 'ready',
|
||||
'plugin' => is_dir( WP_PLUGIN_DIR . '/prautoblogger' ) ? 'installed' : 'not_installed',
|
||||
'active' => is_plugin_active( 'prautoblogger/prautoblogger.php' ),
|
||||
'version' => prautoblogger_deploy_get_installed_version(),
|
||||
];
|
||||
},
|
||||
'permission_callback' => 'prautoblogger_deploy_check_auth',
|
||||
] );
|
||||
register_rest_route(
|
||||
'prautoblogger-deploy/v1',
|
||||
'/status',
|
||||
array(
|
||||
'methods' => 'GET',
|
||||
'callback' => function () {
|
||||
return array(
|
||||
'status' => 'ready',
|
||||
'plugin' => is_dir( WP_PLUGIN_DIR . '/prautoblogger' ) ? 'installed' : 'not_installed',
|
||||
'active' => is_plugin_active( 'prautoblogger/prautoblogger.php' ),
|
||||
'version' => prautoblogger_deploy_get_installed_version(),
|
||||
);
|
||||
},
|
||||
'permission_callback' => 'prautoblogger_deploy_check_auth',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,7 +86,7 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon
|
||||
return new WP_Error(
|
||||
'rate_limited',
|
||||
'Deploy in progress or recently completed. Wait 60 seconds.',
|
||||
[ 'status' => 429 ]
|
||||
array( 'status' => 429 )
|
||||
);
|
||||
}
|
||||
set_transient( 'prautoblogger_deploy_lock', time(), 60 );
|
||||
@@ -87,7 +95,7 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon
|
||||
$files = $request->get_file_params();
|
||||
if ( empty( $files['plugin']['tmp_name'] ) || ! is_uploaded_file( $files['plugin']['tmp_name'] ) ) {
|
||||
delete_transient( 'prautoblogger_deploy_lock' );
|
||||
return new WP_Error( 'no_file', 'No plugin zip uploaded.', [ 'status' => 400 ] );
|
||||
return new WP_Error( 'no_file', 'No plugin zip uploaded.', array( 'status' => 400 ) );
|
||||
}
|
||||
|
||||
$tmp_file = $files['plugin']['tmp_name'];
|
||||
@@ -96,11 +104,11 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon
|
||||
$zip = new ZipArchive();
|
||||
if ( true !== $zip->open( $tmp_file ) ) {
|
||||
delete_transient( 'prautoblogger_deploy_lock' );
|
||||
return new WP_Error( 'invalid_zip', 'Uploaded file is not a valid zip.', [ 'status' => 400 ] );
|
||||
return new WP_Error( 'invalid_zip', 'Uploaded file is not a valid zip.', array( 'status' => 400 ) );
|
||||
}
|
||||
|
||||
$has_main_file = false;
|
||||
for ( $i = 0; $i < $zip->numFiles; $i++ ) {
|
||||
for ( $i = 0; $i < $zip->numFiles; $i++ ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
|
||||
if ( 'prautoblogger/prautoblogger.php' === $zip->getNameIndex( $i ) ) {
|
||||
$has_main_file = true;
|
||||
break;
|
||||
@@ -113,7 +121,7 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon
|
||||
return new WP_Error(
|
||||
'invalid_plugin',
|
||||
'Zip must contain prautoblogger/prautoblogger.php at root.',
|
||||
[ 'status' => 400 ]
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
@@ -143,7 +151,7 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon
|
||||
activate_plugin( 'prautoblogger/prautoblogger.php' );
|
||||
}
|
||||
delete_transient( 'prautoblogger_deploy_lock' );
|
||||
return new WP_Error( 'backup_failed', 'Could not backup current plugin.', [ 'status' => 500 ] );
|
||||
return new WP_Error( 'backup_failed', 'Could not backup current plugin.', array( 'status' => 500 ) );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,7 +169,7 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon
|
||||
}
|
||||
delete_transient( 'prautoblogger_deploy_lock' );
|
||||
error_log( '[PRAutoBlogger Deploy] Extraction failed. Rolled back to previous version.' );
|
||||
return new WP_Error( 'extract_failed', 'Zip extraction failed. Rolled back.', [ 'status' => 500 ] );
|
||||
return new WP_Error( 'extract_failed', 'Zip extraction failed. Rolled back.', array( 'status' => 500 ) );
|
||||
}
|
||||
|
||||
// 7. Activate plugin.
|
||||
@@ -178,7 +186,7 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon
|
||||
return new WP_Error(
|
||||
'activation_failed',
|
||||
'Plugin activation failed: ' . $activation_result->get_error_message() . '. Rolled back.',
|
||||
[ 'status' => 500 ]
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
@@ -188,18 +196,23 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon
|
||||
}
|
||||
|
||||
$old_version = prautoblogger_deploy_get_installed_version();
|
||||
error_log( sprintf(
|
||||
'[PRAutoBlogger Deploy] Successfully deployed v%s (was: %s) at %s',
|
||||
$incoming_version,
|
||||
$old_version ?: 'fresh install',
|
||||
gmdate( 'Y-m-d H:i:s' )
|
||||
) );
|
||||
error_log(
|
||||
sprintf(
|
||||
'[PRAutoBlogger Deploy] Successfully deployed v%s (was: %s) at %s',
|
||||
$incoming_version,
|
||||
$old_version ? $old_version : 'fresh install',
|
||||
gmdate( 'Y-m-d H:i:s' )
|
||||
)
|
||||
);
|
||||
|
||||
return new WP_REST_Response( [
|
||||
'success' => true,
|
||||
'version' => $incoming_version,
|
||||
'message' => "PRAutoBlogger v{$incoming_version} deployed and activated.",
|
||||
], 200 );
|
||||
return new WP_REST_Response(
|
||||
array(
|
||||
'success' => true,
|
||||
'version' => $incoming_version,
|
||||
'message' => "PRAutoBlogger v{$incoming_version} deployed and activated.",
|
||||
),
|
||||
200
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,7 +223,7 @@ function prautoblogger_deploy_get_installed_version(): string {
|
||||
if ( ! file_exists( $main_file ) ) {
|
||||
return '';
|
||||
}
|
||||
$data = get_file_data( $main_file, [ 'Version' => 'Version' ] );
|
||||
$data = get_file_data( $main_file, array( 'Version' => 'Version' ) );
|
||||
return $data['Version'] ?? '';
|
||||
}
|
||||
|
||||
|
||||
132
.github/workflows/ci.yml
vendored
132
.github/workflows/ci.yml
vendored
@@ -1,111 +1,53 @@
|
||||
name: CI — Lint & Test
|
||||
|
||||
# Runs on every PR and every push to main. Validates PHPUnit tests + lint +
|
||||
# coding standards before deploy can happen. deploy.yml on main continues to
|
||||
# run its own validate job as a belt-and-suspenders check, but this CI workflow
|
||||
# is what gives PRs an immediate signal so fixes happen before merge.
|
||||
##
|
||||
# CI caller — PRAutoBlogger
|
||||
#
|
||||
# Thin caller that delegates to the reusable estate CI workflow in peptide-e2e.
|
||||
# workflow_call is required so deploy.yml can gate on this workflow via
|
||||
# `uses: ./.github/workflows/ci.yml` (the workflow_call trigger enables that).
|
||||
#
|
||||
# Permissions: contents: write is required at the caller level so the reusable
|
||||
# workflow's phpcbf auto-commit job can push fixes back to the PR branch.
|
||||
# (GitHub enforces that callers cannot be escalated beyond their declared scope.)
|
||||
#
|
||||
# tests: brain-monkey — no WP install or DB required; Brain Monkey mocking only.
|
||||
# line_limit_paths: includes — estate hard 300-line rule applied to includes/.
|
||||
##
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request: {}
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_call: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
php-lint:
|
||||
name: PHP Lint (${{ matrix.php }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
php: ['8.1', '8.2', '8.3']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup PHP ${{ matrix.php }}
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
coverage: none
|
||||
- name: Syntax check all PHP files
|
||||
# Exclude .github/mu-plugins — those run on the production server (PHP 8+)
|
||||
# and may use PHP 8+ syntax (union types etc.) that older versions reject.
|
||||
run: |
|
||||
set -e
|
||||
find . -name '*.php' \
|
||||
-not -path './vendor/*' \
|
||||
-not -path './node_modules/*' \
|
||||
-not -path './.git/*' \
|
||||
-not -path './.github/*' \
|
||||
-print0 | xargs -0 -n1 -P4 php -l > /dev/null
|
||||
ci:
|
||||
uses: peptide-repo/peptide-e2e/.github/workflows/ci.yml@main
|
||||
with:
|
||||
tests: brain-monkey
|
||||
secrets: inherit
|
||||
|
||||
phpcs:
|
||||
name: PHPCS (WordPress standards)
|
||||
check-constants:
|
||||
name: Check PRAUTOBLOGGER_* constants
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: false # strict after PHPCS cleanup in v0.8.3
|
||||
permissions:
|
||||
contents: write # allows phpcbf auto-fix commits back to the PR branch
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.head_ref || github.ref_name }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Setup PHP
|
||||
- name: Setup PHP 8.1
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.1'
|
||||
coverage: none
|
||||
tools: composer:v2, phpcs
|
||||
- name: Install WordPress Coding Standards
|
||||
run: |
|
||||
composer global config --no-plugins allow-plugins.dealerdirect/phpcodesniffer-composer-installer true
|
||||
composer global require --no-interaction \
|
||||
"dealerdirect/phpcodesniffer-composer-installer:^1.0" \
|
||||
"wp-coding-standards/wpcs:^3.0" \
|
||||
"phpcsstandards/phpcsutils:^1.0" \
|
||||
"phpcsstandards/phpcsextra:^1.0"
|
||||
- name: Auto-fix with phpcbf (commit back to branch if changed)
|
||||
# phpcbf exits 1 when it makes fixes, 0 when nothing to fix — both are success here.
|
||||
# Commits made via GITHUB_TOKEN do not trigger a new CI run (GitHub prevents loops).
|
||||
run: |
|
||||
phpcbf --standard=phpcs.xml \
|
||||
--extensions=php \
|
||||
--ignore=vendor/*,tests/*,node_modules/* \
|
||||
includes/ prautoblogger.php uninstall.php || true
|
||||
if ! git diff --quiet; then
|
||||
git config user.name "peptiderepo"
|
||||
git config user.email "peptiderepo@users.noreply.github.com"
|
||||
git add -A
|
||||
git commit -m "style: auto-fix PHPCS violations via phpcbf"
|
||||
git push
|
||||
fi
|
||||
- name: Run PHPCS (only non-auto-fixable violations can fail here)
|
||||
run: |
|
||||
phpcs --standard=phpcs.xml \
|
||||
--extensions=php \
|
||||
--ignore=vendor/*,tests/*,node_modules/* \
|
||||
--warning-severity=0 \
|
||||
--report=full \
|
||||
includes/ prautoblogger.php uninstall.php
|
||||
|
||||
phpunit:
|
||||
name: PHPUnit (PHP ${{ matrix.php }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
php: ['8.1', '8.2', '8.3']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup PHP ${{ matrix.php }}
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
coverage: none
|
||||
tools: composer:v2
|
||||
- name: Install composer dependencies
|
||||
run: composer install --no-progress --no-interaction --prefer-dist
|
||||
- name: Run PHPUnit (Brain Monkey — no WP install required)
|
||||
run: vendor/bin/phpunit --testdox
|
||||
- name: Check PRAUTOBLOGGER_* constants
|
||||
# Fails if any PRAUTOBLOGGER_* constant referenced in the shipped
|
||||
# plugin PHP is neither defined in prautoblogger.php nor guarded
|
||||
# by defined() at the reference site. Prevents the v0.19.0 incident
|
||||
# class (constant deleted from bootstrap, test fallback masks the gap).
|
||||
run: php scripts/check-constants.php
|
||||
- name: Self-test check-constants scanner
|
||||
# Verifies the scanner itself can detect the failure cases it was
|
||||
# designed to catch. If the scanner regresses, this step fails CI.
|
||||
# Added v0.21.0; restored in v0.22.0 (was accidentally dropped in rewrite).
|
||||
run: php scripts/check-constants.php --self-test
|
||||
|
||||
299
.github/workflows/cto-review.yml
vendored
299
.github/workflows/cto-review.yml
vendored
@@ -1,20 +1,17 @@
|
||||
name: CTO Review
|
||||
|
||||
# Automated PR review powered by Claude + DeepSeek + Kimi (via OpenRouter)
|
||||
# plus Neurometric PR-Summarizer (a Qwen3-4B task-specialist SLM).
|
||||
# Automated PR review powered by Claude Haiku (via OpenRouter).
|
||||
#
|
||||
# During the A/B window (until further notice), every PR gets reviewed by FOUR models
|
||||
# in parallel. The PR comment shows each model's verdict side-by-side so we can compare
|
||||
# quality/cost on real PRs before picking a permanent default.
|
||||
# A/B panel decision 2026-06-12: panel dropped to Haiku-only.
|
||||
# Evidence: convo/cto/threads/2026-06-qa-ab-review/02-analyst-findings.md
|
||||
# DeepSeek: high false-positive rate (absint()="code exec", PLUGIN_DIR="dir traversal").
|
||||
# Kimi: ~60% model failure rate across 13 PRs sampled.
|
||||
# Neurometric: summarizer only, zero unique-real findings, unreliable heuristic verdict.
|
||||
# Haiku: 0 failures, lowest noise, equivalent real-signal catch rate vs 4-voice panel.
|
||||
#
|
||||
# Neurometric is a "prose voice": it does not support OpenAI tool-calling, so its
|
||||
# contribution is raw markdown + a heuristic verdict. It is never the primary voice.
|
||||
# Does NOT merge. Posts formal APPROVE/REQUEST_CHANGES/COMMENT reviews.
|
||||
#
|
||||
# Does NOT merge. Does NOT post formal APPROVE/REQUEST_CHANGES reviews — always COMMENT
|
||||
# (GITHUB_TOKEN can't formally approve PRs without repo-setting toggle).
|
||||
#
|
||||
# Requires repo secrets: OPENROUTER_API_KEY, NEUROMETRIC_API_KEY (optional; the
|
||||
# Neurometric voice self-skips with an inline error note if the key is missing).
|
||||
# Requires repo secret: OPENROUTER_API_KEY
|
||||
#
|
||||
# Cost cap: tracked in Repository Variables (CTO_REVIEW_SPEND_CENTS, CTO_REVIEW_CAP_CENTS).
|
||||
# Default cap is 1000 cents ($10/month). Auto-resets on the 1st of each month.
|
||||
@@ -125,8 +122,8 @@ jobs:
|
||||
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
|
||||
# Write the diff to a file, not a step output / env var. Passing a
|
||||
# ~400KB diff through the process env (execve argv+envp) exceeds the
|
||||
# runner ARG_MAX and fails the next step with "Argument list too long"
|
||||
# (issue #110). File transport also sidesteps the ~1MB step-output cap.
|
||||
# runner ARG_MAX and fails the next step with "Argument list too long".
|
||||
# File transport also sidesteps the ~1MB step-output cap.
|
||||
git diff --no-color "$BASE_SHA" "$HEAD_SHA" | head -c 400000 > /tmp/cto-review-diff.txt
|
||||
FILES=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")
|
||||
SIZE=$(wc -c < /tmp/cto-review-diff.txt)
|
||||
@@ -151,12 +148,11 @@ jobs:
|
||||
echo "EOF_CONV"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Call 4 models in parallel (OpenRouter + Neurometric)
|
||||
- name: Call Haiku via OpenRouter
|
||||
if: steps.budget.outputs.skip != 'true'
|
||||
id: review
|
||||
env:
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
NEUROMETRIC_API_KEY: ${{ secrets.NEUROMETRIC_API_KEY }}
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
@@ -184,24 +180,17 @@ jobs:
|
||||
fi
|
||||
|
||||
python3 - <<'PY'
|
||||
import json, os, sys, urllib.request, concurrent.futures
|
||||
import json, os, sys, urllib.request
|
||||
|
||||
api_key = os.environ["OPENROUTER_API_KEY"]
|
||||
|
||||
# A/B harness: four models. Each gets the same prompt.
|
||||
# Primary model's verdict drives the heading. Others are labeled.
|
||||
# Edit this list to change the roster (or drop to 1 after A/B window closes).
|
||||
#
|
||||
# provider_type semantics:
|
||||
# - "openrouter_tools": OpenAI tool-calling via OpenRouter; structured verdict.
|
||||
# - "neurometric_prose": Neurometric marketplace SLM; prose output + heuristic verdict.
|
||||
MODELS = [
|
||||
# (slug, display name, input_per_M_USD, output_per_M_USD, is_primary, provider_type)
|
||||
("anthropic/claude-haiku-4.5", "Claude Haiku 4.5", 1.00, 5.00, True, "openrouter_tools"),
|
||||
("deepseek/deepseek-chat-v3.1", "DeepSeek V3.1", 0.15, 0.75, False, "openrouter_tools"),
|
||||
("moonshotai/kimi-k2-thinking", "Kimi K2 Thinking", 0.60, 2.50, False, "openrouter_tools"),
|
||||
("neurometric/pr-summarizer", "Neurometric PR-Summarizer (Qwen3-4B)", 0.00, 0.00, False, "neurometric_prose"),
|
||||
]
|
||||
# Haiku-only reviewer. A/B panel dropped 2026-06-12 per QA data:
|
||||
# DeepSeek high false-positive rate, Kimi ~60% failure rate,
|
||||
# Neurometric zero unique-real findings. Haiku retained as sole voice.
|
||||
MODEL_SLUG = "anthropic/claude-haiku-4.5"
|
||||
MODEL_DISPLAY = "Claude Haiku 4.5"
|
||||
INPUT_PER_M_USD = 1.00
|
||||
OUTPUT_PER_M_USD = 5.00
|
||||
|
||||
system = """You are the CTO and lead reviewer for the peptiderepo project. You are reviewing a pull request opened by an autonomous agent or human contributor.
|
||||
|
||||
@@ -264,218 +253,100 @@ jobs:
|
||||
}
|
||||
}]
|
||||
|
||||
PER_MODEL_TIMEOUT = 60 # seconds; one hung model should not block siblings
|
||||
body = {
|
||||
"model": MODEL_SLUG,
|
||||
"max_tokens": 2048,
|
||||
"messages": [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user_message}
|
||||
],
|
||||
"tools": tools,
|
||||
"tool_choice": {"type": "function", "function": {"name": "submit_review"}}
|
||||
}
|
||||
req = urllib.request.Request(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
data=json.dumps(body).encode(),
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"content-type": "application/json",
|
||||
"HTTP-Referer": "https://github.com/peptiderepo",
|
||||
"X-Title": f"peptiderepo CTO reviewer ({MODEL_DISPLAY})"
|
||||
},
|
||||
method="POST"
|
||||
)
|
||||
|
||||
# Heuristic verdict inference for prose-voice models that can't do tool-calling.
|
||||
# Keyword order matters: block markers beat approve markers.
|
||||
PROSE_BLOCK_MARKERS = [
|
||||
"do not merge", "do not approve", "should not be merged", "reject this",
|
||||
"critical security", "critical issue", "critical vulnerability",
|
||||
"blocker", "blocking issue", "request_changes", "request changes",
|
||||
"**critical**", "sql injection", "must be addressed before",
|
||||
]
|
||||
PROSE_APPROVE_MARKERS = [
|
||||
"safe to merge", "ready to merge", "no blocking issues", "lgtm",
|
||||
"approve this", "looks good to merge", "no concerns",
|
||||
]
|
||||
error = None
|
||||
review = None
|
||||
cost_cents = 0.0
|
||||
ptok = 0
|
||||
ctok = 0
|
||||
|
||||
def infer_verdict_from_prose(text):
|
||||
t = (text or "").lower()
|
||||
if any(m in t for m in PROSE_BLOCK_MARKERS):
|
||||
return "REQUEST_CHANGES"
|
||||
if any(m in t for m in PROSE_APPROVE_MARKERS):
|
||||
return "APPROVE"
|
||||
return "COMMENT"
|
||||
|
||||
def call_openrouter_tools(slug, display, in_rate, out_rate, is_primary):
|
||||
body = {
|
||||
"model": slug,
|
||||
"max_tokens": 2048,
|
||||
"messages": [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user_message}
|
||||
],
|
||||
"tools": tools,
|
||||
"tool_choice": {"type": "function", "function": {"name": "submit_review"}}
|
||||
}
|
||||
req = urllib.request.Request(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
data=json.dumps(body).encode(),
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"content-type": "application/json",
|
||||
"HTTP-Referer": "https://github.com/peptiderepo",
|
||||
"X-Title": f"peptiderepo CTO reviewer ({display})"
|
||||
},
|
||||
method="POST"
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=PER_MODEL_TIMEOUT) as resp:
|
||||
data = json.loads(resp.read())
|
||||
except Exception as e:
|
||||
return {
|
||||
"slug": slug, "display": display, "is_primary": is_primary,
|
||||
"provider_type": "openrouter_tools",
|
||||
"error": str(e)[:300],
|
||||
"cost_cents": 0,
|
||||
}
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
data = json.loads(resp.read())
|
||||
except Exception as e:
|
||||
error = str(e)[:300]
|
||||
data = {}
|
||||
|
||||
if not error:
|
||||
choice = data.get("choices", [{}])[0].get("message", {})
|
||||
tool_calls = choice.get("tool_calls", [])
|
||||
if not tool_calls:
|
||||
return {
|
||||
"slug": slug, "display": display, "is_primary": is_primary,
|
||||
"provider_type": "openrouter_tools",
|
||||
"error": f"No tool_call in response. Raw content: {str(choice)[:300]}",
|
||||
"cost_cents": 0,
|
||||
}
|
||||
error = f"No tool_call in response. Raw content: {str(choice)[:300]}"
|
||||
else:
|
||||
try:
|
||||
review = json.loads(tool_calls[0]["function"]["arguments"])
|
||||
except Exception as e:
|
||||
error = f"Malformed tool args: {e}"
|
||||
|
||||
try:
|
||||
review = json.loads(tool_calls[0]["function"]["arguments"])
|
||||
except Exception as e:
|
||||
return {
|
||||
"slug": slug, "display": display, "is_primary": is_primary,
|
||||
"provider_type": "openrouter_tools",
|
||||
"error": f"Malformed tool args: {e}",
|
||||
"cost_cents": 0,
|
||||
}
|
||||
if not error:
|
||||
usage = data.get("usage", {})
|
||||
ptok = usage.get("prompt_tokens", 0)
|
||||
ctok = usage.get("completion_tokens", 0)
|
||||
cost_usd = (ptok * INPUT_PER_M_USD / 1_000_000) + (ctok * OUTPUT_PER_M_USD / 1_000_000)
|
||||
cost_cents = cost_usd * 100
|
||||
|
||||
usage = data.get("usage", {})
|
||||
ptok = usage.get("prompt_tokens", 0)
|
||||
ctok = usage.get("completion_tokens", 0)
|
||||
cost_usd = (ptok * in_rate / 1_000_000) + (ctok * out_rate / 1_000_000)
|
||||
cost_cents = cost_usd * 100
|
||||
return {
|
||||
"slug": slug, "display": display, "is_primary": is_primary,
|
||||
"provider_type": "openrouter_tools",
|
||||
"review": review, "prompt_tokens": ptok, "completion_tokens": ctok,
|
||||
"cost_cents": cost_cents,
|
||||
}
|
||||
|
||||
def call_neurometric_prose(slug, display, is_primary):
|
||||
nm_key = os.environ.get("NEUROMETRIC_API_KEY", "")
|
||||
if not nm_key:
|
||||
return {
|
||||
"slug": slug, "display": display, "is_primary": is_primary,
|
||||
"provider_type": "neurometric_prose",
|
||||
"error": "NEUROMETRIC_API_KEY secret not set on this repo",
|
||||
"cost_cents": 0,
|
||||
}
|
||||
body = {
|
||||
"model": slug,
|
||||
"max_tokens": 2048,
|
||||
"messages": [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user_message},
|
||||
],
|
||||
}
|
||||
req = urllib.request.Request(
|
||||
"https://api.neurometric.ai/v1/chat/completions",
|
||||
data=json.dumps(body).encode(),
|
||||
headers={
|
||||
"Authorization": f"Bearer {nm_key}",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=PER_MODEL_TIMEOUT) as resp:
|
||||
data = json.loads(resp.read())
|
||||
except Exception as e:
|
||||
return {
|
||||
"slug": slug, "display": display, "is_primary": is_primary,
|
||||
"provider_type": "neurometric_prose",
|
||||
"error": str(e)[:300],
|
||||
"cost_cents": 0,
|
||||
}
|
||||
prose = data.get("choices", [{}])[0].get("message", {}).get("content") or ""
|
||||
verdict = infer_verdict_from_prose(prose)
|
||||
return {
|
||||
"slug": slug, "display": display, "is_primary": is_primary,
|
||||
"provider_type": "neurometric_prose",
|
||||
"review": {"verdict": verdict, "prose": prose},
|
||||
"prompt_tokens": 0, "completion_tokens": 0,
|
||||
"cost_cents": 0,
|
||||
}
|
||||
|
||||
def call_model(slug, display, in_rate, out_rate, is_primary, provider_type="openrouter_tools"):
|
||||
if provider_type == "neurometric_prose":
|
||||
return call_neurometric_prose(slug, display, is_primary)
|
||||
return call_openrouter_tools(slug, display, in_rate, out_rate, is_primary)
|
||||
|
||||
results = []
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=len(MODELS)) as pool:
|
||||
futures = [pool.submit(call_model, *m) for m in MODELS]
|
||||
for f in concurrent.futures.as_completed(futures):
|
||||
results.append(f.result())
|
||||
|
||||
# Order: primary first, then alphabetical for stable comparison
|
||||
results.sort(key=lambda r: (not r.get("is_primary"), r["display"]))
|
||||
|
||||
# Build combined comment
|
||||
primary = next((r for r in results if r.get("is_primary") and "review" in r), None)
|
||||
heading_verdict = primary["review"]["verdict"] if primary else "MIXED"
|
||||
|
||||
lines = [f"## CTO Review — {heading_verdict}", ""]
|
||||
lines.append(f"_A/B harness active: {len(results)} models reviewing in parallel. Primary verdict drives heading; others are advisory._")
|
||||
lines.append("")
|
||||
|
||||
total_cents = 0.0
|
||||
for r in results:
|
||||
lines.append(f"---")
|
||||
primary_tag = " (primary)" if r.get("is_primary") else ""
|
||||
lines.append(f"### {r['display']}{primary_tag}")
|
||||
if "error" in r:
|
||||
lines.append("")
|
||||
lines.append(f"⚠️ Model failed: `{r['error']}`")
|
||||
lines.append("")
|
||||
continue
|
||||
rev = r["review"]
|
||||
# Build comment
|
||||
if error:
|
||||
heading_verdict = "ERROR"
|
||||
lines = [f"## CTO Review — {heading_verdict}", ""]
|
||||
lines.append(f"Model failed: `{error}`")
|
||||
lines.append("")
|
||||
if r.get("provider_type") == "neurometric_prose":
|
||||
lines.append(f"**Verdict:** {rev['verdict']} _(heuristic, inferred from prose)_")
|
||||
lines.append("")
|
||||
# Cap at 6KB to keep PR comments readable; 4B model output is rarely over this
|
||||
prose = rev.get("prose", "")
|
||||
if len(prose) > 6000:
|
||||
prose = prose[:6000] + "\n\n_…output truncated for comment length…_"
|
||||
lines.append(prose)
|
||||
lines.append("")
|
||||
lines.append("_free tier; per-call token usage not tracked_")
|
||||
lines.append("")
|
||||
continue
|
||||
lines.append(f"**Verdict:** {rev.get('verdict', 'COMMENT')}")
|
||||
lines.append(f"_reviewer: {MODEL_DISPLAY}_")
|
||||
else:
|
||||
heading_verdict = review.get("verdict", "COMMENT")
|
||||
lines = [f"## CTO Review — {heading_verdict}", ""]
|
||||
lines.append(f"**Verdict:** {heading_verdict}")
|
||||
lines.append("")
|
||||
lines.append(rev.get("summary") or "_(model returned no summary)_")
|
||||
lines.append(review.get("summary") or "_(model returned no summary)_")
|
||||
lines.append("")
|
||||
if rev.get("concerns"):
|
||||
if review.get("concerns"):
|
||||
lines.append("**Concerns:**")
|
||||
for c in rev["concerns"]:
|
||||
for c in review["concerns"]:
|
||||
sev = c.get("severity", "UNKNOWN").upper()
|
||||
f = f"`{c['file']}`" if c.get("file") else ""
|
||||
lines.append(f"- **{sev}** {f} — {c['issue']}")
|
||||
lines.append("")
|
||||
if rev.get("positives"):
|
||||
lines.append("**Positives:** " + "; ".join(rev["positives"]))
|
||||
if review.get("positives"):
|
||||
lines.append("**Positives:** " + "; ".join(review["positives"]))
|
||||
lines.append("")
|
||||
lines.append(f"_tokens in/out: {r['prompt_tokens']}/{r['completion_tokens']} · cost: ${r['cost_cents']/100:.4f}_")
|
||||
total_cents += r["cost_cents"]
|
||||
lines.append(f"_tokens in/out: {ptok}/{ctok} · cost: ${cost_cents/100:.4f}_")
|
||||
lines.append("")
|
||||
|
||||
lines.append("---")
|
||||
lines.append(f"_Total review cost: **${total_cents/100:.4f}**. A/B roster configured in `.github/workflows/cto-review.yml`._")
|
||||
lines.append(f"_Total review cost: **${cost_cents/100:.4f}**. Reviewer: {MODEL_DISPLAY}. Roster configured in `.github/workflows/cto-review.yml`._")
|
||||
|
||||
with open("/tmp/review-body.md", "w") as f:
|
||||
f.write("\n".join(lines))
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a") as f:
|
||||
f.write(f"added_cents={int(round(total_cents))}\n")
|
||||
f.write(f"added_cents={int(round(cost_cents))}\n")
|
||||
f.write(f"skipped=0\n")
|
||||
f.write(f"primary_verdict={heading_verdict}\n")
|
||||
PY
|
||||
|
||||
- name: Post PR review (formal APPROVE / REQUEST_CHANGES / COMMENT)
|
||||
# Uses primary model's verdict as the formal event. Falls back to COMMENT
|
||||
# if the primary model failed or returned something unexpected.
|
||||
# Uses Haiku's verdict as the formal event. Falls back to COMMENT
|
||||
# if the model failed or returned something unexpected.
|
||||
if: steps.budget.outputs.skip != 'true' && steps.review.outputs.skipped != '1'
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
|
||||
28
.github/workflows/deploy.yml
vendored
28
.github/workflows/deploy.yml
vendored
@@ -9,29 +9,17 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: PHP Lint Gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.1'
|
||||
coverage: none
|
||||
- name: PHP Lint Check
|
||||
run: |
|
||||
set -e
|
||||
find . -name '*.php' \
|
||||
-not -path './vendor/*' \
|
||||
-not -path './node_modules/*' \
|
||||
-not -path './.git/*' \
|
||||
-not -path './.github/*' \
|
||||
-print0 | xargs -0 -n1 -P4 php -l > /dev/null
|
||||
ci:
|
||||
name: CI Gate (reusable)
|
||||
uses: ./.github/workflows/ci.yml
|
||||
permissions:
|
||||
contents: write
|
||||
secrets: inherit
|
||||
|
||||
deploy:
|
||||
name: Deploy (shared pipeline)
|
||||
needs: validate
|
||||
uses: peptiderepo/peptide-e2e/.github/workflows/deploy-app.yml@main
|
||||
needs: ci
|
||||
uses: peptide-repo/peptide-e2e/.github/workflows/deploy-app.yml@main
|
||||
with:
|
||||
target_path: wp-content/plugins/prautoblogger
|
||||
secrets: inherit
|
||||
|
||||
291
ARCHITECTURE.md
291
ARCHITECTURE.md
@@ -119,14 +119,21 @@ prautoblogger/
|
||||
│ │ └── OFL.txt # SIL Open Font License
|
||||
│ ├── css/
|
||||
│ │ ├── admin.css # Admin page styles (wp-admin conventions)
|
||||
│ │ ├── board.css # Kanban board styles -- warm editorial palette (v0.19.0)
|
||||
│ │ ├── dossier.css # Article dossier page styles: verdict pills, cost receipt, trace toggle (v0.19.2)
|
||||
│ │ ├── dossier-edit.css # M3 edit/re-run layer: chips, edit panel, image grid, spend warning (v0.20.0)
|
||||
│ │ └── posts-widget.css # Frontend posts widget styles (uses theme CSS vars)
|
||||
│ └── js/
|
||||
│ ├── admin.js # Admin page interactivity (vanilla JS / Alpine.js)
|
||||
│ ├── board.js # Board poller: AJAX poll + DOM updates + backoff (v0.19.0)
|
||||
│ ├── dossier.js # Per-stage raw-trace toggle: aria-expanded / aria-controls (v0.19.2)
|
||||
│ ├── dossier-edit.js # M3 edit/re-run actions + stage-status polling (queued -> pickup -> result) (v0.20.0)
|
||||
│ └── posts-widget.js # React component for frontend post cards (wp.element)
|
||||
│
|
||||
├── includes/
|
||||
│ ├── class-prautoblogger.php # Main orchestrator — registers all hooks, delegates execution
|
||||
│ ├── class-pipeline-schema-installer.php # v0.18.0 substrate tables (prompts, run_sources, run_decisions, runs, run_stages)
|
||||
│ ├── class-prautoblogger.php # Main orchestrator — registers all hooks, delegates execution (split v0.19.2)
|
||||
│ ├── class-db-migrations.php # DB migration methods extracted from class-prautoblogger.php (v0.19.2 split, binding 1)
|
||||
│ ├── class-pipeline-schema-installer.php # Pipeline substrate tables (prompts, run_sources, run_decisions, runs, run_stages, stage_inputs)
|
||||
│ ├── class-migrate-prompt-seed-v0180.php # One-shot v0.18.0 prompt-registry seed migration
|
||||
│ ├── class-executor.php # Cron handlers, generation AJAX (delegates start + status to poller), model registry
|
||||
│ ├── class-generation-status-poller.php # Generation AJAX handlers: status transient renewal, lock-age orphan detection (R2b/R3), abort_orphaned_run
|
||||
@@ -137,6 +144,15 @@ prautoblogger/
|
||||
│ ├── class-autoloader.php # PSR-4-style autoloader for plugin classes
|
||||
│ │
|
||||
│ ├── admin/
|
||||
│ │ ├── class-board-page.php # Kanban board -- primary landing screen (v0.19.0)
|
||||
│ │ ├── class-board-data-provider.php # Board orchestrator: 4-column snapshot + dossier deep-links (v0.19.0, v0.19.2)
|
||||
│ │ ├── class-board-gen-log-query.php # Board gen_log queries: Generating + Failed column raw DB reads (v0.19.0)
|
||||
│ │ ├── class-dossier-page.php # Article dossier admin page: options.php-parent hidden-page, priority 12, enqueue, nonce (v0.19.3)
|
||||
│ │ ├── class-dossier-data-assembler.php # 5-query view-model builder: runs+stages+gen_log+decisions+meta (v0.19.2)
|
||||
│ │ ├── class-dossier-gen-log-query.php # Dossier gen_log queries: per-stage cost receipt + raw trace (v0.19.2)
|
||||
│ │ ├── class-dossier-actions.php # M3 AJAX: save fork / queue replay / queue rebuild / stage-status poll (v0.20.0)
|
||||
│ │ ├── class-dossier-rerun-panel-data.php # M3 per-stage affordance + spend view model (v0.20.0)
|
||||
│ │ ├── class-dossier-image-data.php # F3: post's pipeline attachments via _prautoblogger_image_role (v0.20.0)
|
||||
│ │ ├── class-admin-page.php # Main settings page (tabbed SaaS-style UI)
|
||||
│ │ ├── class-settings-fields.php # Declarative settings: sections + core fields (API/Models/Content/Sources)
|
||||
│ │ ├── class-settings-fields-extended.php # Operational fields: schedule, publishing, display, analytics, images
|
||||
@@ -183,7 +199,15 @@ prautoblogger/
|
||||
│ │ ├── class-run-state.php # runs-table ledger + lifecycle row (ceiling/reserved/settled, pins, status)
|
||||
│ │ ├── class-cost-governor.php # Per-run reserve-before-call enforcement (atomic conditional UPDATE)
|
||||
│ │ ├── class-cost-ceiling-exception.php # Thrown on ceiling breach (run already halted)
|
||||
│ │ ├── class-run-stage-state.php # Per-run per-stage state machine (idempotent resume, output snapshots)
|
||||
│ │ ├── class-run-stage-state.php # Per-run per-stage state machine: read API + thin write proxies (split v0.20.0)
|
||||
│ │ ├── class-run-stage-writes.php # Pipeline-path writes (start/done/fail; done clears stale) — extracted v0.20.0
|
||||
│ │ ├── class-run-stage-rerun-state.php # M3 operator-action mutations: restart/mark_stale/demote (v0.20.0)
|
||||
│ │ ├── class-stage-input-store.php # INSERT-only stage_inputs store: edit forks + idea seeds (v0.20.0)
|
||||
│ │ ├── class-rerun-eligibility.php # M3 policy gates: frozen posts, editable stages, chain order (v0.20.0)
|
||||
│ │ ├── class-stage-replay.php # M3 governed single-stage replay from a fork body (v0.20.0)
|
||||
│ │ ├── class-rerun-executor.php # M3 cron handlers: replay + rebuild jobs (v0.20.0)
|
||||
│ │ ├── class-rerun-job-support.php # M3 queue/lock/budget/status-transient plumbing (v0.20.0)
|
||||
│ │ ├── class-request-recorder.php # B1: process-scoped outbound request-body stash -> generation_log.request_json (v0.20.0)
|
||||
│ │ ├── class-run-reaper.php # Stuck-run sweep + audit-payload retention (rides the #19 cron)
|
||||
│ │ ├── class-audit-writer.php # run_sources / run_decisions insert layer
|
||||
│ │ ├── class-pipeline-status.php # Status-transient + summary helpers (extracted from runner/worker)
|
||||
@@ -239,11 +263,19 @@ prautoblogger/
|
||||
│
|
||||
├── templates/
|
||||
│ └── admin/
|
||||
│ ├── board-page.php # Kanban board template (4-column editorial layout) (v0.19.0)
|
||||
│ ├── dossier-page.php # Article dossier: two-column layout, sidebar, stage sections (v0.19.2)
|
||||
│ ├── dossier-stage-section.php # Stage section partial: output, raw trace, M3 chips + rerun footer
|
||||
│ ├── dossier-edit-panel.php # M3 per-stage edit panel: message textareas, fork info, spend strip (v0.20.0)
|
||||
│ ├── dossier-log-stage-section.php # F3 log-only stage sections incl. image attachment grid (v0.20.0)
|
||||
│ ├── dossier-sidebar-cards.php # M3 sidebar: run spend (guardrail 4) + models/pv consolidation (F2) (v0.20.0)
|
||||
│ ├── dossier-stage-section.php # Per-stage block: rendered output + raw-trace toggle + cost receipt (v0.19.2)
|
||||
│ ├── metabox-dossier-link.php # Slim post metabox: "View generation dossier →" link (v0.19.2)
|
||||
│ ├── settings-page.php # Settings page template (tabbed sidebar layout)
|
||||
│ ├── metrics-page.php # Metrics/cost dashboard template
|
||||
│ ├── review-queue.php # Review queue table template
|
||||
│ ├── log-viewer.php # Activity log viewer template
|
||||
│ └── metabox-generation-info.php# Post metabox template
|
||||
│ └── metabox-generation-info.php# Post metabox template (superseded by metabox-dossier-link.php in v0.19.2)
|
||||
│
|
||||
├── languages/
|
||||
│ └── prautoblogger.pot # i18n translation template
|
||||
@@ -517,6 +549,7 @@ dropped on uninstall.
|
||||
| verdict | VARCHAR(50) | e.g. 'approved', 'revised', 'rejected', 'halted' |
|
||||
| rationale | TEXT | Decision rationale (nullable) |
|
||||
| citation_score | FLOAT | Nullable until the Phase-2 editorial loop computes it |
|
||||
| human_modified | TINYINT(1) | v0.20.0 — decision derives from a human-edited input (set on replay of the stage, or on decisions recorded while rebuilding a human-modified item) |
|
||||
| created_at | DATETIME | Row creation time |
|
||||
|
||||
- INDEX: `run_id`
|
||||
@@ -553,12 +586,38 @@ dropped on uninstall.
|
||||
| attempt | SMALLINT UNSIGNED | Re-entry counter |
|
||||
| cost_usd | DECIMAL(10,6) | Cost attributed to the stage |
|
||||
| meta_json | LONGTEXT | Stage output snapshot — lets resume reuse a done stage instead of re-charging it (payload pruned on the retention cron) |
|
||||
| human_modified | TINYINT(1) | v0.20.0 — set (sticky, never cleared) when an edited input fork enters execution for this row (CPO guardrail 2) |
|
||||
| stale | TINYINT(1) | v0.20.0 — upstream changed after this row completed; row STAYS 'done' so resume never silently re-runs it; cleared only by a fresh done() |
|
||||
| started_at | DATETIME | First entry |
|
||||
| updated_at | DATETIME | Last transition |
|
||||
| finished_at | DATETIME | Set on done/failed (nullable) |
|
||||
|
||||
- UNIQUE: `run_id, stage, agent_role, item_key` (the idempotency key) · INDEX: `status, updated_at`
|
||||
|
||||
##### `prautoblogger_stage_inputs` — immutable input-version store (v0.20.0, db 1.3.0)
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------------|-----------------|--------------------------------------------------|
|
||||
| id | BIGINT UNSIGNED | Auto-increment PK |
|
||||
| run_id | VARCHAR(36) | Pipeline run UUID |
|
||||
| stage | VARCHAR(50) | Stage name ('' for idea seeds) |
|
||||
| agent_role | VARCHAR(50) | Stage row agent role |
|
||||
| item_key | VARCHAR(64) | Article scope |
|
||||
| version | INT UNSIGNED | Monotonic per scope; **rows are INSERT-only** — edits create the next version, originals are never overwritten |
|
||||
| source | VARCHAR(20) | 'human' (edit fork) or 'seed' (Article_Idea payload persisted at worker start) |
|
||||
| request_json | LONGTEXT | Fork body / idea JSON. Human fork bodies pruned by the retention cron after R days; seed rows (~1KB) kept |
|
||||
| author | VARCHAR(100) | WP user login, or 'pipeline' for seeds |
|
||||
| created_at | DATETIME | Row creation time |
|
||||
|
||||
- UNIQUE: `run_id, stage, agent_role, item_key, version` · INDEX: `run_id`
|
||||
- The ORIGINAL executed input of a stage is `generation_log.request_json` (B1) — it is
|
||||
never copied or modified here; forks are the edit history (CPO guardrail 1).
|
||||
|
||||
**db version note:** `PRAUTOBLOGGER_DB_VERSION` moved 1.1.0 → 1.3.0 in v0.20.0. The
|
||||
constant never actually carried the "db 1.2.0" this document used as shorthand for the
|
||||
v0.18.0 substrate; 1.3.0 leapfrogs the phantom value so the version-compare self-healing
|
||||
migration fires exactly once everywhere.
|
||||
|
||||
### WordPress Options (`wp_options`)
|
||||
|
||||
All prefixed with `prautoblogger_`:
|
||||
@@ -608,6 +667,8 @@ All prefixed with `prautoblogger_`:
|
||||
| `prautoblogger_image_compose_variants` | Comma list of composed variants for Image A (default 'og,square'; whitelisted at point of use) |
|
||||
| `prautoblogger_image_featured_mark_enabled` | Toggle: subtle corner mark on featured images (default '1') |
|
||||
| `prautoblogger_image_compose_capability` | Cached capability probe `{fingerprint, capability}`; fingerprint = PHP version + imagick/gd presence, so it auto-invalidates on host changes |
|
||||
| `prautoblogger_board_poll_interval` | Kanban board poll interval in seconds (default 5, min 3). Localized into board.js. |
|
||||
| `prautoblogger_board_published_window_days` | Days back to show in the Published column (default 7). |
|
||||
|
||||
### Post Meta
|
||||
|
||||
@@ -851,6 +912,166 @@ multi-step publish paths. Four subsystems:
|
||||
Tests use Brain\Monkey for WordPress function mocking (no database required). The BaseTestCase singleton handles setup/teardown of Monkey stubs and provides fixture helpers for common data shapes (SourceData, ArticleIdea, GenerationLog, etc.). All WordPress functions used by the plugin are stubbed in BaseTestCase::setUp() so individual test classes only override what they need. In v0.10.1, missing stubs for `post_type_exists()` were added to support PeptideLinker's PR-Core guards. All 1,362 WordPress-Core coding-standards violations (short array syntax, line length, spacing) were auto-fixed via phpcbf and the CI gate was flipped to strict mode — PHPCS failures now block CI. This prevents future regressions on the code style front.
|
||||
|
||||
|
||||
|
||||
### #22b: Kanban board as primary admin landing screen (v0.19.0 / v0.19.1)
|
||||
|
||||
**Hidden admin page convention (v0.19.3):** See CONVENTIONS.md §Hidden Admin Pages for the full rule. Summary: hidden admin pages (link-accessed only, not visible in nav) MUST use `options.php` as the `add_submenu_page()` parent. NEVER unset/remove registered pages from `$submenu` after registration -- doing so causes `get_admin_page_parent()` to fail at request time, which makes WP recompute the hookname in the `admin_page_*` orphan namespace, find no registered handler, and call `wp_die(403)`. This is the dossier 403 incident (v0.19.2→v0.19.3). Same hookname-mismatch class as the board 404 (v0.19.0→v0.19.1); the only difference is that the board bug was a priority-ordering issue at registration time while the dossier bug was a post-registration $submenu mutation.
|
||||
|
||||
The primary admin screen is now a kanban board (Direction C "Editorial Record") with four
|
||||
columns: Generating | In Review | Published | Failed. A separate `class-board-page.php`
|
||||
registers the `Board` submenu and a `$submenu` reorder makes it the first (primary) entry.
|
||||
`class-board-data-provider.php` orchestrates the four columns and handles WP_Query columns
|
||||
(In Review, Published). Raw `prab_generation_log` queries (Generating, Failed) are in the
|
||||
extracted `class-board-gen-log-query.php` (split for 300-line compliance) -- no new schema
|
||||
required for M1. The data provider uses the `prautoblogger_generation_status` transient
|
||||
(written by the Executor for manual/daily runs) as the primary active-run signal, with a
|
||||
secondary query on recent `prab_generation_log` run_ids that have no linked post as a
|
||||
fallback. M2 will rename Runs & Audit -> Articles and wire board cards to the Article Dossier.
|
||||
Poll interval and published window are settings-backed, localized into board.js, never
|
||||
hardcoded.
|
||||
|
||||
**Menu-ordering constraint (v0.19.1 hotfix):** `PRAutoBlogger_Board_Page::on_register_menu`
|
||||
MUST be hooked at `admin_menu` priority 11 (or higher), AFTER
|
||||
`PRAutoBlogger_Admin_Page::on_register_menu` which runs at priority 10. Reason: WordPress
|
||||
computes the page hookname via `get_plugin_page_hookname()` at `add_submenu_page()` call
|
||||
time. If `add_menu_page()` has not yet run, `$admin_page_hooks['prautoblogger-settings']`
|
||||
is unset and WordPress falls back to the `admin_page_*` hookname. At HTTP request time WP
|
||||
recomputes the hookname as `prautoblogger_page_prautoblogger-board` and finds no registered
|
||||
callback -> `wp_die("Invalid plugin page")` 404. The enqueue-gate in `on_enqueue_assets()`
|
||||
checks `prautoblogger_page_prautoblogger-board` and would similarly never fire with the
|
||||
wrong hookname (board.css/board.js would silently not load). Regression test:
|
||||
`tests/unit/Admin/BoardMenuRegistrationTest.php`.
|
||||
|
||||
|
||||
### #23: Article Dossier page — per-article inspection (v0.19.2)
|
||||
|
||||
The dossier is a read-only per-article admin page that exposes the full substrate view for
|
||||
a single post: generation run, stage I/O, cost receipts, raw-trace toggles, editorial
|
||||
decisions, and image A/B pairs. It is link-accessed only (hidden submenu) — no navigation
|
||||
entry appears in the WP sidebar.
|
||||
|
||||
**Page registration** (`class-dossier-page.php`): uses the canonical hidden-admin-page
|
||||
pattern -- registered under parent **`options.php`** at `admin_menu` priority **12**. The
|
||||
hookname is `admin_page_prautoblogger-dossier` at BOTH registration time and request time,
|
||||
so there is no hookname mismatch and no need to manipulate `$submenu`. The page does not
|
||||
appear in the WP admin sidebar (options.php-parent pages are hidden by construction).
|
||||
URL shape: `admin.php?page=prautoblogger-dossier&post_id=<int>` (unchanged from v0.19.2 --
|
||||
deep-link URLs are parent-agnostic). Secured by `manage_options` capability. Static
|
||||
`url_for_post(int $post_id): string` provides the canonical deep-link.
|
||||
|
||||
v0.19.2 used `prautoblogger-settings` as parent with post-registration `unset($submenu[...])` to
|
||||
hide the page. This caused `get_admin_page_parent()` to fail at request time -> orphan
|
||||
`admin_page_*` hookname -> no handler registered -> `wp_die(403)`. Fixed in v0.19.3 by switching
|
||||
to `options.php` parent and removing all `$submenu` mutation. See CONVENTIONS.md §Hidden Admin
|
||||
Pages for the full rule and ARCHITECTURE.md §22b for both incident records.
|
||||
|
||||
Regression test: `tests/unit/Admin/DossierMenuRegistrationTest.php` -- the
|
||||
`test_request_time_hookname_matches_registration_hookname` test replicates the request-time
|
||||
resolution algorithm (simulate `get_admin_page_parent()`) so it FAILS on hide-by-unset and
|
||||
PASSES on options.php-parent.
|
||||
|
||||
**5-query data assembly** (`class-dossier-data-assembler.php`):
|
||||
|
||||
```
|
||||
1. wp_prautoblogger_runs WHERE run_id = %s -- 1 row (run metadata)
|
||||
2. wp_prautoblogger_run_stages WHERE run_id = %s -- N rows (per-stage status/cost)
|
||||
3. wp_prautoblogger_generation_log WHERE run_id = %s -- N rows (model/pv/tokens/request)
|
||||
4. wp_prautoblogger_run_decisions WHERE run_id = %s -- N rows (editorial rationale)
|
||||
5. get_post_meta($post_id) -- WP object cache hit
|
||||
```
|
||||
|
||||
All queries are keyed on `run_id` (indexed). No N+1. Stage output is reconstructed from
|
||||
`run_stages.meta_json → json_decode → meta['output']` (same path as `Run_Stage_State::
|
||||
get_output()`). The dossier assembler does NOT call `Run_Stage_State` directly; it performs
|
||||
its own raw queries so the view is not coupled to the cache layer.
|
||||
|
||||
**Legacy graceful state (binding 5):** Posts published before v0.18.0 (no runs/run_stages
|
||||
rows) and posts where `_prautoblogger_run_id` is absent or blank return `['has_run' => false]`.
|
||||
The template renders a clean "No generation record for this article" notice. No PHP notices
|
||||
or fatals.
|
||||
|
||||
**Amortized research rows:** `generation_log` rows with `prompt_version = NULL` and
|
||||
`agent_role = ''` (from the `llm_research` stage orphan-reaper path) are rendered with
|
||||
model shown as "—" and no raw-trace toggle (request_json may be NULL). No fatal/notice.
|
||||
|
||||
**Raw-trace security (binding 4):** `request_json` stores the OpenRouter request body —
|
||||
model identifier, messages array, temperature. Authorization headers are never logged (the
|
||||
outbound HTTP client strips them before logging, per the original architecture decision on
|
||||
`wp_prautoblogger_generation_log`). The dossier template renders `request_json` via
|
||||
`esc_html()` inside a `<pre>` block. All model-generated text rendered via `wp_kses_post()`
|
||||
(rendered view) or `esc_html()` (raw trace). Both paths treat content as untrusted HTML.
|
||||
|
||||
**File naming note:** `PRAutoBlogger_DB_Migrations` is loaded via explicit `require_once`
|
||||
in `prautoblogger.php` (not the autoloader). The autoloader converts `DB_Migrations` to the
|
||||
kebab segment `d-b-migrations`, producing `class-d-b-migrations.php` — which does not match
|
||||
the file `class-db-migrations.php`. Explicit loading is the canonical pattern for classes
|
||||
whose names do not round-trip cleanly through the kebab converter. `class-prautoblogger.php`
|
||||
uses the same pattern (also explicitly required in `prautoblogger.php`).
|
||||
|
||||
### #24: Edit + single-step re-run under CPO guardrails (v0.20.0, db 1.3.0, June 2026)
|
||||
|
||||
Phase 2 admin M3 (design contract: cpo seq 8 Proposal C + the five guardrails of the
|
||||
edit+rerun delta ruling; decision `2026-06-12-admin-redesign-direction-c.md`).
|
||||
|
||||
**B1 — request_json persistence.** The OpenRouter provider stashes every outgoing chat
|
||||
request BODY in the process-scoped `PRAutoBlogger_Request_Recorder` (the Run_Context
|
||||
pattern) right after `build_body()`; `Cost_Tracker::log_api_call()` consumes it into
|
||||
`generation_log.request_json`. Consume-once + overwrite-on-record means a stale body can
|
||||
never attach to an unrelated row; recording happens pre-dispatch so error rows carry the
|
||||
request too. Authorization can never be recorded: headers are built separately
|
||||
(`build_headers()`) and `build_body()` copies only whitelisted option keys. Retention
|
||||
rides the existing `prautoblogger_request_json_retention_days` prune unchanged.
|
||||
|
||||
**Two re-run primitives, both chained-cron (never synchronous):** the dossier AJAX layer
|
||||
(`Dossier_Actions`) only validates + queues; the cron handlers (`Rerun_Executor`)
|
||||
re-validate eligibility UNDER the generation lock before mutating anything.
|
||||
|
||||
1. **Replay** — one governed chat call from the latest edited input fork
|
||||
(`stage_inputs`, INSERT-only versions). `Stage_Replay::options_from_body()` is the
|
||||
exact inverse of `build_body()`+`apply_reasoning_budget()` (reasoning headroom
|
||||
subtracted back out; reasoning pinned explicitly so global-setting drift cannot
|
||||
reshape a replay). The call goes through `send_chat_completion`, so the cost
|
||||
governor reserves against the SAME run ledger row (guardrail 4), with the normal
|
||||
retry/empty-completion machinery. Output lands in the stage's run_stages snapshot —
|
||||
the same place downstream rebuilds read from.
|
||||
2. **Rebuild ("re-run from here")** — demotes the target + downstream chain stages to
|
||||
`pending` and re-enters `Article_Worker` with the idea reconstructed from the stored
|
||||
seed (`stage_inputs`, source='seed', persisted at worker start because re-deriving
|
||||
the idea from post fields could break the item_key hash and duplicate a post).
|
||||
Upstream done stages are reused (never re-charged); demoted stages rebuild their
|
||||
prompts from CURRENT upstream snapshots — this is how an edit propagates downstream.
|
||||
|
||||
**Stale is a column, not a status.** A stale stage stays `done`, so
|
||||
resume-without-recharge logic can never silently re-run it — guardrail 3 (no silent
|
||||
auto-rerun; each downstream re-run is a deliberate operator action) holds by
|
||||
construction. Only `Run_Stage_Writes::done()` clears `stale`; only
|
||||
`Run_Stage_Rerun_State` (operator-action paths) may demote a done row. `human_modified`
|
||||
is sticky for the life of the run (set when a fork enters execution, surfaced as header/
|
||||
stage chips in the dossier, a board-card chip, and a Review Queue link-chip — the
|
||||
run-list-level visibility the product AC requires).
|
||||
|
||||
**Run reopening.** `Run_State::reopen()` is the atomic terminal→running gate for re-run
|
||||
spend; it RE-SNAPSHOTS `ceiling_usd` from the current setting (a deliberate re-run
|
||||
adopts the operator's current per-run policy, exactly like a new run) and never resets
|
||||
reserved/settled — re-runs are new spend on the same run. Handlers always restore a
|
||||
terminal status (or record failed/halted) so the run-reaper's stuck-running sweep stays
|
||||
meaningful; jobs hold the global generation lock and ride the M1 status transient, so
|
||||
board/dossier polling shows queued → pickup → result and the existing R2/R3 orphan
|
||||
recovery covers dead re-run processes unchanged.
|
||||
|
||||
**Eligibility (guardrail 5).** Posts with status publish/future/private freeze their
|
||||
run server-side (and `Publisher` refuses to touch them: re-run output refreshes only
|
||||
UNPUBLISHED posts, preserving their status — re-runs never publish). Editable stages
|
||||
are the item-scoped writer chat stages (outline/draft/polish); review/publish re-run
|
||||
via rebuild (their inputs are derived), run-level and image stages are excluded with
|
||||
visible in-UI reasons. Phase-2b stages inherit the mechanism when they exist as
|
||||
item-scoped chat calls.
|
||||
|
||||
**F2/F3 (QA M2):** the sidebar consolidates per-stage model/pv/role; image (and other
|
||||
log-only) stages render from generation_log + the `_prautoblogger_image_role`
|
||||
attachment set — wiring the dossier to real data was deliberately chosen over teaching
|
||||
the live image pipeline to write run_stages (that belongs to the Phase-2b restructure).
|
||||
|
||||
## Cross-System LLM Budget Coordination
|
||||
|
||||
PRAutoBlogger and Peptide News both call OpenRouter and may share a single API key / billing account. Their combined spend should be considered when setting per-plugin budgets.
|
||||
@@ -863,3 +1084,65 @@ PRAutoBlogger and Peptide News both call OpenRouter and may share a single API k
|
||||
**Important:** If your OpenRouter account has a global spending limit, set each plugin's budget to less than half the total. PRAutoBlogger will hard-stop when its budget is exhausted, but Peptide News currently has no budget enforcement — a spike in news fetches could consume shared quota.
|
||||
|
||||
**Future improvement:** A shared `wp_options` key (e.g., `ecosystem_monthly_llm_budget`) that both plugins read, with each plugin reserving its allocation on startup. This requires coordination at the ecosystem level and is tracked as a medium-term goal.
|
||||
|
||||
## §25 Chained-Cron Checkpoint Generation Path (v0.21.0, M4)
|
||||
|
||||
### Problem
|
||||
|
||||
Hostinger shared hosting kills PHP background processes after ~120 s. The former
|
||||
`on_manual_generation()` path in `Executor` ran the complete orchestrate +
|
||||
generate pipeline in a single PHP process — meaning a host kill mid-run left the
|
||||
generation lock held, the run row stuck in `running`, and partially-generated
|
||||
articles orphaned with no status broadcast.
|
||||
|
||||
### Solution: Two-Tick Checkpoint Machine
|
||||
|
||||
`PRAutoBlogger_Generation_Checkpoint_Runner` (v0.21.0) splits the pipeline into
|
||||
independent cron ticks, each of which completes well within Hostinger's limit:
|
||||
|
||||
| Tick | WP-Cron action | What happens |
|
||||
|------|---------------|-------------|
|
||||
| 1 | `prautoblogger_gen_orchestrate` | Collect sources → analyze → score ideas. Serialize ideas to `prautoblogger_article_queue` option. Schedule Tick 2. |
|
||||
| 2..N | `prautoblogger_gen_tick` | Pop ONE idea from queue. Call `Article_Worker::generate()`. Persist updated queue. Reschedule next Tick, or finalize. |
|
||||
|
||||
If the host kills any tick:
|
||||
- The idea queue is persisted (option survived the crash).
|
||||
- The next CRON run picks up naturally (no orphan).
|
||||
- `Run_Reaper` reclaims truly stuck runs via its daily sweep.
|
||||
|
||||
### Entry Points
|
||||
|
||||
| Caller | Path |
|
||||
|--------|------|
|
||||
| Board "New Article" button | AJAX `prautoblogger_generate_now` → `Generation_Status_Poller::on_ajax_generate_now()` → schedules `prautoblogger_manual_generation` → `Executor::on_manual_generation()` → `kick_off()` |
|
||||
| WP-CLI | `wp prautoblogger generate` → `WP_CLI_Commands::generate_command()` → `kick_off()` |
|
||||
| Old cron hook (retained) | `prautoblogger_manual_generation` → `Executor::on_manual_generation()` → `kick_off()` |
|
||||
|
||||
### SSH Workaround Retirement
|
||||
|
||||
The former `wp eval 'do_action("prautoblogger_manual_generation")'` workaround
|
||||
fired `on_manual_generation()` synchronously in the CLI PHP process, bypassing:
|
||||
- Nonce/capability checks (running as CLI user, not WP user)
|
||||
- Cron-based loopback spawning
|
||||
- Per-process time limits
|
||||
|
||||
This path is retired. `Executor::on_manual_generation()` now delegates immediately
|
||||
to `Generation_Checkpoint_Runner::on_orchestrate_tick()` (the Tick 1 handler),
|
||||
which acquires the lock and runs orchestration within its own process. The
|
||||
`wp prautoblogger generate` WP-CLI command provides the supported CLI path.
|
||||
|
||||
### Key State
|
||||
|
||||
| Store | Key | Contents |
|
||||
|-------|-----|---------|
|
||||
| DB option | `prautoblogger_article_queue` | `{run_id, ideas[], results{}}` — persisted across ticks |
|
||||
| DB option | `prautoblogger_checkpoint_run_id` | Current run UUID (for finalize/cleanup paths) |
|
||||
| Transient | `prautoblogger_generation_status` | Status broadcast (polling by board/dossier JS) |
|
||||
| DB | `prautoblogger_runs` | Per-run ledger with status, cost ceiling |
|
||||
|
||||
### Budget / Halt Contract
|
||||
|
||||
`Cost_Tracker::is_budget_exceeded()` is checked at the START of Tick 1 and each
|
||||
generate tick. A halted run (`Run_State::get_status() = 'halted'`) causes the
|
||||
generate tick to abort without calling `Article_Worker`, clean up the queue, and
|
||||
release the lock — same guarantee as `Pipeline_Runner`'s queued-article path.
|
||||
|
||||
552
CHANGELOG.md
552
CHANGELOG.md
@@ -5,247 +5,346 @@ 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.18.3] - 2026-06-12
|
||||
## [Unreleased - Internal]
|
||||
|
||||
GA4-context SQL fix, manual-run resilience, and honest status on transient expiry
|
||||
(CTO thread `2026-06-pipeline-v2-phase1` seq 38).
|
||||
### Internal
|
||||
- CI constants guard (`scripts/check-constants.php`): new required CI step (`Check PRAUTOBLOGGER_* constants`) fails the build when any `PRAUTOBLOGGER_*` constant referenced in shipped plugin PHP is neither defined in `prautoblogger.php` nor `defined()`-guarded at the reference site. Prevents a recurrence of the v0.19.0 admin-500 regression class.
|
||||
|
||||
### Fixed
|
||||
- **Analysis-prompts SQL / GA4 self-improvement loop (R1 — P2)**: `phpcs:ignore` annotation
|
||||
was embedded inside the SQL string literal in `class-analysis-prompts.php:81` (same pattern
|
||||
as the v0.18.2 cost-reporter fix). MariaDB rejected the query on every pipeline run,
|
||||
causing `get_performance_context()` to always return `''` — the LLM research/analysis
|
||||
stage has had no historical performance context since v0.16.0. Annotation moved to the
|
||||
`// phpcs:ignore` pragma above `get_results()`. Sibling occurrence also fixed in
|
||||
`class-post-assembler.php:112` (same pattern). Regression + behavioral tests added to
|
||||
`AnalysisPromptsTest`.
|
||||
- **Manual-run resilience — transient renewal (R2a)**: `update_generation_stage()` in the
|
||||
new `PRAutoBlogger_Generation_Status_Poller` now renews the STATUS_TTL on every stage
|
||||
update, ensuring the status transient outlasts any single LLM call even when `Pipeline_
|
||||
Status::broadcast()` is delayed by a long model call.
|
||||
- **Manual-run resilience — stuck-run detection (R2b)**: `on_ajax_generation_status()`
|
||||
now detects when the status transient is absent but the generation lock has been held
|
||||
longer than STATUS_TTL seconds — indicative of a Hostinger background-process kill.
|
||||
Marks the stuck run `failed` in the audit ledger, releases the lock, and returns
|
||||
`status: error` with an "infrastructure timeout" message instead of silently resetting
|
||||
to idle.
|
||||
- **Honest status when transient gone but lock held (R3)**: when the status transient is
|
||||
absent but the lock is held within TTL, `on_ajax_generation_status()` now returns
|
||||
`status: running` with `started_at` from the lock timestamp (rather than `status: idle`)
|
||||
so the Generate Now button reflects reality during long background runs.
|
||||
## [0.22.0] - 2026-06-16
|
||||
|
||||
### Changed
|
||||
- **Executor split (300-line rule)**: `class-executor.php` was at 351 lines. AJAX generation
|
||||
handlers (`on_ajax_generate_now`, `on_ajax_generation_status`, `abort_orphaned_run`,
|
||||
`update_generation_stage`) extracted to new `class-generation-status-poller.php` (260 lines).
|
||||
Executor retains cron handlers and delegates AJAX calls to the poller. Public API unchanged.
|
||||
- **Generation_Lock**: new `get_acquired_at()` method returns the Unix timestamp when the
|
||||
lock was set, used by R2b/R3 to determine lock age without touching the pipeline run table.
|
||||
- **CI adopted estate standard (reusable workflow)**: `ci.yml` is now a thin
|
||||
caller of `peptiderepo/peptide-e2e/.github/workflows/ci.yml@main` with
|
||||
`tests: brain-monkey`. Removes duplicate per-repo lint, PHPCS, and PHPUnit
|
||||
jobs (now provided by the reusable workflow). The phpcbf auto-fix commit-back
|
||||
pattern (prautoblogger gold standard) is now the reusable implementation.
|
||||
- **Deploy gate upgraded**: `deploy.yml` no longer runs a standalone PHP-lint
|
||||
validate job; it calls `./.github/workflows/ci.yml` (which includes the full
|
||||
reusable suite) via `workflow_call`. This closes the gap where deploy's lint
|
||||
gate was weaker than CI's PHPCS gate.
|
||||
- **Constants guard retained**: `check-constants` job kept as a second parallel
|
||||
job in `ci.yml` alongside the reusable `ci` job. No regression from the
|
||||
v0.19.0 protection.
|
||||
- **`composer.json` test script added**: `composer test` now runs
|
||||
`vendor/bin/phpunit --testdox` (required by the reusable workflow's phpunit job).
|
||||
|
||||
### Refactored (300-line rule compliance)
|
||||
- `PRAutoBlogger_Keyword_Extractor` (new, `core/class-keyword-extractor.php`):
|
||||
shared keyword tokeniser extracted from `Idea_Scorer` and `Semantic_Dedup`;
|
||||
both now delegate to `PRAutoBlogger_Keyword_Extractor::extract()`.
|
||||
- `PRAutoBlogger_Image_Aspect_Ratio` (new, `providers/class-image-aspect-ratio.php`):
|
||||
aspect-ratio snap utility extracted from `OpenRouter_Image_Provider` and
|
||||
`OpenRouter_Image_Batch`; both now call `PRAutoBlogger_Image_Aspect_Ratio::snap()`.
|
||||
- `PRAutoBlogger_Settings_Sanitizer` (new, `admin/class-settings-sanitizer.php`):
|
||||
all option sanitization logic extracted from `Admin_Page::sanitize_field()`;
|
||||
`Admin_Page` delegates via `PRAutoBlogger_Settings_Sanitizer::sanitize_field()`.
|
||||
- `PRAutoBlogger_Settings_Fields_Images` (new, `admin/class-settings-fields-images.php`):
|
||||
image-section field definitions extracted from `Settings_Fields_Extended`;
|
||||
`Settings_Fields_Extended::get_fields()` merges via array union.
|
||||
- `PRAutoBlogger_Runware_Validator` (new, `providers/class-runware-image-validator.php`):
|
||||
`validate_credentials_detailed()` extracted from `Runware_Image_Provider`;
|
||||
provider delegates via `PRAutoBlogger_Runware_Validator::validate()`.
|
||||
- `class-runware-image-batch.php`: 15 individual `phpcs:disable` lines collapsed
|
||||
to a single grouped suppression (equivalent effect, saves 14 lines).
|
||||
- `class-open-router-image-provider.php`: `snap_aspect_ratio` private method and
|
||||
`STANDARD_ASPECTS` constant removed (now handled by `Image_Aspect_Ratio`).
|
||||
|
||||
## [0.21.0] - 2026-06-14
|
||||
|
||||
### Added
|
||||
- **Runware catalog `modelSearch` migration**: `class-runware-model-catalog.php` migrated
|
||||
from the retired `taskType:models` endpoint to the current `taskType:modelSearch` API.
|
||||
Payload format: `[{taskType:authentication,apiKey:...},{taskType:modelSearch,
|
||||
taskUUID:<uuid4>,offset:N,limit:N}]`. Response normalization updated for the new shape:
|
||||
`air` identifier replaces `id`, `capabilities[]` array replaces `taskType`/`requiresImage`
|
||||
(filter: entry must contain `"io:text-to-image"`), `comment` field maps to `description`.
|
||||
Logic extracted to `class-runware-catalog-fetcher.php` companion (300-line rule). Cache
|
||||
option key bumped to `prautoblogger_runware_model_cache_v2` to cleanly invalidate v1 rows.
|
||||
- **Pagination for `modelSearch`**: fetcher paginates via `offset`/`limit` up to 5 pages of
|
||||
100 results = 500 models inspected. Runware's catalog has 320k+ entries (mostly community
|
||||
checkpoints); 5 pages reliably captures all curated text-to-image models. A page-cap
|
||||
warning is logged if the cap is reached before exhausting `totalResults`. Client-side
|
||||
`io:text-to-image` capability filter applied — no server-side filter is available.
|
||||
- **Negative-result failure cooldown**: `sync()` records a failure timestamp in
|
||||
`prautoblogger_runware_catalog_last_failure_at` on every error path. `get_models()`
|
||||
gates re-trigger through `is_in_failure_cooldown()`, which respects a configurable
|
||||
window from `prautoblogger_runware_catalog_failure_cooldown_seconds` (default 3600s / 1h,
|
||||
never hardcoded at point of use). A successful sync clears the timestamp. This caps the
|
||||
admin-page-load cascade (42 errors/day observed) to ~24/day even when the cache is absent.
|
||||
- **Hardcoded fallback list preserved**: unchanged; carried prod through the entire
|
||||
`taskType:models` outage. Tests verify it is always non-empty and correctly priced.
|
||||
- **Board âNew Articleâ button (Phase 2 admin M4)**. Manual generation is now
|
||||
accessible from the kanban board (not just the Settings page). The button fires
|
||||
the existing `prautoblogger_generate_now` AJAX action; the board status area
|
||||
shows generation progress via the existing transient poller. Requires
|
||||
`manage_options` capability.
|
||||
- **Chained-cron checkpoint machine** (`PRAutoBlogger_Generation_Checkpoint_Runner`).
|
||||
Replaces the monolithic `on_manual_generation()` synchronous path with a
|
||||
two-tick checkpoint machine so Hostingerâs PHP-process kill cannot abort a
|
||||
mid-pipeline run:
|
||||
- Tickâ1 (`prautoblogger_gen_orchestrate`): collect â analyze â score ideas,
|
||||
persist to `prautoblogger_article_queue` option, schedule Tick 2.
|
||||
- Tickâ2â¦N (`prautoblogger_gen_tick`): pop ONE idea, generate one article,
|
||||
reschedule or finalize. Persists consumed queue BEFORE generating to prevent
|
||||
orphan-recovery duplication on restart.
|
||||
- Budget / halt: `Cost_Tracker::is_budget_exceeded()` checked per tick;
|
||||
a halted run aborts all remaining ticks without calling `Article_Worker`.
|
||||
- **WP-CLI `wp prautoblogger generate` command**. Routes through the same
|
||||
chained-cron checkpoints as the board button. Replaces the retired SSH-only
|
||||
`wp eval 'do_action("prautoblogger_manual_generation")'` workaround.
|
||||
- **Settings-backed board pagination** (perf pass). All hardcoded LIMIT / `posts_per_page`
|
||||
values in board queries replaced with two new settings:
|
||||
- `prautoblogger_board_column_limit` (default 20, constant `PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT`)
|
||||
- `prautoblogger_ideas_per_page` (default 30, constant `PRAUTOBLOGGER_DEFAULT_IDEAS_PER_PAGE`)
|
||||
- **DB indexes** (via `PRAUTOBLOGGER_DB_VERSION` â `1.4.0`):
|
||||
- `generation_log`: `KEY run_id_post_id`, `KEY created_status` (board query + failed-cards).
|
||||
- `analysis_results`: `KEY analyzed_type` (ideas-browser pagination + type filter).
|
||||
- `runs`: `KEY started_at` (articles-list pagination `ORDER BY started_at DESC LIMIT n OFFSET m`).
|
||||
- **Query helper split**: `PRAutoBlogger_Ideas_Browser_Query` (new `class-ideas-browser-query.php`)
|
||||
carries static query helpers extracted from `class-ideas-browser.php` to enforce the
|
||||
300-line rule (CONVENTIONS §1).
|
||||
- **Guard P2s** (`scripts/check-constants.php`):
|
||||
- Added `--self-test` mode: synthesizes a missing constant and asserts the scanner catches it.
|
||||
- Added inline comment documenting `CONSTANTS_GUARD_WINDOW = 4`.
|
||||
- Wired `--self-test` as a CI step after the main constants check.
|
||||
- **PHPUnit test** `tests/unit/Core/GenerationCheckpointRunnerTest.php`: tick-by-tick
|
||||
state-machine proof (kick_off schedules only, empty queue finalizes, halted run aborts,
|
||||
multi-idea queue pops one and reschedules).
|
||||
- **Pipeline_Status** now exports `write_initial()` and `write_error()` for the
|
||||
checkpoint runner to set the status transient before the first cron tick fires.
|
||||
|
||||
### Changed
|
||||
- `Executor::on_manual_generation()` now delegates to
|
||||
`Generation_Checkpoint_Runner::on_orchestrate_tick()` instead of running the full
|
||||
pipeline synchronously. The method body is 1 line; the synchronous path is retired.
|
||||
- `Pipeline_Runner::orchestrate_only()` added as a public wrapper exposing the private
|
||||
`orchestrate()` method for use by `Generation_Checkpoint_Runner`.
|
||||
|
||||
## [0.18.2] - 2026-06-12
|
||||
### Removed
|
||||
- **SSH-only workaround retired**: `wp eval 'do_action("prautoblogger_manual_generation")'` no
|
||||
longer fires the pipeline synchronously in one PHP process. Use `wp prautoblogger generate`
|
||||
instead (routes through the chained-cron checkpoint machine). See ARCHITECTURE.md §25.
|
||||
|
||||
Four confirmed-live maintenance fixes (CTO thread `2026-06-pipeline-v2-phase1` seq 25).
|
||||
## [0.20.1] - 2026-06-12
|
||||
|
||||
### Fixed
|
||||
- **Cost-reporter SQL (P2)**: `phpcs:ignore` annotations were embedded inside two SQL string
|
||||
literals in `class-cost-reporter.php` (`get_daily_spend` and `get_spend_by_stage`). MariaDB
|
||||
received the comment text as literal SQL and rejected both queries, breaking the Metrics &
|
||||
Costs admin page. Annotations moved above the `$wpdb->prepare()` calls. Regression tests
|
||||
added to `CostReporterTest` asserting no `// phpcs:ignore` text appears in the SQL passed
|
||||
to `prepare()`.
|
||||
- **Collector failure observability (P2)**: `RuntimeException` catch in
|
||||
`Source_Collector::collect_from_all_sources()` now logs structured meta — model slug,
|
||||
HTTP status parsed from the exception message, and the first 200 chars of the error body —
|
||||
so a 27-day outage like the seq-22 llm_research 404 incident is diagnosable after the fact.
|
||||
No behaviour change; logging only.
|
||||
- **`run_stages.agent_role` symmetric write (P3)**: `Pipeline_Runner`, `Article_Worker`, and
|
||||
`Content_Generator` were calling `start()` with the mapped role but leaving `done()`,
|
||||
`is_done()`, and `get_output()` with `agent_role = ''`. With `agent_role` in the UNIQUE key,
|
||||
the mismatch addressed different rows — stranding `start()` rows in `running` forever and
|
||||
breaking idempotent resume (duplicate-post vector). Fixed via an in-class seam in
|
||||
`Run_Stage_State::resolve_role()`: when the caller passes `''`, the role is derived from
|
||||
`Stage_Display_Map::default_agent_role($stage)` before any DB query, ensuring every method
|
||||
addresses the same row. Explicit non-empty roles (Phase-2 fan-out) are passed through
|
||||
unchanged. `get()` falls back to `role=''` on miss for mid-run-upgrade compat with v0.18.1
|
||||
rows. Two new `RunStageStateTest` cases lock the symmetric-role and fallback contracts.
|
||||
ARCHITECTURE.md §22 updated to document Phase 1 role population.
|
||||
- **Runware float deprecation (P3)**: `normalize_seed()` in `class-runware-image-support.php`
|
||||
applied the modulo operator to a float (`microtime(true) * 1000`), triggering
|
||||
`Deprecated: Implicit conversion from float … to int` on every image generation. Fixed
|
||||
with `(int) round(...)` before the `%` operator.
|
||||
- **Restore three bootstrap constants deleted by the v0.19.0 constants-block regression**
|
||||
(`PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD` 0.50, `PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS` 14,
|
||||
`PRAUTOBLOGGER_DEFAULT_REASONING_MAX_TOKENS` 2048 — verbatim v0.18.3 values). PR #156 (v0.19.0)
|
||||
dropped them from `prautoblogger.php`; the gap was latent because `tests/bootstrap.php` defines
|
||||
its own fallbacks (CI green) and no exercised v0.19.x runtime path read them (prod reasoning
|
||||
disabled). v0.20.0's activator defaults + run-state ceiling read them on the admin
|
||||
migration path -> fatal 500 on all plugin admin pages immediately after the v0.20.0 deploy
|
||||
(2026-06-12; front-of-site unaffected; hot-patched on prod within minutes, this commit makes
|
||||
it canonical). Follow-up owed: CI guard asserting every referenced PRAUTOBLOGGER_* constant
|
||||
is production-defined or defined()-guarded (maintenance thread).
|
||||
|
||||
## [0.18.1] - 2026-06-11
|
||||
|
||||
Empty-draft hotfix (CTO diagnosis: convo thread `2026-06-pipeline-v2-phase1` seq 12; prod run
|
||||
`acf24029`, draft post 921). A true reasoning model at a high reasoning effort could spend the
|
||||
entire `max_tokens` completion budget on thinking, return zero visible content with
|
||||
`finish_reason=length`, and the empty article flowed writer → editor (reject: "draft text is
|
||||
missing") → empty draft post in the Review Queue, with the cost booked as `success`.
|
||||
|
||||
### Fixed
|
||||
- **Empty-completion guard at the call seam** (`PRAutoBlogger_OpenRouter_Completion_Guard`):
|
||||
a chat completion whose visible content is empty/whitespace is now a **failure**, never a
|
||||
success. When the empty result co-occurs with `finish_reason=length` or an active reasoning
|
||||
request, the call is retried **once** with reasoning disabled (per-call `reasoning`
|
||||
override) before the stage fails. Failed/retried attempts land in `generation_log` with
|
||||
`response_status='error'`, the failure note, and the real token burn; inside a v0.18.0 run a
|
||||
`run_decisions` row records the verdict (`retry_reasoning_disabled` /
|
||||
`failed_empty_completion`).
|
||||
- **Writer-path guard**: a writing stage (outline/draft/polish/single-pass) whose output is
|
||||
empty throws instead of passing an empty article to the editor (belt over the seam guard,
|
||||
covers any future non-OpenRouter provider).
|
||||
- **Publisher belt-and-braces**: `Publisher::create_post()` refuses to create any post —
|
||||
publish or draft — whose content is empty after tag stripping. No empty draft post can ever
|
||||
be created again.
|
||||
- `finish_reason=length` is now always warning-logged with model, stage, and
|
||||
prompt/completion/reasoning token counts (previously silent — the truncated `llm_research`
|
||||
call in the incident run was invisible).
|
||||
## [0.20.0] - 2026-06-12
|
||||
|
||||
### Added
|
||||
- **Reasoning token cap + completion headroom**: when a request enables reasoning, the
|
||||
thinking budget is capped via OpenRouter's `reasoning.max_tokens` and the request's
|
||||
`max_tokens` is **raised** by the same amount, so no reasoning effort (incl. `xhigh`) can
|
||||
consume the visible-content budget. New setting `prautoblogger_reasoning_max_tokens`
|
||||
(Settings → AI Models, default `PRAUTOBLOGGER_DEFAULT_REASONING_MAX_TOKENS` = 2048; 0
|
||||
restores the previous uncapped effort mode; the option is sanitized server-side as
|
||||
numeric). When the cap is active it replaces `effort`
|
||||
(OpenRouter treats them as alternative budget controls); the `xhigh`/`high` effort options
|
||||
stay available but are bounded by default. Cost-governor reservations use the effective
|
||||
(raised) ceiling.
|
||||
- LLM call sites now pass their pipeline stage to the provider (`stage` option, never sent
|
||||
upstream) so guard warnings and failure rows are attributable: `analysis`, `review`,
|
||||
`llm_research`, `image_prompt_rewrite`, `opik_eval_judge`, and the writer stages (which
|
||||
also pass `prompt_key`).
|
||||
- **Edit + single-step re-run (Phase 2 admin M3)** under the five CPO guardrails
|
||||
(cpo seq 8, hard AC). From the Article Dossier, eligible stages can have their
|
||||
input edited and re-run as QUEUED background jobs (chained-cron — the UI never
|
||||
implies synchronous execution; board + dossier polling shows queued → pickup →
|
||||
result).
|
||||
- **Immutable input versions (guardrail 1).** Edits fork into the new INSERT-only
|
||||
`prautoblogger_stage_inputs` table (monotonic versions per stage scope); the
|
||||
executed original in `generation_log.request_json` is never overwritten. The
|
||||
editor locks message structure (count/roles) so every fork stays replayable.
|
||||
- **Visible human-modified audit (guardrail 2).** `run_stages.human_modified` +
|
||||
`run_decisions.human_modified` set when an edited fork enters execution (sticky,
|
||||
never cleared); surfaced as dossier header + stage chips, a board-card chip, and
|
||||
decisions recorded while rebuilding a human-modified item are flagged too.
|
||||
- **Explicit downstream invalidation (guardrail 3).** Re-running a stage marks all
|
||||
downstream chain stages `stale` (new column — rows STAY 'done', so the resume
|
||||
machinery can never silently auto-re-run them). Stale chips render per stage, in
|
||||
the dossier header, and as a Review Queue warning chip linking to the dossier
|
||||
(stale state visible at publish). Downstream re-runs are deliberate: per-stage
|
||||
"Re-run from here" rebuilds the target + downstream from current upstream
|
||||
snapshots via the existing worker resume path.
|
||||
- **Cost governor applies to re-runs (guardrail 4).** Replays go through the normal
|
||||
provider seam: reserve-before-call against the SAME run ledger row.
|
||||
`Run_State::reopen()` re-snapshots the ceiling from the current setting and never
|
||||
resets accumulated spend. The dossier shows a Run Spend card + per-panel spend
|
||||
strip and warns when committed spend reaches the ceiling fraction (filterable,
|
||||
`prautoblogger_rerun_ceiling_warn_fraction`, default 0.8).
|
||||
- **Published posts are frozen (guardrail 5).** publish/future/private lock the run
|
||||
server-side (not just hidden UI); `Publisher` refreshes re-run output onto
|
||||
UNPUBLISHED posts only, preserving their status — a re-run never publishes.
|
||||
- **B1 — `request_json` persisted on every chat LLM call** (QA M2 F1). The provider
|
||||
records the outgoing request BODY pre-dispatch (process-scoped recorder, consume-once);
|
||||
`log_api_call()` writes it to the row. Authorization headers can never be recorded
|
||||
(built separately; body keys whitelisted — covered by tests). Retention rides the
|
||||
existing `prautoblogger_request_json_retention_days` prune; the dossier raw-trace
|
||||
"Request payload" section now renders for new runs.
|
||||
- **Idea seeds.** `Article_Worker` persists the exact `Article_Idea` payload once per
|
||||
item (stage_inputs, source='seed') so re-run-from-here reconstructs the precise idea
|
||||
(re-deriving from post fields could break the item-key hash and duplicate a post).
|
||||
- **Dossier sidebar consolidation (QA M2 F2).** New "Models & Prompts" card: per-stage
|
||||
model + prompt version + agent role at a glance, plus the "Run Spend" card.
|
||||
- **Image sections wired to real data (QA M2 F3).** image_a/image_b (and every
|
||||
log-only stage: llm_research, image_prompt_rewrite, …) now render from their actual
|
||||
generation_log rows, with the post's pipeline attachments (featured/og/square via
|
||||
`_prautoblogger_image_role`) shown as an image grid — chosen/featured highlighted.
|
||||
- **Schema (db 1.3.0).** `run_stages` + `human_modified`/`stale`; `run_decisions` +
|
||||
`human_modified`; new `stage_inputs` table. dbDelta-idempotent via the standard
|
||||
self-healing migration path (admin_init + cron init); half-migrated sites degrade
|
||||
gracefully (done() retries the legacy statement; M3 features no-op until migrated).
|
||||
Uninstall drops the new table and clears the two new cron hooks.
|
||||
|
||||
### Changed
|
||||
- **`PRAutoBlogger_Run_Stage_State` split** (300-line cap, M2 pattern): pipeline write
|
||||
bodies moved verbatim to `Run_Stage_Writes` (public API unchanged via thin proxies);
|
||||
operator-action mutations (restart / mark_stale / demote_to_pending) isolated in
|
||||
`Run_Stage_Rerun_State` — the ONLY code allowed to demote a done row. `done()` now
|
||||
clears `stale` on fresh output.
|
||||
- **Dossier is item-scoped** (M2 correctness fix): stage rows filter to THIS post's
|
||||
item (idea-hash meta), and gen_log rows linked to OTHER posts of a multi-article run
|
||||
are excluded (unlinked run-level rows are kept).
|
||||
- **`Publisher` idempotency short-circuit** now refreshes existing UNPUBLISHED posts
|
||||
with re-run content (title/content/verdict meta; status untouched) instead of
|
||||
silently discarding regenerated output. Published posts remain untouched.
|
||||
- Review Queue rows show a stale-stages warning chip linking to the dossier; board
|
||||
cards show a "Human-modified" chip (single batched query, no N+1).
|
||||
|
||||
### Fixed
|
||||
- Dossier "Request payload" raw trace was always empty (request_json never populated —
|
||||
QA M2 F1); image sections always rendered the absent state (QA M2 F3).
|
||||
## [0.19.4] - 2026-06-12
|
||||
|
||||
### Added
|
||||
- **GD-rung unit tests.** New `ImageComposerEditorTest.php` (7 tests) covering
|
||||
`PRAutoBlogger_Image_Composer_Editor`: `wp_get_image_editor` WP_Error, resize WP_Error,
|
||||
upscale-guard size mismatch, save WP_Error, zero-width target geometry guard, missing-role
|
||||
guard, and successful OG resize happy path. Orchestrator ladder test
|
||||
`test_gd_rung_emits_passthrough_featured_and_single_warning` added to `ImageComposerTest.php`,
|
||||
asserting the Imagick renderer is never called on the `gd` rung and pass-through featured is
|
||||
always first.
|
||||
(Source: QA verdict `Peptide Repo CTO/qa-reviews/prautoblogger/2026-06-11-6f76df1.md` P2-1.)
|
||||
|
||||
### Fixed
|
||||
- **Dead layout keys removed.** `caption_margin_right` (og) and `caption_side_padding` (square)
|
||||
were defined in `Layout::defaults()` and exposed via the `prautoblogger_image_compose_layout`
|
||||
filter but never consumed by the Imagick renderer. The og renderer positions text from
|
||||
`logo_x + logo_w + caption_gap` (left-anchor); the square renderer uses centered `x` alignment.
|
||||
Right-edge overflow is enforced via `caption_chars_per_line` on both variants. Keys removed;
|
||||
filter users tuning these values had no effect and received no error.
|
||||
(Source: QA verdict `Peptide Repo CTO/qa-reviews/prautoblogger/2026-06-11-6f76df1.md` P3-3.)
|
||||
- **Amortized research row now carries audit fields.** `Post_Assembler::amortize_research_costs()`
|
||||
previously inserted per-article `llm_research` copies without `prompt_version` or `agent_role`,
|
||||
leaving those columns NULL/empty while the original (correctly stamped) row was deleted.
|
||||
SELECT now includes both columns; INSERT carries them forward from the original row.
|
||||
(Source: phase-1 thread seq 49, seq 50 carried-list: "amortized llm_research rows pv=null/role
|
||||
empty" — `convo/prautoblogger/threads/2026-06-pipeline-v2-phase1/`.)
|
||||
- **`fetch_page()` error body included in exception.** Non-200 responses from the Runware
|
||||
modelSearch API now include up to 200 chars of the response body in the `RuntimeException`
|
||||
message, making diagnosis from logs actionable without re-reproducing the request.
|
||||
(Source: phase-1 thread seq 50 carried-list: "`fetch_page()` error-body logging".)
|
||||
|
||||
### Notes
|
||||
- `OpenRouter_Provider::send_chat_completion()` body assembly moved to
|
||||
`OpenRouter_Request_Builder::build_body()` (300-line cap; behavior unchanged apart from the
|
||||
reasoning budget above).
|
||||
- cto-review.yml isinstance guard (handoff item 5): the fix (`c.get("severity", "UNKNOWN")`)
|
||||
is already applied at HEAD (line 454 of the workflow). No changes made.
|
||||
|
||||
## [0.18.0] - 2026-06-12
|
||||
|
||||
Pipeline v2 — Phase 1 substrate (plan of record: CPO thread
|
||||
`2026-06-prautoblogger-pipeline-redesign`, seq 3). Built on the current pipeline with **no
|
||||
behavior change** to the Economy (single-pass) and multi-step publish paths.
|
||||
|
||||
### Added
|
||||
- **Schema v1.2.0** (`prautoblogger_db_version` 1.1.0 → 1.2.0, idempotent dbDelta
|
||||
migrations, self-healing on version mismatch): new tables
|
||||
`wp_prautoblogger_prompts` (versioned prompt registry),
|
||||
`wp_prautoblogger_run_sources` + `wp_prautoblogger_run_decisions` (audit child tables),
|
||||
`wp_prautoblogger_runs` (per-run cost ledger + lifecycle) and
|
||||
`wp_prautoblogger_run_stages` (per-run per-stage state machine, idempotency key
|
||||
`run_id + stage + agent_role + item_key`); additive nullable columns `agent_role` and
|
||||
`prompt_version` on `wp_prautoblogger_generation_log` (historical rows stay valid).
|
||||
- `PRAutoBlogger_Stage_Display_Map`: single PHP vocabulary for historical stages
|
||||
(`analysis`, `outline`, `draft`, `polish`, `review`, `llm_research`, `image_a`, `image_b`,
|
||||
`image_prompt_rewrite`, `opik_eval_judge`), Pipeline v2 stages (`research`, `curate`,
|
||||
`draft`, `editorial`, `seo`, `publish`) and a humanizing fallback for unknown values, plus
|
||||
per-stage default agent role and primary prompt-registry key.
|
||||
- Uninstall now drops the five substrate tables and clears every plugin cron hook
|
||||
(previously only two of eight were cleared).
|
||||
- **Versioned prompt registry** (`PRAutoBlogger_Prompt_Registry` + `_Writer`): all pipeline
|
||||
prompt copy — content system/single-pass/outline/draft/polish, analysis system/user,
|
||||
editor system/review, research system, plus the **illustration rewriter prompt and the
|
||||
image Style Template** (the image-composer PR seam) — is seeded as immutable version 1
|
||||
from the v0.16.0 hardcoded texts (`prautoblogger_migrated_prompt_seed_v0180`). One active
|
||||
version per key; changes create new versions, never mutate. Call sites render through the
|
||||
registry with a **byte-identical in-code fallback** when the table is missing/empty
|
||||
(self-healing, no fatals on half-migrated state). No admin UI in Phase 1.
|
||||
- **Prompt-version pinning**: `Cost_Tracker::set_run_id()` pins the active version of every
|
||||
key on the run's row; every generation_log row (text and image stages) is stamped with the
|
||||
pinned `prompt_version` plus a stage-derived `agent_role`. Single-pass rows keep stage
|
||||
`draft` and stamp `content.single_pass` explicitly.
|
||||
- `PRAutoBlogger_Run_State` (runs-table ledger row: ceiling/reserved/settled/overage, pins,
|
||||
sticky lifecycle states) and `PRAutoBlogger_Run_Context` (per-process run id, mirrors the
|
||||
Opik trace-context pattern).
|
||||
|
||||
- **Per-run cost governor** (`PRAutoBlogger_Cost_Governor`, net-new enforcement — previously
|
||||
only the monthly cap was enforced): every `send_chat_completion()` reserves its worst-case
|
||||
estimate (prompt chars/4 + max_tokens, priced via the #18 chain) against the run ledger
|
||||
with a single atomic conditional UPDATE (the #10 lock discipline) BEFORE dispatch; the
|
||||
image `curl_multi` batch reserves its summed estimate before dispatch; reservations settle
|
||||
to actuals after each response (a failed call releases its hold). Breach → run `halted`
|
||||
(sticky), overage recorded, `Cost_Ceiling_Exception` aborts the call, remaining queued
|
||||
articles abort, and the run is surfaced in a "Halted runs" notice on the Review Queue.
|
||||
New setting **Per-Run Cost Ceiling (USD)** (`prautoblogger_per_run_cost_ceiling_usd`,
|
||||
default `PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD` = $0.50, snapshotted at run start,
|
||||
0 = disabled). Calls outside a run, the monthly budget, and the Cloudflare AI Gateway
|
||||
path behave exactly as before.
|
||||
|
||||
- **Run state machine + idempotent resume** (`PRAutoBlogger_Run_Stage_State`): per-run
|
||||
per-stage rows keyed `run_id + stage + agent_role + item_key` (item_key scopes
|
||||
article-level stages — one run_id spans all N articles of a batch; agent_role is the
|
||||
Phase-2 quorum dimension, schema-only for now). Done stages snapshot their output and are
|
||||
REUSED on re-entry — never re-run, never re-charged (writer stages via
|
||||
`Content_Generator::set_run_item()`, the editorial review via its stored verdict, a fully
|
||||
published item is skipped outright). **Post creation is keyed by `_prautoblogger_run_id`
|
||||
+ new `_prautoblogger_idea_hash` meta (check-before-insert)** so a retried run cannot
|
||||
create a duplicate post. Run-level research/analysis checkpoints ride Pipeline_Runner;
|
||||
completion/failure marks land on the runs row (Executor catch paths, orphan aborts,
|
||||
monthly-budget aborts).
|
||||
- **Reaper extension** (`PRAutoBlogger_Run_Reaper`, same daily cron as the #19 research
|
||||
reaper — no new schedule): stages stuck `running` > 2× expected stage wall-clock and runs
|
||||
silent > 2× expected run wall-clock are marked `failed` (filterable expectations); their
|
||||
open stages are reaped; such runs are queryable as **"incomplete"** via
|
||||
`Run_Reaper::incomplete_runs()`. Also enforces **retention**: `request_json` on the
|
||||
generation log and stage output snapshots are NULLed after R days — R from the new
|
||||
**Audit Payload Retention** setting (`prautoblogger_request_json_retention_days`, default
|
||||
`PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS` = 14, 0 = keep forever).
|
||||
- **Audit writes** (`PRAutoBlogger_Audit_Writer`): every reviewed article records its
|
||||
editorial verdict in `run_decisions`; every published article records the sources that
|
||||
fed it in `run_sources` (the source_ids_json / `_prautoblogger_research_sources`
|
||||
consolidation for new runs; no backfill).
|
||||
|
||||
### Changed
|
||||
- `Content_Generator`'s four stage methods now share one `execute_stage()` helper (the Opik
|
||||
span + dispatch + cost-log boilerplate was identical); file drops from 301 to ~220 lines.
|
||||
No behavior change: same prompts, models, params, span names, stage values.
|
||||
- `Cost_Tracker::get_avg_tokens_for_stages()` body moved to `Cost_Reporter` (read-only
|
||||
reporting query); the Cost_Tracker method remains as a delegating wrapper.
|
||||
- `OpenRouter_Provider`'s API-key fetch + format validation moved to
|
||||
`OpenRouter_Request_Builder::resolve_api_key()` (300-line cap); identical messages/behavior.
|
||||
- Pipeline_Runner's and Article_Worker's duplicated status-transient/summary helpers moved
|
||||
verbatim to the new `PRAutoBlogger_Pipeline_Status` (Pipeline_Runner back under the
|
||||
300-line cap at 271).
|
||||
## [0.19.3] - 2026-06-12
|
||||
|
||||
### Fixed
|
||||
- ARCHITECTURE.md documented the multi-step edit stage as `edit`; the code has always
|
||||
written `polish`. Canonicalized to **`polish`** everywhere.
|
||||
- Half-migrated-schema hardening: generation_log inserts retry without the new audit
|
||||
columns when the columns are missing (a cron run right after deploy no longer loses cost
|
||||
rows), and the db-version self-heal now also runs on cron requests (`init` +
|
||||
`wp_doing_cron()`), not only on `admin_init`.
|
||||
- OpenRouter pricing now resolves dated snapshot model IDs returned by the API
|
||||
(e.g. response model `deepseek/deepseek-v4-flash-20260423` for a
|
||||
`deepseek/deepseek-v4-flash` request) to the base model's price before the
|
||||
conservative-estimate fallback applies. Such responses were previously booked
|
||||
at the conservative $10/$30 per-M rate (~30x real cost on flash-tier models) —
|
||||
phantom spend that ate the per-run cost ceiling and the monthly budget gate.
|
||||
- **Dossier page 403 (P0 hotfix).** `PRAutoBlogger_Dossier_Page::on_register_menu` registered the
|
||||
dossier as a hidden submenu under `prautoblogger-settings` then called `unset($submenu[...])` to
|
||||
hide it from the nav. This is the **hide-by-unset anti-pattern**: at registration time WP records
|
||||
the render callback under hookname `prautoblogger_page_prautoblogger-dossier`; at request time
|
||||
`get_admin_page_parent()` scans `$submenu`, finds the slug absent (it was unset), returns false,
|
||||
and WP recomputes the hookname under the orphan `admin_page_*` namespace -- no handler registered
|
||||
there -- `wp_die(403)`. Same hookname-mismatch class as the board 404 (v0.19.1), different vector.
|
||||
**Fix:** register under parent `options.php` (canonical hidden-page pattern). Hookname is
|
||||
`admin_page_prautoblogger-dossier` at both registration and request time. No `$submenu` mutation
|
||||
required or permitted. Asset enqueue gate updated to match (`admin_page_*` prefix).
|
||||
Deep-link URLs (`admin.php?page=prautoblogger-dossier&post_id=N`) are parent-agnostic and
|
||||
unchanged. Board cards and the post metabox link continue to work without modification.
|
||||
|
||||
### Changed
|
||||
- **Regression test upgraded to request-time faithful** (`DossierMenuRegistrationTest`).
|
||||
New `test_request_time_hookname_matches_registration_hookname` simulates
|
||||
`get_admin_page_parent()` against the post-registration `$submenu` state and asserts it
|
||||
equals the registration-time hookname. FAILS on hide-by-unset (4 failures on v0.19.2 code,
|
||||
captured in test evidence). PASSES on options.php-parent. New
|
||||
`test_hide_by_unset_would_fail_request_time_check` documents the broken pattern inline.
|
||||
- **CONVENTIONS.md §Hidden Admin Pages** added: options.php-parent is the only permitted
|
||||
pattern for hidden admin pages; `$submenu` mutation after registration is explicitly
|
||||
prohibited; full incident history (board 404 + dossier 403) and test methodology documented.
|
||||
- **ARCHITECTURE.md §22b** updated with hidden-page convention cross-reference.
|
||||
**§23** updated to reflect options.php-parent registration and the v0.19.2→v0.19.3 fix.
|
||||
- **tests/bootstrap.php**: added `PRAUTOBLOGGER_PLUGIN_URL` and `PRAUTOBLOGGER_VERSION`
|
||||
constants so admin enqueue tests resolve without "Undefined constant" errors.
|
||||
|
||||
## [0.19.2] - 2026-06-12
|
||||
|
||||
### Added
|
||||
- **Article Dossier page (M2).** Per-article read-only inspection page at
|
||||
`admin.php?page=prautoblogger-dossier&post_id=<id>`. Accessible via board card
|
||||
deep-links and the slim post metabox link. Shows: header (title, verdict pill, tier,
|
||||
status), sidebar (run metadata, per-stage cost receipt with model + prompt-version),
|
||||
vertical stage sections (rendered output default, per-stage raw-trace toggle exposing
|
||||
`generation_log` request JSON + tokens + cost + role), editorial decisions rationale,
|
||||
image A/B pair sections.
|
||||
- **Board card deep-links.** All Kanban cards now deep-link directly to the article
|
||||
dossier page (`dossier_url` on card data; `click_action = dossier` in board JS).
|
||||
- **Slim post metabox.** `PRAutoBlogger_Post_Metabox` now renders a single "View
|
||||
generation dossier →" link (was a verbose generation-metadata dump).
|
||||
- **Dossier CSS/JS.** `assets/css/dossier.css` (warm editorial palette, verdict pills,
|
||||
cost receipt, raw-trace toggle) + `assets/js/dossier.js` (vanilla JS toggle,
|
||||
`aria-expanded` / `aria-controls` accessible).
|
||||
- **Menu registration test coverage.** `DossierMenuRegistrationTest.php` — asserts
|
||||
dossier hook fires at priority > board (> parent), uses hidden-submenu pattern,
|
||||
correct slug, and `url_for_post()` URL shape. Mirrors `BoardMenuRegistrationTest`.
|
||||
- **Raw-trace credential check (binding 4).** `request_json` column contains only
|
||||
OpenRouter request bodies (model, messages, temperature — no Authorization headers
|
||||
per architecture). All model output escaped via `esc_html()` / `wp_kses_post()`;
|
||||
treated as untrusted HTML throughout the render path.
|
||||
- **Legacy graceful state (binding 5).** Posts with no run record (pre-v0.18.0) render
|
||||
a clean "No generation record" notice — no notices, no fatals. Amortized research
|
||||
rows (pv=null, role='') render model as "—" with no raw-trace toggle.
|
||||
|
||||
### Changed
|
||||
- **`class-prautoblogger.php` → `class-db-migrations.php` split (binding 1).**
|
||||
All DB-migration methods extracted byte-identically into new static class
|
||||
`PRAutoBlogger_DB_Migrations` (`includes/class-db-migrations.php`, 129 lines).
|
||||
`class-prautoblogger.php` shrinks from 407 → 258 lines; delegates via thin proxy.
|
||||
Loaded via explicit `require_once` in `prautoblogger.php` (autoloader filename
|
||||
collision avoidance — see ARCHITECTURE.md §File Naming Notes).
|
||||
|
||||
## [0.19.1] - 2026-06-12
|
||||
|
||||
### Fixed
|
||||
- **Board admin submenu 404 (P0 hotfix).** `PRAutoBlogger_Board_Page::on_register_menu`
|
||||
was hooked at `admin_menu` priority 10, identical to
|
||||
`PRAutoBlogger_Admin_Page::on_register_menu`. Because the board hook was
|
||||
registered first in `register_admin_hooks()`, `add_submenu_page()` fired while
|
||||
`$admin_page_hooks['prautoblogger-settings']` was still unset. WordPress fell back
|
||||
to the `admin_page_prautoblogger-board` hookname; at request time it recomputed
|
||||
`prautoblogger_page_prautoblogger-board`, found no registered callback, and called
|
||||
`wp_die()` with "Invalid plugin page". Board CSS/JS also never loaded (same
|
||||
enqueue-gate mismatch). Fix: parent menu hook stays at priority 10; board submenu
|
||||
hook moves to priority 11, guaranteeing `add_menu_page()` fires first. Board is
|
||||
kept as the first submenu item via explicit `$submenu` reorder in
|
||||
`PRAutoBlogger_Board_Page::on_register_menu()`.
|
||||
- **Regression test added:** `tests/unit/Admin/BoardMenuRegistrationTest.php` —
|
||||
three assertions covering priority relationship, execution order, and parent-slug
|
||||
correctness. Includes a "documents the bug" test that fires the unfixed hook order
|
||||
to prove the broken state.
|
||||
|
||||
## [0.19.0] - 2026-06-12
|
||||
|
||||
### Added
|
||||
- **Kanban board dashboard (M1 — Phase 2 admin / Direction C).** New
|
||||
`Board` submenu page is now the primary landing screen for the plugin.
|
||||
Four columns — Generating | In Review | Published | Failed — with card
|
||||
click-throughs: Generating/Failed → Activity Log; In Review → Review
|
||||
Queue; Published → Post edit screen. (M2 will rewire all to the Article
|
||||
Dossier.)
|
||||
- **Live board polling.** AJAX action `prautoblogger_board_status` (nonce
|
||||
`prautoblogger_board`) returns the full board snapshot every N seconds.
|
||||
Poll interval backs off to 2× when no article is actively generating
|
||||
and resets to base as soon as a generating card appears. All nonce and
|
||||
capability checks enforced.
|
||||
- **Two new settings** in Schedule & Budget: `prautoblogger_board_poll_interval`
|
||||
(default 5s, min 3s) and `prautoblogger_board_published_window_days`
|
||||
(default 7 days). Both localized into board.js — no hardcoded values.
|
||||
- New files: `includes/admin/class-board-page.php`,
|
||||
`includes/admin/class-board-data-provider.php`,
|
||||
`templates/admin/board-page.php`, `assets/css/board.css`,
|
||||
`assets/js/board.js`.
|
||||
- PHPUnit behavioral tests: `tests/unit/Admin/BoardDataProviderTest.php`
|
||||
(8 tests covering all column/state mappings) and
|
||||
`tests/unit/Core/CostReporterSqlBugFixTest.php` (3 tests for the SQL
|
||||
inline-comment fix).
|
||||
|
||||
### Fixed
|
||||
- **Cost-reporter SQL inline-comment bug (P2).** `PRAutoBlogger_Cost_Reporter::get_daily_spend()`
|
||||
and `get_spend_by_stage()` embedded PHP `// phpcs:ignore` annotations
|
||||
inside SQL string literals, passing the comment text to MySQL. Moved
|
||||
phpcs suppression annotations to standalone PHP line comments above the
|
||||
`$wpdb->prepare()` call — matching the pattern already used in
|
||||
`get_monthly_spend()`. No query behavior change; fixes malformed
|
||||
prepared-statement strings visible in debug logging.
|
||||
|
||||
## [0.17.0] - 2026-06-11
|
||||
|
||||
@@ -1161,6 +1260,7 @@ swap with no user-visible change in article quality.
|
||||
run_id-based log linking, RuntimeException on WP_Error, action/filter
|
||||
hook firing. Replaces previous method-existence-only checks.
|
||||
|
||||
|
||||
## [0.2.1] — 2026-04-12
|
||||
|
||||
### Changed
|
||||
@@ -1197,14 +1297,6 @@ swap with no user-visible change in article quality.
|
||||
- Posts-per-subreddit limit (5–100) in Sources tab.
|
||||
- LiteSpeed cache purge step in CI/CD deploy pipeline.
|
||||
|
||||
### Removed
|
||||
- `class-reddit-api-client.php` — replaced by `class-reddit-json-client.php`.
|
||||
- Reddit OAuth credential fields and encrypted storage entry.
|
||||
|
||||
### Fixed
|
||||
- Deploy pipeline now purges LiteSpeed/OPcache after file extraction,
|
||||
preventing stale PHP bytecode from being served.
|
||||
|
||||
## [0.1.0] — 2026-04-10
|
||||
|
||||
### Added
|
||||
|
||||
@@ -502,3 +502,73 @@ public function my_operation( $input ) {
|
||||
```
|
||||
|
||||
That's it. No config, no feature-flag checks, no try/catch needed.
|
||||
|
||||
---
|
||||
|
||||
## Hidden Admin Pages (v0.19.3 convention, codified after two incidents)
|
||||
|
||||
### Rule: use options.php as parent; NEVER unset/remove registered pages from $submenu
|
||||
|
||||
When a WordPress admin page must be accessible by URL but hidden from the nav menu, use the
|
||||
**options.php-parent pattern** exclusively:
|
||||
|
||||
```php
|
||||
add_submenu_page(
|
||||
'options.php', // parent: built-in WP page; pages hidden by construction
|
||||
__( 'My Page', 'prautoblogger' ), // page title
|
||||
__( 'My Page', 'prautoblogger' ), // menu title (never shown; options.php is hidden)
|
||||
'manage_options', // capability
|
||||
'my-page-slug', // page slug
|
||||
array( $this, 'render_page' ), // callback
|
||||
);
|
||||
// No $submenu manipulation. options.php-parent pages are hidden from nav by construction.
|
||||
```
|
||||
|
||||
The resulting hookname is `admin_page_my-page-slug` at BOTH registration time and request time.
|
||||
|
||||
### Why the hide-by-unset pattern causes 403 (the incident class)
|
||||
|
||||
WordPress computes the page hookname at two distinct moments:
|
||||
|
||||
**Registration time** (inside `add_submenu_page`):
|
||||
WP resolves the parent slug against `$admin_page_hooks`. If found, hookname = `{parent_key}_page_{slug}`. WP registers the render callback and `$_registered_pages` entry under that name.
|
||||
|
||||
**Request time** (inside `wp-admin/admin.php`):
|
||||
WP calls `get_admin_page_parent()` which scans `$submenu` for the page slug. If found, it derives the hookname the same way as (1). If **not found** (e.g., because the entry was unset), it falls back to `admin_page_{slug}`.
|
||||
|
||||
When these two hooknames diverge, WP dispatches the request to a hookname with no registered callback → `wp_die(403)`.
|
||||
|
||||
**Why options.php avoids this:** `options.php` is a built-in page that WordPress always keeps intact in `$admin_page_hooks` and `$submenu`. For its children, `get_admin_page_parent()` returns `options.php` consistently at both moments, so both produce `admin_page_{slug}`. No mutation required.
|
||||
|
||||
### Incident history
|
||||
|
||||
| Version | Symptom | Root cause | Fix |
|
||||
|---------|---------|------------|-----|
|
||||
| v0.19.1 | Board page 404 | Board submenu registered at same priority as parent (both 10) — add_submenu_page fired before add_menu_page → wrong hookname at registration time | Board moved to priority 11, after parent at 10 |
|
||||
| v0.19.3 | Dossier page 403 | Dossier registered under `prautoblogger-settings` parent (priority 12, after parent ✓) but post-registration `unset($submenu[...])` removed the slug → get_admin_page_parent() returned false at request time → orphan `admin_page_*` hookname → no handler → 403 | Switched to `options.php` parent; removed all $submenu mutation |
|
||||
|
||||
Both incidents are in the same **hookname-mismatch class**: registration-time hookname ≠ request-time hookname.
|
||||
|
||||
### Test pattern
|
||||
|
||||
The `DossierMenuRegistrationTest::test_request_time_hookname_matches_registration_hookname` test
|
||||
replicates the request-time resolution algorithm (scan `$submenu` for the slug; if found derive
|
||||
hookname; if absent return `admin_page_*`) and asserts it equals the registration-time hookname.
|
||||
This test FAILS on hide-by-unset implementations and passes on the options.php-parent pattern.
|
||||
Apply the same test strategy to any new hidden admin page.
|
||||
|
||||
### Asset enqueue gate
|
||||
|
||||
For `options.php`-parent pages, the hook suffix passed to `admin_enqueue_scripts` is
|
||||
`admin_page_{slug}`. For plugin-parent pages it would be `{parent_key}_page_{slug}`. Always
|
||||
match the asset enqueue gate to the actual hookname for the chosen parent pattern:
|
||||
|
||||
```php
|
||||
// options.php parent → 'admin_page_{slug}'
|
||||
public function on_enqueue_assets( string $hook_suffix ): void {
|
||||
if ( $hook_suffix !== 'admin_page_' . self::PAGE_SLUG ) {
|
||||
return;
|
||||
}
|
||||
// ... enqueue
|
||||
}
|
||||
```
|
||||
|
||||
84
INFORMATION-ARCHITECTURE.md
Normal file
84
INFORMATION-ARCHITECTURE.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Information Architecture — PRAutoBlogger
|
||||
|
||||
Auto-generated index of all admin surfaces, data flows, and integrations.
|
||||
Maintained per DoD v1.1.0 (same-PR update rule).
|
||||
|
||||
---
|
||||
|
||||
## Admin Pages
|
||||
|
||||
| Slug | Class | Template | Purpose |
|
||||
|------|-------|----------|---------|
|
||||
| `prautoblogger-settings` | `PRAutoBlogger_Admin_Page` | `admin-page.php` | Main settings (schedule, models, budget, board limits) |
|
||||
| `prautoblogger-board` | `PRAutoBlogger_Board_Page` | `board-page.php` | Kanban board: Generating / Failed / In Review / Published columns. New Article button (v0.21.0). |
|
||||
| `prautoblogger-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 |
|
||||
| `prautoblogger-activity` | `PRAutoBlogger_Activity_Page` | `activity-page.php` | Cost + generation activity log |
|
||||
|
||||
---
|
||||
|
||||
## Generation Entry Points (v0.21.0)
|
||||
|
||||
```
|
||||
Board "New Article" button
|
||||
└─ AJAX prautoblogger_generate_now
|
||||
└─ Generation_Status_Poller::on_ajax_generate_now()
|
||||
└─ schedules prautoblogger_manual_generation cron
|
||||
└─ Executor::on_manual_generation()
|
||||
└─ Generation_Checkpoint_Runner::on_orchestrate_tick() ← Tick 1
|
||||
└─ schedules prautoblogger_gen_tick
|
||||
└─ Generation_Checkpoint_Runner::on_generate_tick() ← Tick 2..N
|
||||
|
||||
WP-CLI: wp prautoblogger generate
|
||||
└─ WP_CLI_Commands::generate_command()
|
||||
└─ Generation_Checkpoint_Runner::kick_off()
|
||||
└─ schedules prautoblogger_gen_orchestrate
|
||||
└─ Generation_Checkpoint_Runner::on_orchestrate_tick() ← Tick 1
|
||||
|
||||
Daily auto-generation cron
|
||||
└─ prautoblogger_daily_generation
|
||||
└─ Executor::on_cron_run()
|
||||
└─ Pipeline_Runner::run() (legacy synchronous path — runs in a single PHP process)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Tables
|
||||
|
||||
| Table | DB Version | Purpose |
|
||||
|-------|-----------|---------|
|
||||
| `prautoblogger_sources` | 1.0.0 | Collected Reddit/RSS source content |
|
||||
| `prautoblogger_analysis_results` | 1.0.0 | Scored article ideas |
|
||||
| `prautoblogger_generation_log` | 1.0.0 | Per-LLM-call log (cost, tokens, status) |
|
||||
| `prautoblogger_runs` | 1.0.0 | Per-run ledger (status, ceiling_usd) |
|
||||
| `prautoblogger_run_stages` | 1.2.0 | Per-stage state for M3 re-run |
|
||||
| `prautoblogger_stage_inputs` | 1.3.0 | Immutable input versions for stage edits (M3) |
|
||||
| `prautoblogger_run_decisions` | 1.2.0 | Editorial decisions per run |
|
||||
|
||||
DB version history: 1.0→1.1 (schema normalisation), 1.1→1.2 (M2 dossier), 1.2→1.3 (M3 stage inputs), 1.3→1.4 (M4 indexes).
|
||||
|
||||
---
|
||||
|
||||
## Key Options
|
||||
|
||||
| Option | Type | Default | Purpose |
|
||||
|--------|------|---------|---------|
|
||||
| `prautoblogger_board_column_limit` | int | 20 | Max cards per board column |
|
||||
| `prautoblogger_ideas_per_page` | int | 30 | Ideas browser page size |
|
||||
| `prautoblogger_article_queue` | array | — | Checkpoint queue (run_id + ideas + results) |
|
||||
| `prautoblogger_checkpoint_run_id` | string | — | Current run UUID for Tick 2..N finalize path |
|
||||
| `prautoblogger_db_version` | string | — | Installed schema version (triggers dbDelta on mismatch) |
|
||||
|
||||
---
|
||||
|
||||
## WP-CLI Commands
|
||||
|
||||
| Command | Since | Description |
|
||||
|---------|-------|-------------|
|
||||
| `wp prautoblogger generate` | 0.21.0 | Queue article generation via chained-cron checkpoints |
|
||||
| `wp prautoblogger opik:eval` | 0.12.0 | Trigger Opik eval run |
|
||||
|
||||
---
|
||||
|
||||
*Last updated: v0.21.0 (2026-06-14)*
|
||||
249
assets/css/board.css
Normal file
249
assets/css/board.css
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* PRAutoBlogger — Kanban Board Styles
|
||||
*
|
||||
* Warm editorial style (Proposal C / Direction C).
|
||||
* Inside wp-admin chrome; uses WP admin vars where available.
|
||||
* No build step — plain CSS, no preprocessor.
|
||||
*
|
||||
* Palette:
|
||||
* Teal #1B8A92 (brand primary — Generating pill, spinner)
|
||||
* Cream #FAF6EE (card background)
|
||||
* Ink #0D0D0D (primary text)
|
||||
* Orange #FF8A3D (In Review pill)
|
||||
* Lime #7FD600 (Published pill — success)
|
||||
* Red #D94F4F (Failed pill)
|
||||
* Mid-grey #8A8A8A (meta text, border)
|
||||
*
|
||||
* @see templates/admin/board-page.php — Rendered HTML structure.
|
||||
* @see assets/js/board.js — DOM updates that match these selectors.
|
||||
*/
|
||||
|
||||
/* ── Page wrapper ──────────────────────────────────────────────────────── */
|
||||
.prab-board-wrap {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.prab-board-heading {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: #0d0d0d;
|
||||
margin: 20px 0 18px 0;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.prab-board-heading-sub {
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
color: #8a8a8a;
|
||||
}
|
||||
|
||||
/* ── Four-column board grid ────────────────────────────────────────────── */
|
||||
.prab-board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
padding: 0 20px 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.prab-board {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.prab-board {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Column ────────────────────────────────────────────────────────────── */
|
||||
.prab-board-column {
|
||||
background: #f8f7f5;
|
||||
border: 1px solid #e2ddd6;
|
||||
border-radius: 8px;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.prab-col-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px 10px;
|
||||
border-bottom: 1px solid #e2ddd6;
|
||||
}
|
||||
|
||||
.prab-col-title {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #4a4540;
|
||||
}
|
||||
|
||||
.prab-col-cards {
|
||||
padding: 10px 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.prab-col-empty {
|
||||
font-size: 0.82rem;
|
||||
color: #b0a898;
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Status pills ──────────────────────────────────────────────────────── */
|
||||
.prab-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
color: #fff;
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.prab-pill--generating {
|
||||
background: #1b8a92;
|
||||
}
|
||||
|
||||
.prab-pill--review {
|
||||
background: #ff8a3d;
|
||||
}
|
||||
|
||||
.prab-pill--published {
|
||||
background: #5a9e28;
|
||||
}
|
||||
|
||||
.prab-pill--failed {
|
||||
background: #d94f4f;
|
||||
}
|
||||
|
||||
/* ── Card ──────────────────────────────────────────────────────────────── */
|
||||
.prab-board-card {
|
||||
background: #faf6ee;
|
||||
border: 1px solid #e0d8cc;
|
||||
border-radius: 6px;
|
||||
padding: 11px 12px 10px;
|
||||
position: relative;
|
||||
transition: box-shadow 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.prab-board-card:hover {
|
||||
box-shadow: 0 2px 8px rgba(27, 138, 146, 0.12);
|
||||
border-color: #b8cfd2;
|
||||
}
|
||||
|
||||
.prab-board-card--generating {
|
||||
border-left: 3px solid #1b8a92;
|
||||
}
|
||||
|
||||
.prab-card-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #0d0d0d;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 6px;
|
||||
padding-right: 20px; /* room for spinner */
|
||||
}
|
||||
|
||||
.prab-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.prab-card-cost {
|
||||
font-size: 0.75rem;
|
||||
color: #5a8a50;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.prab-card-stage {
|
||||
font-size: 0.74rem;
|
||||
color: #1b8a92;
|
||||
font-weight: 500;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.prab-card-error {
|
||||
font-size: 0.74rem;
|
||||
color: #d94f4f;
|
||||
margin-bottom: 5px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.prab-card-actions {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.prab-card-link {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
color: #1b8a92;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
border: 1px solid #b8d8db;
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
|
||||
.prab-card-link:hover {
|
||||
background: #e8f4f5;
|
||||
color: #136870;
|
||||
border-color: #8ac4c9;
|
||||
}
|
||||
|
||||
/* ── Generating spinner ────────────────────────────────────────────────── */
|
||||
.prab-card-spinner {
|
||||
position: absolute;
|
||||
top: 11px;
|
||||
right: 11px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid #c8e6e8;
|
||||
border-top-color: #1b8a92;
|
||||
border-radius: 50%;
|
||||
animation: prab-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes prab-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ── Board footer ──────────────────────────────────────────────────────── */
|
||||
.prab-board-footer {
|
||||
padding: 0 20px 16px;
|
||||
font-size: 0.78rem;
|
||||
color: #a0998f;
|
||||
}
|
||||
|
||||
/* ── Poll-error banner ─────────────────────────────────────────────────── */
|
||||
.prab-board-error {
|
||||
background: #fff4f4;
|
||||
border: 1px solid #f5c4c4;
|
||||
color: #8b2020;
|
||||
border-radius: 6px;
|
||||
padding: 8px 14px;
|
||||
margin: 0 20px 12px;
|
||||
font-size: 0.82rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.prab-board-error.is-visible {
|
||||
display: block;
|
||||
}
|
||||
240
assets/css/dossier-edit.css
Normal file
240
assets/css/dossier-edit.css
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* PRAutoBlogger dossier — edit + re-run styles (M3, v0.20.0).
|
||||
* Extends dossier.css (kept separate: dossier.css predates the
|
||||
* 300-line discipline and is not grown further).
|
||||
*/
|
||||
|
||||
/* ── Audit chips (stale / human-edited / attempt) ─────────────────── */
|
||||
.prab-chip {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.6;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.prab-chip--stale {
|
||||
background: #fcf0e4;
|
||||
color: #8a4a0b;
|
||||
border: 1px solid #e8c9a8;
|
||||
}
|
||||
|
||||
.prab-chip--human {
|
||||
background: #e9f2f4;
|
||||
color: #1b5e6b;
|
||||
border: 1px solid #b8d8de;
|
||||
}
|
||||
|
||||
.prab-chip--attempt {
|
||||
background: #f0f0f1;
|
||||
color: #50575e;
|
||||
border: 1px solid #dcdcde;
|
||||
}
|
||||
|
||||
.prab-chip--chosen {
|
||||
background: #edf7ed;
|
||||
color: #1e6b2e;
|
||||
border: 1px solid #b9dfc0;
|
||||
}
|
||||
|
||||
.prab-chip--header {
|
||||
margin-left: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.prab-stage--stale {
|
||||
border-left: 3px solid #d98c2b;
|
||||
}
|
||||
|
||||
/* ── Stage footer actions ─────────────────────────────────────────── */
|
||||
.prab-stage-rerun-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed #e2dcd2;
|
||||
}
|
||||
|
||||
.prab-edit-unavailable {
|
||||
font-size: 12px;
|
||||
color: #777;
|
||||
font-style: italic;
|
||||
max-width: 46em;
|
||||
}
|
||||
|
||||
.prab-rerun-from--stale {
|
||||
border-color: #d98c2b;
|
||||
color: #8a4a0b;
|
||||
}
|
||||
|
||||
/* ── Edit panel ───────────────────────────────────────────────────── */
|
||||
.prab-edit-panel {
|
||||
margin-top: 12px;
|
||||
padding: 14px 16px;
|
||||
background: #fbf8f2;
|
||||
border: 1px solid #e2dcd2;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.prab-edit-copy {
|
||||
font-size: 13px;
|
||||
color: #50575e;
|
||||
max-width: 60em;
|
||||
}
|
||||
|
||||
.prab-edit-spend {
|
||||
font-size: 12px;
|
||||
color: #50575e;
|
||||
}
|
||||
|
||||
.prab-edit-spend--warn,
|
||||
.prab-spend-warning {
|
||||
color: #8a2a0b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prab-run-spend--warn {
|
||||
border-left: 3px solid #c9501f;
|
||||
}
|
||||
|
||||
.prab-edit-model {
|
||||
font-size: 12px;
|
||||
color: #777;
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
}
|
||||
|
||||
.prab-edit-message {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.prab-edit-role {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #8c8f94;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.prab-edit-textarea {
|
||||
width: 100%;
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.prab-edit-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.prab-edit-forkinfo {
|
||||
font-size: 12px;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.prab-edit-feedback {
|
||||
font-size: 12px;
|
||||
color: #1e6b2e;
|
||||
}
|
||||
|
||||
.prab-edit-feedback--error {
|
||||
color: #b32d2e;
|
||||
}
|
||||
|
||||
/* ── Image grid (F3) ──────────────────────────────────────────────── */
|
||||
.prab-image-grid {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.prab-image-card {
|
||||
margin: 0;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.prab-image-card img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e2dcd2;
|
||||
}
|
||||
|
||||
.prab-image-card--chosen img {
|
||||
border: 2px solid #1b8a92;
|
||||
}
|
||||
|
||||
.prab-image-card figcaption {
|
||||
font-size: 12px;
|
||||
color: #50575e;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ── Sidebar models card (F2) ─────────────────────────────────────── */
|
||||
.prab-models-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.prab-models-table th {
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
padding: 4px 6px 4px 0;
|
||||
vertical-align: top;
|
||||
color: #50575e;
|
||||
width: 38%;
|
||||
}
|
||||
|
||||
.prab-models-table td {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.prab-models-model {
|
||||
display: block;
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
font-size: 11px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.prab-models-meta {
|
||||
display: block;
|
||||
color: #8c8f94;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ── Board card human-modified dot ────────────────────────────────── */
|
||||
.prab-card-human-chip {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 9px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
background: #e9f2f4;
|
||||
color: #1b5e6b;
|
||||
border: 1px solid #b8d8de;
|
||||
}
|
||||
|
||||
/* ── Review queue stale warning ───────────────────────────────────── */
|
||||
.prab-review-stale-chip {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 9px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
background: #fcf0e4;
|
||||
color: #8a4a0b;
|
||||
border: 1px solid #e8c9a8;
|
||||
}
|
||||
441
assets/css/dossier.css
Normal file
441
assets/css/dossier.css
Normal file
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* PRAutoBlogger Article Dossier styles (M2).
|
||||
*
|
||||
* Design contract: Proposal C "Editorial Record" -- warm editorial,
|
||||
* generous whitespace, typographic hierarchy, verdict pills, receipt
|
||||
* sidebar. Scoped to .prab-dossier-wrap to avoid WP admin conflicts.
|
||||
*
|
||||
* @see admin/class-dossier-page.php
|
||||
* @see templates/admin/dossier-page.php
|
||||
*/
|
||||
|
||||
/* ── Layout ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.prab-dossier-wrap {
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
.prab-dossier-back {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.prab-dossier-back a {
|
||||
color: #1B8A92;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.prab-dossier-back a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.prab-dossier--empty {
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.prab-dossier-empty-notice h2 {
|
||||
font-size: 1.3em;
|
||||
margin: 0 0 12px;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.prab-dossier {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* ── Header ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.prab-dossier-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 24px 28px 20px;
|
||||
border-bottom: 1px solid #e8e0d8;
|
||||
background: #FAF6EE;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
.prab-dossier-title {
|
||||
font-size: 1.6em;
|
||||
margin: 0 0 8px;
|
||||
color: #1a1a1a;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.prab-dossier-header-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── Body: two-column ─────────────────────────────────────────────────── */
|
||||
|
||||
.prab-dossier-body {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.prab-dossier-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 24px 28px;
|
||||
}
|
||||
|
||||
.prab-dossier-sidebar {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
padding: 24px 20px;
|
||||
border-left: 1px solid #e8e0d8;
|
||||
}
|
||||
|
||||
/* ── Verdict pills ────────────────────────────────────────────────────── */
|
||||
|
||||
.prab-verdict-pill {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.prab-verdict-pill--approved { background: #e6f5ee; color: #1a7a45; }
|
||||
.prab-verdict-pill--revised { background: #fff4e0; color: #a06000; }
|
||||
.prab-verdict-pill--rejected { background: #fde8e8; color: #b91c1c; }
|
||||
.prab-verdict-pill--halted { background: #f0f0f0; color: #555; }
|
||||
.prab-verdict-pill--pending { background: #e8f0fe; color: #1a55a0; }
|
||||
|
||||
/* ── Status pills ─────────────────────────────────────────────────────── */
|
||||
|
||||
.prab-dossier-tier {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.prab-dossier-status {
|
||||
font-size: 12px;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
/* ── Stage sections ───────────────────────────────────────────────────── */
|
||||
|
||||
.prab-dossier-section {
|
||||
margin-bottom: 28px;
|
||||
border: 1px solid #e5ddd5;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.prab-dossier-section--stage {
|
||||
border-left: 4px solid #ccc;
|
||||
}
|
||||
|
||||
.prab-stage-status--done { border-left-color: #1B8A92; }
|
||||
.prab-stage-status--running { border-left-color: #FF8A3D; }
|
||||
.prab-stage-status--failed { border-left-color: #e53e3e; }
|
||||
.prab-stage-status--pending { border-left-color: #a0aec0; }
|
||||
|
||||
.prab-stage-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: #faf8f5;
|
||||
border-bottom: 1px solid #ede6dd;
|
||||
}
|
||||
|
||||
.prab-section-title {
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: #2d2d2d;
|
||||
}
|
||||
|
||||
.prab-stage-role {
|
||||
font-weight: 400;
|
||||
color: #888;
|
||||
font-size: 0.9em;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.prab-stage-header-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.prab-stage-status-pill {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prab-stage-status-pill--done { background: #e6f5ee; color: #1a7a45; }
|
||||
.prab-stage-status-pill--running { background: #fff4e0; color: #a06000; }
|
||||
.prab-stage-status-pill--failed { background: #fde8e8; color: #b91c1c; }
|
||||
.prab-stage-status-pill--pending { background: #f0f0f0; color: #555; }
|
||||
|
||||
.prab-stage-cost {
|
||||
color: #1B8A92;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prab-stage-pv {
|
||||
background: #f0f0f0;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ── Stage output ─────────────────────────────────────────────────────── */
|
||||
|
||||
.prab-stage-output {
|
||||
padding: 16px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.prab-stage-output-rendered {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
max-height: 480px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.prab-output-pruned,
|
||||
.prab-output-absent {
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Raw trace toggle ─────────────────────────────────────────────────── */
|
||||
|
||||
.prab-stage-trace {
|
||||
padding: 0 16px 12px;
|
||||
border-top: 1px solid #f0ece6;
|
||||
}
|
||||
|
||||
.prab-trace-toggle {
|
||||
font-size: 12px;
|
||||
color: #1B8A92;
|
||||
cursor: pointer;
|
||||
padding: 6px 0;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.prab-trace-toggle:hover {
|
||||
color: #0d6066;
|
||||
}
|
||||
|
||||
.prab-trace-detail {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.prab-trace-entry {
|
||||
background: #f7f4ef;
|
||||
border: 1px solid #e5ddd5;
|
||||
border-radius: 4px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.prab-trace-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
margin-bottom: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.prab-trace-status--success { color: #1a7a45; }
|
||||
.prab-trace-status--error { color: #b91c1c; }
|
||||
|
||||
.prab-trace-request summary {
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: #888;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.prab-trace-pre {
|
||||
background: #1a1a1a;
|
||||
color: #e2e8f0;
|
||||
padding: 10px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
overflow-x: auto;
|
||||
max-height: 300px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.prab-trace-error {
|
||||
color: #b91c1c;
|
||||
font-size: 12px;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
/* ── Stage timestamps ─────────────────────────────────────────────────── */
|
||||
|
||||
.prab-stage-timestamps {
|
||||
padding: 4px 16px 10px;
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.prab-ts-sep {
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* ── Decisions section ───────────────────────────────────────────────── */
|
||||
|
||||
.prab-dossier-section--decisions {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.prab-dossier-section--decisions .prab-section-title {
|
||||
font-size: 1.1em;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.prab-decision-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f0ece6;
|
||||
}
|
||||
|
||||
.prab-decision-row:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.prab-decision-stage {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.prab-decision-rationale {
|
||||
margin: 4px 0 0;
|
||||
width: 100%;
|
||||
color: #555;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── Sidebar ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.prab-sidebar-card {
|
||||
background: #faf8f5;
|
||||
border: 1px solid #e5ddd5;
|
||||
border-radius: 4px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.prab-sidebar-heading {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #888;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.prab-sidebar-dl {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.prab-sidebar-dl dt {
|
||||
color: #888;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.prab-sidebar-dl dd {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.prab-run-id {
|
||||
font-family: monospace;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* ── Cost receipt ─────────────────────────────────────────────────────── */
|
||||
|
||||
.prab-receipt-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.prab-receipt-table td {
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
.prab-receipt-stage {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.prab-receipt-cost {
|
||||
text-align: right;
|
||||
color: #1B8A92;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.prab-receipt-total {
|
||||
border-top: 1px solid #ddd;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.prab-receipt-total td {
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
/* ── Run status ───────────────────────────────────────────────────────── */
|
||||
|
||||
.prab-run-status {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.prab-run-status--done { color: #1a7a45; }
|
||||
.prab-run-status--running { color: #a06000; }
|
||||
.prab-run-status--failed { color: #b91c1c; }
|
||||
|
||||
/* ── No stages ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.prab-dossier-no-stages {
|
||||
padding: 24px 0;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* ── Metabox ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.prab-metabox-dossier .prab-run-id-label {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}
|
||||
53
assets/js/board-generate.js
Normal file
53
assets/js/board-generate.js
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* PRAutoBlogger Board -- "New Article" generate button handler (v0.21.0, M4).
|
||||
*
|
||||
* Loaded on the board page alongside board.js. Depends on the prabBoard config
|
||||
* object and jQuery, both already present when board.js loads.
|
||||
*
|
||||
* @see assets/js/board.js -- Board poller (loads first).
|
||||
* @see includes/admin/class-board-page.php -- Enqueues + localizes prabBoard.
|
||||
*/
|
||||
( function ( $ ) {
|
||||
'use strict';
|
||||
|
||||
var cfg = window.prabBoard || {};
|
||||
|
||||
/**
|
||||
* Handle click on the "New Article" button. Fires the existing generate_now
|
||||
* AJAX action and triggers board backoff reset so the new card appears fast.
|
||||
*/
|
||||
function onBoardGenerateNow() {
|
||||
var $btn = $( '#prab-board-generate-now' );
|
||||
if ( ! $btn.length || $btn.prop( 'disabled' ) ) {
|
||||
return;
|
||||
}
|
||||
$btn.prop( 'disabled', true ).text( cfg.strings && cfg.strings.generatingBtn || 'Generating\u2026' );
|
||||
var $status = $( '#prab-board-gen-status' );
|
||||
$status.text( '' ).hide();
|
||||
|
||||
$.ajax( {
|
||||
url: cfg.ajaxUrl,
|
||||
method: 'POST',
|
||||
data: { action: 'prautoblogger_generate_now', nonce: cfg.generateNonce },
|
||||
success: function ( response ) {
|
||||
if ( response && response.success ) {
|
||||
$status.text( cfg.strings && cfg.strings.genStarted || 'Generation started.' ).show();
|
||||
// Notify board poller to reset backoff (run is now active).
|
||||
$( document ).trigger( 'prab:generation-started' );
|
||||
} else {
|
||||
$status.text( cfg.strings && cfg.strings.genError || 'Generation failed.' ).show();
|
||||
$btn.prop( 'disabled', false ).text( cfg.strings && cfg.strings.newArticle || 'New Article' );
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
$status.text( cfg.strings && cfg.strings.genError || 'Generation failed.' ).show();
|
||||
$btn.prop( 'disabled', false ).text( cfg.strings && cfg.strings.newArticle || 'New Article' );
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
$( function () {
|
||||
$( document ).on( 'click', '#prab-board-generate-now', onBoardGenerateNow );
|
||||
} );
|
||||
|
||||
}( jQuery ) );
|
||||
236
assets/js/board.js
Normal file
236
assets/js/board.js
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* PRAutoBlogger — Kanban Board Poller
|
||||
*
|
||||
* Live-polls the board status AJAX endpoint and updates the four columns
|
||||
* (Generating | In Review | Published | Failed) in-place without a full
|
||||
* page reload.
|
||||
*
|
||||
* Poll behaviour:
|
||||
* - Polls at `prabBoard.pollInterval` ms when any article is generating.
|
||||
* - Doubles the interval (backoff) when `has_active_runs` is false, capping
|
||||
* at 4× the base interval — prevents needless AJAX on an idle board.
|
||||
* - Resets to base interval as soon as an active run is detected again.
|
||||
*
|
||||
* Click-throughs (M1):
|
||||
* - Generating → Activity Log
|
||||
* - In Review → Review Queue
|
||||
* - Published → Post edit screen
|
||||
* - Failed → Activity Log
|
||||
* (M2 rewires all to Article Dossier)
|
||||
*
|
||||
* Localised config: `prabBoard` object (ajaxUrl, nonce, pollInterval, action,
|
||||
* strings) injected by PRAutoBlogger_Board_Page::on_enqueue_assets().
|
||||
*
|
||||
* @see includes/admin/class-board-page.php — AJAX handler + localise.
|
||||
* @see assets/css/board.css — Card and column styles.
|
||||
* @see templates/admin/board-page.php — Initial server-rendered HTML.
|
||||
*/
|
||||
( function ( $ ) {
|
||||
'use strict';
|
||||
|
||||
var cfg = window.prabBoard || {};
|
||||
var baseInterval = cfg.pollInterval || 5000;
|
||||
var maxBackoff = 4;
|
||||
var backoffFactor = 1;
|
||||
var pollTimer = null;
|
||||
var $board = null;
|
||||
var $errorBanner = null;
|
||||
|
||||
/** Escape HTML for safe DOM insertion. */
|
||||
function escHtml( str ) {
|
||||
return $( '<div>' ).text( String( str ) ).html();
|
||||
}
|
||||
|
||||
/** Format cost to 4 decimal places with a leading $. */
|
||||
function formatCost( cost ) {
|
||||
var n = parseFloat( cost ) || 0;
|
||||
return '$' + n.toFixed( 4 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a card HTML string from a card data object.
|
||||
*
|
||||
* @param {Object} card Card data from server.
|
||||
* @param {string} columnKey Column identifier.
|
||||
* @returns {string} HTML string.
|
||||
*/
|
||||
function buildCardHtml( card, columnKey ) {
|
||||
var isGenerating = ( columnKey === 'generating' );
|
||||
var cls = 'prab-board-card' + ( isGenerating ? ' prab-board-card--generating' : '' );
|
||||
var html = '';
|
||||
|
||||
html += '<div class="' + escHtml( cls ) + '"';
|
||||
html += ' data-run-id="' + escHtml( card.run_id || '' ) + '"';
|
||||
html += ' data-post-id="' + escHtml( card.post_id || 0 ) + '">';
|
||||
|
||||
if ( isGenerating ) {
|
||||
html += '<div class="prab-card-spinner" aria-label="' + escHtml( cfg.strings.generating ) + '"></div>';
|
||||
}
|
||||
|
||||
html += '<div class="prab-card-title">' + escHtml( card.title || cfg.strings.empty ) + '</div>';
|
||||
|
||||
// v0.20.0: human-modified runs are visually distinct at run-list level.
|
||||
if ( card.human_modified ) {
|
||||
html += '<span class="prab-card-human-chip">' + escHtml( cfg.strings.humanModified || 'Human-modified' ) + '</span>';
|
||||
}
|
||||
|
||||
if ( card.cost_total ) {
|
||||
html += '<div class="prab-card-meta"><span class="prab-card-cost">';
|
||||
html += escHtml( formatCost( card.cost_total ) );
|
||||
html += '</span></div>';
|
||||
}
|
||||
|
||||
if ( isGenerating && card.stage_current ) {
|
||||
html += '<div class="prab-card-stage">';
|
||||
/* translators: stage name */
|
||||
html += escHtml( cfg.strings.generating + ': ' + card.stage_current );
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
if ( card.error_message ) {
|
||||
var errShort = String( card.error_message ).split( ' ' ).slice( 0, 12 ).join( ' ' );
|
||||
if ( card.error_message.split( ' ' ).length > 12 ) {
|
||||
errShort += '…';
|
||||
}
|
||||
html += '<div class="prab-card-error">' + escHtml( errShort ) + '</div>';
|
||||
}
|
||||
|
||||
// Click-through link.
|
||||
var linkUrl = '';
|
||||
var linkText = '';
|
||||
if ( card.click_action === 'dossier' && card.dossier_url ) {
|
||||
linkUrl = card.dossier_url;
|
||||
linkText = cfg.strings.view;
|
||||
} else if ( card.click_action === 'review' && card.review_url ) {
|
||||
linkUrl = card.review_url;
|
||||
linkText = cfg.strings.view;
|
||||
} else if ( card.click_action === 'edit' && card.edit_url ) {
|
||||
linkUrl = card.edit_url;
|
||||
linkText = cfg.strings.edit;
|
||||
} else if ( card.click_action === 'logs' && card.log_url ) {
|
||||
linkUrl = card.log_url;
|
||||
linkText = cfg.strings.viewLog;
|
||||
}
|
||||
|
||||
if ( linkUrl ) {
|
||||
html += '<div class="prab-card-actions">';
|
||||
html += '<a href="' + escHtml( linkUrl ) + '" class="prab-card-link">';
|
||||
html += escHtml( linkText );
|
||||
html += '</a></div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-render a single column from the server snapshot.
|
||||
*
|
||||
* Updates the pill count and replaces card HTML. Avoids full DOM teardown
|
||||
* to prevent flicker on the running spinner.
|
||||
*
|
||||
* @param {string} colKey Column identifier.
|
||||
* @param {Array} cards Array of card objects.
|
||||
* @returns {void}
|
||||
*/
|
||||
function renderColumn( colKey, cards ) {
|
||||
var $col = $board.find( '[data-column="' + colKey + '"]' );
|
||||
var $cards = $col.find( '[data-column-cards]' );
|
||||
var $pill = $col.find( '.prab-col-count' );
|
||||
|
||||
$pill.text( cards.length );
|
||||
|
||||
if ( ! cards.length ) {
|
||||
$cards.html( '<p class="prab-col-empty">' + escHtml( cfg.strings.empty ) + '</p>' );
|
||||
return;
|
||||
}
|
||||
|
||||
// Build new card HTML.
|
||||
var newHtml = '';
|
||||
for ( var i = 0; i < cards.length; i++ ) {
|
||||
newHtml += buildCardHtml( cards[ i ], colKey );
|
||||
}
|
||||
$cards.html( newHtml );
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a server snapshot to all four columns.
|
||||
*
|
||||
* @param {Object} snapshot Board snapshot from AJAX.
|
||||
* @returns {void}
|
||||
*/
|
||||
function applySnapshot( snapshot ) {
|
||||
renderColumn( 'generating', snapshot.generating || [] );
|
||||
renderColumn( 'in_review', snapshot.in_review || [] );
|
||||
renderColumn( 'published', snapshot.published || [] );
|
||||
renderColumn( 'failed', snapshot.failed || [] );
|
||||
// Re-enable board Generate button once no active runs remain.
|
||||
if ( ! snapshot.has_active_runs ) {
|
||||
$( '#prab-board-generate-now' ).prop( 'disabled', false )
|
||||
.text( ( cfg.strings && cfg.strings.newArticle ) || 'New Article' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the next poll delay based on whether there are active runs.
|
||||
*
|
||||
* @param {boolean} hasActiveRuns
|
||||
* @returns {number} Milliseconds until next poll.
|
||||
*/
|
||||
function nextDelay( hasActiveRuns ) {
|
||||
if ( hasActiveRuns ) {
|
||||
backoffFactor = 1;
|
||||
return baseInterval;
|
||||
}
|
||||
backoffFactor = Math.min( backoffFactor + 1, maxBackoff );
|
||||
return baseInterval * backoffFactor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule the next poll after a given delay.
|
||||
*
|
||||
* @param {number} delay Milliseconds.
|
||||
* @returns {void}
|
||||
*/
|
||||
function schedulePoll( delay ) {
|
||||
clearTimeout( pollTimer );
|
||||
pollTimer = setTimeout( doPoll, delay );
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute one board poll: AJAX POST → apply snapshot → reschedule.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function doPoll() {
|
||||
$.ajax( {
|
||||
url: cfg.ajaxUrl,
|
||||
method: 'POST',
|
||||
data: {
|
||||
action: cfg.action,
|
||||
nonce: cfg.nonce
|
||||
},
|
||||
success: function ( response ) {
|
||||
if ( $errorBanner ) {
|
||||
$errorBanner.removeClass( 'is-visible' );
|
||||
}
|
||||
if ( response && response.success && response.data ) {
|
||||
applySnapshot( response.data );
|
||||
schedulePoll( nextDelay( !! response.data.has_active_runs ) );
|
||||
} else {
|
||||
schedulePoll( nextDelay( false ) );
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
if ( $errorBanner ) {
|
||||
$errorBanner.addClass( 'is-visible' );
|
||||
}
|
||||
schedulePoll( nextDelay( false ) );
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
|
||||
} );
|
||||
|
||||
}( jQuery ) );
|
||||
235
assets/js/dossier-edit.js
Normal file
235
assets/js/dossier-edit.js
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* PRAutoBlogger Article Dossier — edit + re-run actions (M3, v0.20.0).
|
||||
*
|
||||
* Chained-cron UX contract (CPO hard constraint): clicking a re-run
|
||||
* button QUEUES a background job — the UI says "queued", starts the
|
||||
* stage-status poll, and renders pickup/result as the rows change.
|
||||
* Nothing here implies synchronous execution.
|
||||
*
|
||||
* Vanilla JS, no build step. Config injected via wp_localize_script
|
||||
* ('prabDossierEdit': ajaxUrl, nonce, postId, pollInterval, strings).
|
||||
*
|
||||
* @see admin/class-dossier-actions.php — The endpoints this calls.
|
||||
* @see admin/class-dossier-page.php — Enqueue + localization.
|
||||
*/
|
||||
( function () {
|
||||
'use strict';
|
||||
|
||||
var cfg = window.prabDossierEdit || null;
|
||||
|
||||
function post( action, fields ) {
|
||||
var data = new window.FormData();
|
||||
data.append( 'action', action );
|
||||
data.append( 'nonce', cfg.nonce );
|
||||
data.append( 'post_id', cfg.postId );
|
||||
Object.keys( fields || {} ).forEach( function ( key ) {
|
||||
data.append( key, fields[ key ] );
|
||||
} );
|
||||
return window.fetch( cfg.ajaxUrl, { method: 'POST', credentials: 'same-origin', body: data } )
|
||||
.then( function ( res ) { return res.json(); } );
|
||||
}
|
||||
|
||||
function feedback( panel, message, isError ) {
|
||||
var el = panel.querySelector( '[data-feedback]' );
|
||||
if ( el ) {
|
||||
el.textContent = message || '';
|
||||
el.classList.toggle( 'prab-edit-feedback--error', !! isError );
|
||||
}
|
||||
}
|
||||
|
||||
function sectionFor( el ) {
|
||||
var node = el;
|
||||
while ( node && ! node.classList.contains( 'prab-dossier-section--stage' ) ) {
|
||||
node = node.parentElement;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
// ── Stage-status polling (queued → pickup → result) ────────────────
|
||||
var pollTimer = null;
|
||||
var sawActivity = false;
|
||||
|
||||
function applyStageState( state ) {
|
||||
var selector = '.prab-dossier-section--stage[data-stage="' + state.stage + '"][data-agent-role="' + state.agent_role + '"]';
|
||||
var section = document.querySelector( selector );
|
||||
if ( ! section ) {
|
||||
return;
|
||||
}
|
||||
var pill = section.querySelector( '.prab-stage-status-pill' );
|
||||
if ( pill ) {
|
||||
pill.className = 'prab-stage-status-pill prab-stage-status-pill--' + state.status;
|
||||
pill.textContent = state.status.charAt( 0 ).toUpperCase() + state.status.slice( 1 );
|
||||
}
|
||||
var staleChip = section.querySelector( '[data-chip="stale"]' );
|
||||
if ( staleChip ) {
|
||||
staleChip.hidden = ! state.stale;
|
||||
}
|
||||
section.classList.toggle( 'prab-stage--stale', !! state.stale );
|
||||
var humanChip = section.querySelector( '[data-chip="human"]' );
|
||||
if ( humanChip ) {
|
||||
humanChip.hidden = ! state.human_modified;
|
||||
}
|
||||
}
|
||||
|
||||
function poll() {
|
||||
post( 'prautoblogger_dossier_stage_status', {} ).then( function ( res ) {
|
||||
if ( ! res || ! res.success || ! res.data ) {
|
||||
return;
|
||||
}
|
||||
var anyRunning = false;
|
||||
var anyStale = false;
|
||||
var anyHuman = false;
|
||||
( res.data.stages || [] ).forEach( function ( state ) {
|
||||
applyStageState( state );
|
||||
if ( 'running' === state.status || 'pending' === state.status ) {
|
||||
anyRunning = true;
|
||||
}
|
||||
anyStale = anyStale || !! state.stale;
|
||||
anyHuman = anyHuman || !! state.human_modified;
|
||||
} );
|
||||
var headerHuman = document.querySelector( '[data-header-chip="human"]' );
|
||||
if ( headerHuman ) {
|
||||
headerHuman.hidden = ! anyHuman;
|
||||
}
|
||||
var headerStale = document.querySelector( '[data-header-chip="stale"]' );
|
||||
if ( headerStale ) {
|
||||
headerStale.hidden = ! anyStale;
|
||||
}
|
||||
|
||||
var terminal = -1 === [ 'running', 'pending' ].indexOf( res.data.run_status );
|
||||
if ( anyRunning || ! terminal ) {
|
||||
sawActivity = true;
|
||||
return;
|
||||
}
|
||||
// Run settled after activity we watched: re-render the dossier
|
||||
// fresh (server-rendered outputs, receipts, fork info).
|
||||
if ( sawActivity ) {
|
||||
window.clearInterval( pollTimer );
|
||||
pollTimer = null;
|
||||
window.location.reload();
|
||||
}
|
||||
} ).catch( function () { /* transient poll errors are ignored */ } );
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
sawActivity = true;
|
||||
if ( ! pollTimer ) {
|
||||
pollTimer = window.setInterval( poll, cfg.pollInterval * 1000 );
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wiring ──────────────────────────────────────────────────────────
|
||||
document.addEventListener( 'DOMContentLoaded', function () {
|
||||
if ( ! cfg ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Edit panel toggles.
|
||||
document.querySelectorAll( '.prab-edit-toggle' ).forEach( function ( btn ) {
|
||||
btn.addEventListener( 'click', function () {
|
||||
var panel = document.getElementById( btn.getAttribute( 'aria-controls' ) );
|
||||
if ( ! panel ) {
|
||||
return;
|
||||
}
|
||||
var open = btn.getAttribute( 'aria-expanded' ) === 'true';
|
||||
panel.hidden = open;
|
||||
btn.setAttribute( 'aria-expanded', open ? 'false' : 'true' );
|
||||
} );
|
||||
} );
|
||||
|
||||
// Save fork.
|
||||
document.querySelectorAll( '.prab-edit-save' ).forEach( function ( btn ) {
|
||||
btn.addEventListener( 'click', function () {
|
||||
var panel = btn.closest( '.prab-edit-panel' );
|
||||
var messages = [];
|
||||
panel.querySelectorAll( '.prab-edit-textarea' ).forEach( function ( area ) {
|
||||
messages.push( { role: area.getAttribute( 'data-role' ), content: area.value } );
|
||||
} );
|
||||
btn.disabled = true;
|
||||
feedback( panel, cfg.strings.saving, false );
|
||||
post( 'prautoblogger_dossier_save_input', {
|
||||
stage: panel.getAttribute( 'data-stage' ),
|
||||
agent_role: panel.getAttribute( 'data-agent-role' ),
|
||||
messages: JSON.stringify( messages )
|
||||
} ).then( function ( res ) {
|
||||
btn.disabled = false;
|
||||
if ( res && res.success ) {
|
||||
feedback( panel, res.data.message, false );
|
||||
var rerunBtn = panel.querySelector( '.prab-edit-rerun' );
|
||||
if ( rerunBtn ) {
|
||||
rerunBtn.disabled = false;
|
||||
}
|
||||
var info = panel.querySelector( '[data-forkinfo]' );
|
||||
if ( info && res.data.version ) {
|
||||
info.textContent = 'v' + res.data.version;
|
||||
}
|
||||
} else {
|
||||
feedback( panel, ( res && res.data && res.data.message ) || cfg.strings.error, true );
|
||||
}
|
||||
} ).catch( function () {
|
||||
btn.disabled = false;
|
||||
feedback( panel, cfg.strings.error, true );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
|
||||
// Replay latest fork (queued).
|
||||
document.querySelectorAll( '.prab-edit-rerun' ).forEach( function ( btn ) {
|
||||
btn.addEventListener( 'click', function () {
|
||||
var panel = btn.closest( '.prab-edit-panel' );
|
||||
if ( ! window.confirm( cfg.strings.confirmReplay ) ) {
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
post( 'prautoblogger_dossier_rerun_stage', {
|
||||
stage: panel.getAttribute( 'data-stage' ),
|
||||
agent_role: panel.getAttribute( 'data-agent-role' )
|
||||
} ).then( function ( res ) {
|
||||
if ( res && res.success ) {
|
||||
feedback( panel, res.data.message, false );
|
||||
startPolling();
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
feedback( panel, ( res && res.data && res.data.message ) || cfg.strings.error, true );
|
||||
}
|
||||
} ).catch( function () {
|
||||
btn.disabled = false;
|
||||
feedback( panel, cfg.strings.error, true );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
|
||||
// Re-run from here (queued rebuild).
|
||||
document.querySelectorAll( '.prab-rerun-from' ).forEach( function ( btn ) {
|
||||
btn.addEventListener( 'click', function () {
|
||||
var section = sectionFor( btn );
|
||||
if ( ! window.confirm( cfg.strings.confirmRerunFrom ) ) {
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
btn.textContent = cfg.strings.queued;
|
||||
post( 'prautoblogger_dossier_rerun_from', {
|
||||
stage: btn.getAttribute( 'data-stage' ),
|
||||
agent_role: section ? section.getAttribute( 'data-agent-role' ) : ''
|
||||
} ).then( function ( res ) {
|
||||
if ( res && res.success ) {
|
||||
startPolling();
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
btn.textContent = cfg.strings.rerunFromLabel;
|
||||
window.alert( ( res && res.data && res.data.message ) || cfg.strings.error );
|
||||
}
|
||||
} ).catch( function () {
|
||||
btn.disabled = false;
|
||||
btn.textContent = cfg.strings.rerunFromLabel;
|
||||
window.alert( cfg.strings.error );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
|
||||
// A stage already running (e.g. revisit mid-job): poll from load.
|
||||
if ( document.querySelector( '.prab-stage-status-pill--running' ) ) {
|
||||
startPolling();
|
||||
}
|
||||
} );
|
||||
}() );
|
||||
41
assets/js/dossier.js
Normal file
41
assets/js/dossier.js
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* PRAutoBlogger Article Dossier -- raw-trace toggle (M2).
|
||||
*
|
||||
* Toggles the hidden raw-trace <div> per stage when the "Show raw trace"
|
||||
* button is clicked. Vanilla JS, no build step, no external deps.
|
||||
*
|
||||
* Accessible: uses aria-expanded + aria-controls; respects the hidden attribute.
|
||||
*
|
||||
* @see admin/class-dossier-page.php -- Enqueues this file.
|
||||
* @see templates/admin/dossier-page.php -- Renders the toggle buttons.
|
||||
*/
|
||||
( function () {
|
||||
'use strict';
|
||||
|
||||
document.addEventListener( 'DOMContentLoaded', function () {
|
||||
var toggles = document.querySelectorAll( '.prab-trace-toggle' );
|
||||
|
||||
toggles.forEach( function ( btn ) {
|
||||
btn.addEventListener( 'click', function () {
|
||||
var targetId = btn.getAttribute( 'aria-controls' );
|
||||
if ( ! targetId ) {
|
||||
return;
|
||||
}
|
||||
var panel = document.getElementById( targetId );
|
||||
if ( ! panel ) {
|
||||
return;
|
||||
}
|
||||
var isOpen = btn.getAttribute( 'aria-expanded' ) === 'true';
|
||||
if ( isOpen ) {
|
||||
panel.hidden = true;
|
||||
btn.setAttribute( 'aria-expanded', 'false' );
|
||||
btn.textContent = btn.textContent.replace( /hide/i, 'Show' );
|
||||
} else {
|
||||
panel.hidden = false;
|
||||
btn.setAttribute( 'aria-expanded', 'true' );
|
||||
btn.textContent = btn.textContent.replace( /show/i, 'Hide' );
|
||||
}
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
}() );
|
||||
@@ -1,23 +1,26 @@
|
||||
{
|
||||
"name": "peptiderepo/prautoblogger",
|
||||
"description": "WordPress plugin that auto-researches and publishes blog content using AI.",
|
||||
"type": "wordpress-plugin",
|
||||
"license": "GPL-2.0-or-later",
|
||||
"require": {
|
||||
"php": ">=7.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9.5",
|
||||
"brain/monkey": "^2.6"
|
||||
},
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"includes/"
|
||||
]
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"PRAutoBlogger\\Tests\\": "tests/unit/"
|
||||
"name": "peptiderepo/prautoblogger",
|
||||
"description": "WordPress plugin that auto-researches and publishes blog content using AI.",
|
||||
"type": "wordpress-plugin",
|
||||
"license": "GPL-2.0-or-later",
|
||||
"require": {
|
||||
"php": ">=7.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9.5",
|
||||
"brain/monkey": "^2.6"
|
||||
},
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"includes/"
|
||||
]
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"PRAutoBlogger\\Tests\\": "tests/unit/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vendor/bin/phpunit --testdox"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
81
includes/admin/class-admin-page.php
Executable file → Normal file
81
includes/admin/class-admin-page.php
Executable file → Normal file
@@ -62,6 +62,7 @@ class PRAutoBlogger_Admin_Page {
|
||||
public function on_enqueue_assets( string $hook_suffix ): void {
|
||||
$pages = array(
|
||||
'toplevel_page_' . self::PAGE_SLUG,
|
||||
'prautoblogger_page_prautoblogger-board',
|
||||
'prautoblogger_page_prautoblogger-metrics',
|
||||
'prautoblogger_page_prautoblogger-review-queue',
|
||||
'prautoblogger_page_prautoblogger-ideas',
|
||||
@@ -269,84 +270,14 @@ class PRAutoBlogger_Admin_Page {
|
||||
/**
|
||||
* Sanitize a settings field value.
|
||||
*
|
||||
* Delegates to PRAutoBlogger_Settings_Sanitizer (extracted for 300-line compliance).
|
||||
*
|
||||
* @param mixed $value The submitted value.
|
||||
* @return mixed Sanitized value.
|
||||
*
|
||||
* @see admin/class-settings-sanitizer.php
|
||||
*/
|
||||
public function sanitize_field( $value ) {
|
||||
$option_name = '';
|
||||
$filter = current_filter();
|
||||
if ( 0 === strpos( $filter, 'sanitize_option_' ) ) {
|
||||
$option_name = substr( $filter, strlen( 'sanitize_option_' ) );
|
||||
}
|
||||
|
||||
$encrypted = array( 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json', 'prautoblogger_runware_api_key' );
|
||||
if ( in_array( $option_name, $encrypted, true ) ) {
|
||||
// Empty value means password field wasn't touched — keep existing.
|
||||
if ( '' === $value ) {
|
||||
return get_option( $option_name, '' );
|
||||
}
|
||||
|
||||
// Already encrypted (has "enc:" prefix) — return as-is.
|
||||
// This is the primary defence against double-encryption:
|
||||
// PRAutoBlogger_Encryption::encrypt() also checks for this prefix,
|
||||
// so even if this callback is called multiple times, the value
|
||||
// is encrypted exactly once and never re-encrypted.
|
||||
if ( PRAutoBlogger_Encryption::is_encrypted( $value ) ) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
// New plaintext value — encrypt it (adds "enc:" prefix).
|
||||
return PRAutoBlogger_Encryption::encrypt( sanitize_text_field( $value ) );
|
||||
}
|
||||
|
||||
$json_fields = array( 'prautoblogger_target_subreddits', 'prautoblogger_topic_exclusions', 'prautoblogger_enabled_sources' );
|
||||
if ( in_array( $option_name, $json_fields, true ) ) {
|
||||
// PHP array from checkboxes — sanitize each item and re-encode.
|
||||
if ( is_array( $value ) ) {
|
||||
return wp_json_encode( array_values( array_map( 'sanitize_text_field', $value ) ) );
|
||||
}
|
||||
|
||||
$trimmed = trim( (string) $value );
|
||||
|
||||
// Already JSON-encoded array — decode, sanitize each item, re-encode.
|
||||
// This prevents sanitize_text_field() from mangling the JSON string,
|
||||
// which caused json_decode() on read to return null → empty subreddits.
|
||||
if ( '[' === substr( $trimmed, 0, 1 ) ) {
|
||||
$decoded = json_decode( $trimmed, true );
|
||||
if ( is_array( $decoded ) ) {
|
||||
return wp_json_encode( array_values( array_map( 'sanitize_text_field', $decoded ) ) );
|
||||
}
|
||||
}
|
||||
|
||||
// Comma-separated plain text — split, sanitize, encode as JSON array.
|
||||
$items = array_filter( array_map( 'trim', explode( ',', $trimmed ) ) );
|
||||
return wp_json_encode( array_values( array_map( 'sanitize_text_field', $items ) ) );
|
||||
}
|
||||
|
||||
$numeric = array( 'prautoblogger_daily_article_target', 'prautoblogger_monthly_budget_usd', 'prautoblogger_min_word_count', 'prautoblogger_max_word_count', 'prautoblogger_default_author', 'prautoblogger_default_category', 'prautoblogger_pullpush_cache_ttl', 'prautoblogger_reddit_posts_per_subreddit', 'prautoblogger_reasoning_max_tokens' );
|
||||
if ( in_array( $option_name, $numeric, true ) ) {
|
||||
return is_numeric( $value ) ? $value : 0;
|
||||
}
|
||||
if ( 'prautoblogger_image_model' === $option_name ) {
|
||||
return $this->sanitize_image_model( (string) $value );
|
||||
}
|
||||
if ( 'prautoblogger_image_style_template' === $option_name ) {
|
||||
// Multi-line template: validation + textarea-safe sanitisation
|
||||
// (incl. the single-token check, brief A5) lives in the filler.
|
||||
return PRAutoBlogger_Image_Template_Filler::sanitize_for_save( (string) $value );
|
||||
}
|
||||
return sanitize_text_field( (string) $value );
|
||||
}
|
||||
|
||||
/** Validate model id against the registry and persist the derived provider. */
|
||||
private function sanitize_image_model( string $value ): string {
|
||||
$candidate = sanitize_text_field( $value );
|
||||
$provider = PRAutoBlogger_Image_Model_Registry::provider_for( $candidate );
|
||||
if ( '' !== $provider ) {
|
||||
update_option( 'prautoblogger_image_provider', $provider );
|
||||
return $candidate;
|
||||
}
|
||||
add_settings_error( 'prautoblogger_image_model', 'prautoblogger_image_model_unknown', sprintf( esc_html__( 'Image model "%s" is not in the registry. Keeping the previous selection.', 'prautoblogger' ), esc_html( $candidate ) ) );
|
||||
return (string) get_option( 'prautoblogger_image_model', PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL );
|
||||
return PRAutoBlogger_Settings_Sanitizer::sanitize_field( $value );
|
||||
}
|
||||
}
|
||||
|
||||
230
includes/admin/class-board-data-provider.php
Normal file
230
includes/admin/class-board-data-provider.php
Normal file
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Supplies card data for the kanban board page from existing DB tables.
|
||||
*
|
||||
* Orchestrates the four board columns:
|
||||
* Generating | In Review | Published | Failed
|
||||
*
|
||||
* Generation-log queries (Generating, Failed) are delegated to
|
||||
* PRAutoBlogger_Board_Gen_Log_Query. WP_Query columns (In Review, Published)
|
||||
* are handled here.
|
||||
*
|
||||
* M2: cards now include `dossier_url` so board.js can deep-link to the
|
||||
* Article Dossier instead of the old per-column destinations.
|
||||
*
|
||||
* No schema changes -- all data comes from tables that already exist.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Board_Page::on_ajax_board_status().
|
||||
* Dependencies: PRAutoBlogger_Board_Gen_Log_Query, PRAutoBlogger_Dossier_Page,
|
||||
* WordPress WP_Query, get_option().
|
||||
*
|
||||
* @see admin/class-board-gen-log-query.php -- Raw gen_log queries (Generating/Failed).
|
||||
* @see admin/class-board-page.php -- Calls get_board_snapshot().
|
||||
* @see admin/class-dossier-page.php -- Provides url_for_post() for deep-links.
|
||||
* @see ARCHITECTURE.md -- Database schema.
|
||||
*/
|
||||
class PRAutoBlogger_Board_Data_Provider {
|
||||
|
||||
/** @var PRAutoBlogger_Board_Gen_Log_Query Handles raw gen_log DB queries. */
|
||||
private PRAutoBlogger_Board_Gen_Log_Query $gen_log_query;
|
||||
|
||||
/**
|
||||
* @param PRAutoBlogger_Board_Gen_Log_Query|null $gen_log_query Optional injection for testing.
|
||||
*/
|
||||
public function __construct( ?PRAutoBlogger_Board_Gen_Log_Query $gen_log_query = null ) {
|
||||
$this->gen_log_query = $gen_log_query ?? new PRAutoBlogger_Board_Gen_Log_Query();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the full board snapshot consumed by the AJAX poller.
|
||||
*
|
||||
* @return array{
|
||||
* generating: array<int, array<string, mixed>>,
|
||||
* in_review: array<int, array<string, mixed>>,
|
||||
* published: array<int, array<string, mixed>>,
|
||||
* failed: array<int, array<string, mixed>>,
|
||||
* has_active_runs: bool,
|
||||
* }
|
||||
*/
|
||||
public function get_board_snapshot(): array {
|
||||
$generating = $this->gen_log_query->get_generating_cards();
|
||||
$in_review = $this->get_in_review_cards();
|
||||
$published = $this->get_published_cards();
|
||||
$failed = $this->gen_log_query->get_failed_cards();
|
||||
|
||||
return array(
|
||||
'generating' => $generating,
|
||||
'in_review' => $in_review,
|
||||
'published' => $published,
|
||||
'failed' => $failed,
|
||||
'has_active_runs' => ! empty( $generating ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cards for runs that are currently in-progress.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function get_generating_cards(): array {
|
||||
return $this->gen_log_query->get_generating_cards();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cards for generation runs that ended in an error state.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function get_failed_cards(): array {
|
||||
return $this->gen_log_query->get_failed_cards();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cards for generated draft posts awaiting human review.
|
||||
* M2: cards include dossier_url for deep-linking.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function get_in_review_cards(): array {
|
||||
$query = new WP_Query(
|
||||
array(
|
||||
'post_type' => 'post',
|
||||
'post_status' => 'draft',
|
||||
'posts_per_page' => max( 5, (int) get_option( 'prautoblogger_board_column_limit', PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT ) ),
|
||||
'meta_query' => array(
|
||||
array(
|
||||
'key' => '_prautoblogger_generated',
|
||||
'value' => '1',
|
||||
),
|
||||
),
|
||||
'orderby' => 'date',
|
||||
'order' => 'DESC',
|
||||
)
|
||||
);
|
||||
|
||||
$cards = array();
|
||||
foreach ( $query->posts as $post ) {
|
||||
if ( ! ( $post instanceof WP_Post ) ) {
|
||||
continue;
|
||||
}
|
||||
$run_id = (string) get_post_meta( $post->ID, '_prautoblogger_run_id', true );
|
||||
$cost_total = (float) get_post_meta( $post->ID, '_prautoblogger_total_cost', true );
|
||||
|
||||
$cards[] = array(
|
||||
'run_id' => $run_id,
|
||||
'post_id' => $post->ID,
|
||||
'title' => $post->post_title,
|
||||
'cost_total' => $cost_total,
|
||||
'created_at' => strtotime( $post->post_date_gmt ),
|
||||
'click_action' => 'dossier',
|
||||
'dossier_url' => PRAutoBlogger_Dossier_Page::url_for_post( $post->ID ),
|
||||
'review_url' => admin_url( 'admin.php?page=prautoblogger-review-queue' ),
|
||||
'edit_url' => get_edit_post_link( $post->ID, 'raw' ),
|
||||
);
|
||||
}
|
||||
|
||||
return $this->flag_human_modified( $cards );
|
||||
}
|
||||
|
||||
/**
|
||||
* Cards for published posts within the configured window.
|
||||
* M2: cards include dossier_url for deep-linking.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function get_published_cards(): array {
|
||||
$window_days = max( 1, (int) get_option( 'prautoblogger_board_published_window_days', 7 ) );
|
||||
$date_after = gmdate( 'Y-m-d H:i:s', time() - ( $window_days * DAY_IN_SECONDS ) );
|
||||
|
||||
$query = new WP_Query(
|
||||
array(
|
||||
'post_type' => 'post',
|
||||
'post_status' => 'publish',
|
||||
'posts_per_page' => max( 5, (int) get_option( 'prautoblogger_board_column_limit', PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT ) ),
|
||||
'date_query' => array(
|
||||
array(
|
||||
'after' => $date_after,
|
||||
'inclusive' => true,
|
||||
),
|
||||
),
|
||||
'meta_query' => array(
|
||||
array(
|
||||
'key' => '_prautoblogger_generated',
|
||||
'value' => '1',
|
||||
),
|
||||
),
|
||||
'orderby' => 'date',
|
||||
'order' => 'DESC',
|
||||
)
|
||||
);
|
||||
|
||||
$cards = array();
|
||||
foreach ( $query->posts as $post ) {
|
||||
if ( ! ( $post instanceof WP_Post ) ) {
|
||||
continue;
|
||||
}
|
||||
$run_id = (string) get_post_meta( $post->ID, '_prautoblogger_run_id', true );
|
||||
$cost_total = (float) get_post_meta( $post->ID, '_prautoblogger_total_cost', true );
|
||||
|
||||
$cards[] = array(
|
||||
'run_id' => $run_id,
|
||||
'post_id' => $post->ID,
|
||||
'title' => $post->post_title,
|
||||
'cost_total' => $cost_total,
|
||||
'published_at' => strtotime( $post->post_date_gmt ),
|
||||
'click_action' => 'dossier',
|
||||
'dossier_url' => PRAutoBlogger_Dossier_Page::url_for_post( $post->ID ),
|
||||
'edit_url' => get_edit_post_link( $post->ID, 'raw' ),
|
||||
'post_url' => get_permalink( $post->ID ),
|
||||
);
|
||||
}
|
||||
|
||||
return $this->flag_human_modified( $cards );
|
||||
}
|
||||
|
||||
/**
|
||||
* Set 'human_modified' on each card in ONE batched query (v0.20.0 —
|
||||
* CPO product AC: human-modified runs are visually distinct at the
|
||||
* run-list level). No per-card queries; self-healing on a missing
|
||||
* table/column (flag simply stays false).
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $cards Column cards.
|
||||
* @return array<int, array<string, mixed>> Cards with the flag set.
|
||||
*/
|
||||
private function flag_human_modified( array $cards ): array {
|
||||
$run_ids = array();
|
||||
foreach ( $cards as $i => $card ) {
|
||||
$cards[ $i ]['human_modified'] = false;
|
||||
if ( ! empty( $card['run_id'] ) ) {
|
||||
$run_ids[] = (string) $card['run_id'];
|
||||
}
|
||||
}
|
||||
if ( empty( $run_ids ) || ! PRAutoBlogger_Run_Stage_State::is_available() ) {
|
||||
return $cards;
|
||||
}
|
||||
global $wpdb;
|
||||
$table = PRAutoBlogger_Run_Stage_State::table_name();
|
||||
$placeholders = implode( ',', array_fill( 0, count( $run_ids ), '%s' ) );
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- $placeholders is a fixed %s list.
|
||||
$flagged = $wpdb->get_col(
|
||||
$wpdb->prepare(
|
||||
"SELECT DISTINCT run_id FROM {$table} WHERE human_modified = 1 AND run_id IN ({$placeholders})",
|
||||
$run_ids
|
||||
)
|
||||
);
|
||||
if ( ! is_array( $flagged ) || empty( $flagged ) ) {
|
||||
return $cards;
|
||||
}
|
||||
$flagged = array_flip( array_map( 'strval', $flagged ) );
|
||||
foreach ( $cards as $i => $card ) {
|
||||
if ( isset( $flagged[ (string) ( $card['run_id'] ?? '' ) ] ) ) {
|
||||
$cards[ $i ]['human_modified'] = true;
|
||||
}
|
||||
}
|
||||
return $cards;
|
||||
}
|
||||
}
|
||||
186
includes/admin/class-board-gen-log-query.php
Normal file
186
includes/admin/class-board-gen-log-query.php
Normal file
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Raw generation-log queries for the kanban board.
|
||||
*
|
||||
* Extracted from PRAutoBlogger_Board_Data_Provider to stay under the 300-line
|
||||
* cap. Handles the two columns that read directly from prab_generation_log:
|
||||
* Generating (in-progress runs) | Failed (error runs with no linked post)
|
||||
*
|
||||
* No schema changes — data comes from prab_generation_log which already exists.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Board_Data_Provider::get_generating_cards()
|
||||
* PRAutoBlogger_Board_Data_Provider::get_failed_cards()
|
||||
* Dependencies: WordPress $wpdb, get_transient().
|
||||
*
|
||||
* @see admin/class-board-data-provider.php — Orchestrator that calls this class.
|
||||
* @see ARCHITECTURE.md — Database schema (generation_log).
|
||||
*/
|
||||
class PRAutoBlogger_Board_Gen_Log_Query {
|
||||
|
||||
/** Seconds of gen-log inactivity after which a run is no longer "generating". */
|
||||
public const GENERATING_GRACE_SECONDS = 1800;
|
||||
|
||||
/** Transient key written by PRAutoBlogger_Executor for the active generation run. */
|
||||
public const STATUS_TRANSIENT = 'prautoblogger_generation_status';
|
||||
|
||||
/**
|
||||
* Cards for runs that are currently in-progress.
|
||||
*
|
||||
* Detection strategy: check the generation status transient first (authoritative
|
||||
* for the active manual-run or daily-cron run), then fall back to recent gen_log
|
||||
* run_ids that have no associated published/draft post yet.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function get_generating_cards(): array {
|
||||
global $wpdb;
|
||||
|
||||
$cards = array();
|
||||
|
||||
// Primary: check if the executor transient signals an active run.
|
||||
$status = get_transient( self::STATUS_TRANSIENT );
|
||||
if ( is_array( $status ) && 'running' === ( $status['status'] ?? '' ) ) {
|
||||
$cards[] = array(
|
||||
'run_id' => '',
|
||||
'post_id' => 0,
|
||||
'title' => $status['stage'] ?? __( 'Generating…', 'prautoblogger' ),
|
||||
'cost_total' => 0.0,
|
||||
'stage_current' => $status['stage'] ?? '',
|
||||
'stage_count' => 0,
|
||||
'started_at' => $status['started'] ?? 0,
|
||||
'click_action' => 'logs',
|
||||
'log_url' => admin_url( 'admin.php?page=prautoblogger-logs' ),
|
||||
);
|
||||
}
|
||||
|
||||
// Secondary: runs in gen_log that are recent and have no linked post.
|
||||
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||
$since = gmdate( 'Y-m-d H:i:s', time() - self::GENERATING_GRACE_SECONDS );
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$raw_runs = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT run_id,
|
||||
MIN(created_at) as started_at,
|
||||
MAX(stage) as last_stage,
|
||||
COUNT(*) as stage_count,
|
||||
SUM(CASE WHEN response_status = 'success' THEN estimated_cost ELSE 0 END) as cost_total
|
||||
FROM {$table}
|
||||
WHERE created_at >= %s
|
||||
AND run_id IS NOT NULL
|
||||
AND post_id IS NULL
|
||||
GROUP BY run_id
|
||||
ORDER BY started_at DESC
|
||||
LIMIT %d",
|
||||
$since,
|
||||
(int) max( 5, get_option( 'prautoblogger_board_column_limit', PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT ) )
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
if ( ! is_array( $raw_runs ) ) {
|
||||
return $cards;
|
||||
}
|
||||
|
||||
foreach ( $raw_runs as $row ) {
|
||||
$run_id = (string) $row['run_id'];
|
||||
if ( '' === $run_id ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if we already have a transient card (avoid duplicate).
|
||||
if ( ! empty( $cards ) && '' === $cards[0]['run_id'] ) {
|
||||
// Only add secondary cards if we have a real run_id.
|
||||
// The transient card already covers the active run.
|
||||
}
|
||||
|
||||
$cards[] = array(
|
||||
'run_id' => $run_id,
|
||||
'post_id' => 0,
|
||||
'title' => sprintf(
|
||||
/* translators: %s: pipeline stage name */
|
||||
__( 'Generating — %s', 'prautoblogger' ),
|
||||
esc_html( ucfirst( (string) $row['last_stage'] ) )
|
||||
),
|
||||
'cost_total' => round( (float) $row['cost_total'], 6 ),
|
||||
'stage_current' => (string) $row['last_stage'],
|
||||
'stage_count' => (int) $row['stage_count'],
|
||||
'started_at' => strtotime( (string) $row['started_at'] ),
|
||||
'click_action' => 'logs',
|
||||
'log_url' => admin_url( 'admin.php?page=prautoblogger-logs' ),
|
||||
);
|
||||
}
|
||||
|
||||
return $cards;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cards for generation runs that ended in an error state.
|
||||
*
|
||||
* Looks for recent gen_log rows with response_status='error' that have
|
||||
* no corresponding published post — these are pipeline failures.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function get_failed_cards(): array {
|
||||
global $wpdb;
|
||||
|
||||
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||
$since = gmdate( 'Y-m-d H:i:s', time() - ( 7 * DAY_IN_SECONDS ) );
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$raw = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT run_id,
|
||||
MIN(created_at) as started_at,
|
||||
MAX(error_message) as last_error,
|
||||
MAX(stage) as last_stage,
|
||||
COUNT(*) as call_count
|
||||
FROM {$table}
|
||||
WHERE created_at >= %s
|
||||
AND response_status = 'error'
|
||||
AND run_id IS NOT NULL
|
||||
AND post_id IS NULL
|
||||
GROUP BY run_id
|
||||
ORDER BY started_at DESC
|
||||
LIMIT %d",
|
||||
$since,
|
||||
(int) max( 5, get_option( 'prautoblogger_board_column_limit', PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT ) )
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
if ( ! is_array( $raw ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$cards = array();
|
||||
foreach ( $raw as $row ) {
|
||||
$run_id = (string) $row['run_id'];
|
||||
if ( '' === $run_id ) {
|
||||
continue;
|
||||
}
|
||||
$cards[] = array(
|
||||
'run_id' => $run_id,
|
||||
'post_id' => 0,
|
||||
'title' => sprintf(
|
||||
/* translators: %s: pipeline stage name */
|
||||
__( 'Failed — %s', 'prautoblogger' ),
|
||||
esc_html( ucfirst( (string) $row['last_stage'] ) )
|
||||
),
|
||||
'error_message' => (string) $row['last_error'],
|
||||
'started_at' => strtotime( (string) $row['started_at'] ),
|
||||
'click_action' => 'logs',
|
||||
'log_url' => admin_url( 'admin.php?page=prautoblogger-logs' ),
|
||||
);
|
||||
}
|
||||
|
||||
return $cards;
|
||||
}
|
||||
}
|
||||
195
includes/admin/class-board-page.php
Normal file
195
includes/admin/class-board-page.php
Normal file
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Registers and renders the kanban board -- the primary landing screen for PRAutoBlogger.
|
||||
*
|
||||
* Board columns: Generating | In Review | Published | Failed.
|
||||
* Cards live-update via AJAX polling at a settings-backed interval (default 5s),
|
||||
* backing off to 2x when no active generating run is detected.
|
||||
*
|
||||
* Card click-throughs (M1):
|
||||
* - Generating -> Activity Log filtered to run context
|
||||
* - In Review -> Review Queue
|
||||
* - Published -> Post edit screen
|
||||
* - Failed -> Activity Log
|
||||
*
|
||||
* M2 will rewire all click-throughs to the Article Dossier page.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger::register_admin_hooks() on `admin_menu`.
|
||||
* Dependencies: PRAutoBlogger_Board_Data_Provider, wp_localize_script.
|
||||
*
|
||||
* @see admin/class-board-data-provider.php -- Supplies card data.
|
||||
* @see templates/admin/board-page.php -- HTML template.
|
||||
* @see assets/js/board.js -- Polling + DOM updates.
|
||||
* @see ARCHITECTURE.md -- Section: Board (kanban dashboard).
|
||||
*/
|
||||
class PRAutoBlogger_Board_Page {
|
||||
|
||||
private const PAGE_SLUG = 'prautoblogger-board';
|
||||
|
||||
/** AJAX action name for board polling. */
|
||||
public const AJAX_ACTION = 'prautoblogger_board_status';
|
||||
|
||||
/** Nonce action string for board AJAX. */
|
||||
public const NONCE_ACTION = 'prautoblogger_board';
|
||||
|
||||
/**
|
||||
* Register the Board as the PRIMARY (first) submenu item, overriding the
|
||||
* implicit Settings landing page. The trick is: WordPress auto-generates a
|
||||
* submenu duplicate of the top-level page; we name it "Board" so the
|
||||
* first click goes to the board, not the raw settings page.
|
||||
*
|
||||
* Registration order note: this method is hooked at admin_menu priority 11
|
||||
* so that PRAutoBlogger_Admin_Page::on_register_menu() (priority 10) has
|
||||
* already called add_menu_page(). This ensures WordPress has populated
|
||||
* $admin_page_hooks['prautoblogger-settings'] before add_submenu_page()
|
||||
* runs here, so get_plugin_page_hookname() resolves to the correct
|
||||
* 'prautoblogger_page_prautoblogger-board' suffix rather than the fallback
|
||||
* 'admin_page_prautoblogger-board'. See ARCHITECTURE.md Section: Board.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function on_register_menu(): void {
|
||||
// Override the auto-created "PRAutoBlogger" submenu entry with "Board".
|
||||
add_submenu_page(
|
||||
'prautoblogger-settings',
|
||||
__( 'PRAutoBlogger Board', 'prautoblogger' ),
|
||||
__( 'Board', 'prautoblogger' ),
|
||||
'manage_options',
|
||||
self::PAGE_SLUG,
|
||||
array( $this, 'render_page' )
|
||||
);
|
||||
|
||||
// Reorder submenu so Board is the FIRST entry (primary click target).
|
||||
// WordPress auto-appended a duplicate of the top-level page as submenu
|
||||
// index 0; our Board was appended at index 1. We swap them so the
|
||||
// top-level PRAutoBlogger click lands on the Board page, not Settings.
|
||||
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- intentional submenu reorder.
|
||||
global $submenu;
|
||||
if ( isset( $submenu['prautoblogger-settings'] ) ) {
|
||||
$items = $submenu['prautoblogger-settings'];
|
||||
// Find the Board entry and move it to position 0.
|
||||
$board_idx = null;
|
||||
foreach ( $items as $i => $item ) {
|
||||
if ( isset( $item[2] ) && self::PAGE_SLUG === $item[2] ) {
|
||||
$board_idx = $i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ( null !== $board_idx && $board_idx > 0 ) {
|
||||
$board_item = $items[ $board_idx ];
|
||||
unset( $items[ $board_idx ] );
|
||||
// Prepend board item at position 0 while preserving remaining order.
|
||||
$submenu['prautoblogger-settings'] = array_merge( array( $board_item ), array_values( $items ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the board page.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function render_page(): void {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
return;
|
||||
}
|
||||
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/board-page.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue board-specific JS + CSS and localize the poller config.
|
||||
*
|
||||
* Called via `admin_enqueue_scripts` action, gated to this page's hook suffix.
|
||||
*
|
||||
* @param string $hook_suffix Current admin page hook suffix.
|
||||
* @return void
|
||||
*/
|
||||
public function on_enqueue_assets( string $hook_suffix ): void {
|
||||
$board_hook = 'prautoblogger_page_' . self::PAGE_SLUG;
|
||||
if ( $hook_suffix !== $board_hook ) {
|
||||
return;
|
||||
}
|
||||
|
||||
wp_enqueue_style(
|
||||
'prautoblogger-board',
|
||||
PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/board.css',
|
||||
array( 'prautoblogger-admin' ),
|
||||
PRAUTOBLOGGER_VERSION
|
||||
);
|
||||
|
||||
wp_enqueue_script(
|
||||
'prautoblogger-board',
|
||||
PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/board.js',
|
||||
array( 'jquery' ),
|
||||
PRAUTOBLOGGER_VERSION,
|
||||
true
|
||||
);
|
||||
wp_enqueue_script(
|
||||
'prautoblogger-board-generate',
|
||||
PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/board-generate.js',
|
||||
array( 'jquery', 'prautoblogger-board' ),
|
||||
PRAUTOBLOGGER_VERSION,
|
||||
true
|
||||
);
|
||||
|
||||
$poll_interval = max( 3, (int) get_option( 'prautoblogger_board_poll_interval', 5 ) );
|
||||
$published_days = max( 1, (int) get_option( 'prautoblogger_board_published_window_days', 7 ) );
|
||||
|
||||
wp_localize_script(
|
||||
'prautoblogger-board',
|
||||
'prabBoard',
|
||||
array(
|
||||
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||
'nonce' => wp_create_nonce( self::NONCE_ACTION ),
|
||||
'generateNonce' => wp_create_nonce( 'prautoblogger_generate_now' ),
|
||||
'pollInterval' => $poll_interval * 1000,
|
||||
'publishedWindowDays' => $published_days,
|
||||
'action' => self::AJAX_ACTION,
|
||||
'strings' => array(
|
||||
'generating' => __( 'Generating', 'prautoblogger' ),
|
||||
'inReview' => __( 'In Review', 'prautoblogger' ),
|
||||
'published' => __( 'Published', 'prautoblogger' ),
|
||||
'failed' => __( 'Failed', 'prautoblogger' ),
|
||||
'empty' => __( 'Nothing here yet.', 'prautoblogger' ),
|
||||
'cost' => __( 'Cost', 'prautoblogger' ),
|
||||
'view' => __( 'View', 'prautoblogger' ),
|
||||
'edit' => __( 'Edit', 'prautoblogger' ),
|
||||
'viewLog' => __( 'View Log', 'prautoblogger' ),
|
||||
'pollError' => __( 'Board update failed -- retrying.', 'prautoblogger' ),
|
||||
'humanModified' => __( 'Human-modified', 'prautoblogger' ),
|
||||
'newArticle' => __( 'New Article', 'prautoblogger' ),
|
||||
'generatingBtn' => __( 'Generating...', 'prautoblogger' ),
|
||||
'genStarted' => __( 'Generation started. Board will update automatically.', 'prautoblogger' ),
|
||||
'genError' => __( 'Generation failed to start. Check the Activity Log.', 'prautoblogger' ),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX handler: return the current board snapshot (all four columns).
|
||||
*
|
||||
* Nonce: `prautoblogger_board`. Requires `manage_options` capability.
|
||||
*
|
||||
* Side effects: DB reads only -- no writes.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function on_ajax_board_status(): void {
|
||||
check_ajax_referer( self::NONCE_ACTION, 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
|
||||
return;
|
||||
}
|
||||
|
||||
$provider = new PRAutoBlogger_Board_Data_Provider();
|
||||
$snapshot = $provider->get_board_snapshot();
|
||||
|
||||
wp_send_json_success( $snapshot );
|
||||
}
|
||||
}
|
||||
299
includes/admin/class-dossier-actions.php
Normal file
299
includes/admin/class-dossier-actions.php
Normal file
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* AJAX endpoints for the dossier's edit + re-run actions (v0.20.0, M3).
|
||||
*
|
||||
* What: Four manage_options + nonce gated endpoints. The mutation
|
||||
* endpoints follow the chained-cron contract strictly: save_input
|
||||
* only INSERTs an immutable fork version; the two rerun endpoints
|
||||
* only validate + queue (Rerun_Job_Support::queue) — execution and
|
||||
* ALL run/stage state mutations happen in the cron handler after
|
||||
* re-validation under the generation lock. Run identity (run_id /
|
||||
* item_key) is always derived server-side from the post's meta —
|
||||
* never trusted from the client; the client only names the stage
|
||||
* row it acted on, which is validated against an existing row.
|
||||
* stage_status is the read-only poll the dossier JS uses to show
|
||||
* queued -> pickup -> result.
|
||||
* Who triggers it: assets/js/dossier-edit.js via admin-ajax.php;
|
||||
* registered in class-prautoblogger.php.
|
||||
* Dependencies: Rerun_Eligibility, Stage_Input_Store, Stage_Replay
|
||||
* (decode), Rerun_Job_Support/Executor, Run_Stage_State.
|
||||
*
|
||||
* @see assets/js/dossier-edit.js — The frontend caller.
|
||||
* @see core/class-rerun-executor.php — The cron handlers jobs land in.
|
||||
* @see ARCHITECTURE.md #24 — Edit + re-run design.
|
||||
*/
|
||||
class PRAutoBlogger_Dossier_Actions {
|
||||
|
||||
/** Nonce action shared by all four endpoints. */
|
||||
public const NONCE_ACTION = 'prautoblogger_dossier_actions';
|
||||
|
||||
/**
|
||||
* AJAX: save an edited stage input as a new immutable fork version.
|
||||
*
|
||||
* The fork body is the stage's base request (latest fork, else the
|
||||
* recorded request_json) with ONLY the message contents substituted —
|
||||
* roles, message count, model and call options are not editable, so
|
||||
* a fork is always a well-formed replayable chat body. Prompt text
|
||||
* is stored raw (it is LLM input, never rendered unescaped).
|
||||
*
|
||||
* Side effects: one stage_inputs INSERT.
|
||||
*
|
||||
* @return void Sends JSON.
|
||||
*/
|
||||
public function on_save_input(): void {
|
||||
$request = $this->authorize_and_resolve();
|
||||
|
||||
$check = PRAutoBlogger_Rerun_Eligibility::check( $request['run_id'], $request['post_id'] );
|
||||
if ( ! PRAutoBlogger_Rerun_Eligibility::is_editable_stage( $request['stage'] ) || ! $check['ok'] ) {
|
||||
$reason = $check['ok'] ? __( 'This stage cannot be replayed with an edited input.', 'prautoblogger' ) : $check['reason'];
|
||||
wp_send_json_error( array( 'message' => $reason ), 400 );
|
||||
return;
|
||||
}
|
||||
|
||||
$base_body = $this->resolve_base_body( $request );
|
||||
if ( null === $base_body ) {
|
||||
wp_send_json_error( array( 'message' => __( 'No persisted input exists for this stage.', 'prautoblogger' ) ), 400 );
|
||||
return;
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- raw prompt text for the LLM; structure-validated below, stored raw, escaped at render.
|
||||
$raw = isset( $_POST['messages'] ) ? wp_unslash( (string) $_POST['messages'] ) : '';
|
||||
$messages = json_decode( $raw, true );
|
||||
$merged = $this->merge_messages( $base_body, is_array( $messages ) ? $messages : null );
|
||||
if ( null === $merged ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Edited messages must match the original structure (same count and roles, non-empty contents).', 'prautoblogger' ) ), 400 );
|
||||
return;
|
||||
}
|
||||
|
||||
$user = wp_get_current_user();
|
||||
$version = PRAutoBlogger_Stage_Input_Store::save_fork(
|
||||
$request['run_id'],
|
||||
$request['stage'],
|
||||
$request['agent_role'],
|
||||
$request['item_key'],
|
||||
(string) wp_json_encode( $merged ),
|
||||
(string) $user->user_login
|
||||
);
|
||||
if ( null === $version ) {
|
||||
wp_send_json_error( array( 'message' => __( 'The edited input could not be saved.', 'prautoblogger' ) ), 500 );
|
||||
return;
|
||||
}
|
||||
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'version' => $version,
|
||||
'message' => sprintf(
|
||||
/* translators: %d: fork version number. */
|
||||
__( 'Saved as version %d. The original input is preserved unchanged; re-running this stage will use your edited version and mark all downstream stages stale.', 'prautoblogger' ),
|
||||
$version
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: queue a single-stage replay of the latest edited fork.
|
||||
*
|
||||
* Validates, then ONLY schedules — chained-cron semantics. The UI
|
||||
* copy never implies synchronous execution.
|
||||
*
|
||||
* @return void Sends JSON.
|
||||
*/
|
||||
public function on_rerun_stage(): void {
|
||||
$request = $this->authorize_and_resolve();
|
||||
|
||||
$check = PRAutoBlogger_Rerun_Eligibility::check_replay(
|
||||
$request['run_id'],
|
||||
$request['post_id'],
|
||||
$request['stage'],
|
||||
$request['agent_role'],
|
||||
$request['item_key']
|
||||
);
|
||||
if ( ! $check['ok'] ) {
|
||||
wp_send_json_error( array( 'message' => $check['reason'] ), 400 );
|
||||
return;
|
||||
}
|
||||
|
||||
PRAutoBlogger_Rerun_Job_Support::queue(
|
||||
PRAutoBlogger_Rerun_Executor::REPLAY_ACTION,
|
||||
array( $request['run_id'], $request['post_id'], $request['stage'], $request['agent_role'], $request['item_key'] ),
|
||||
sprintf(
|
||||
/* translators: %s: stage display label. */
|
||||
__( 'Queued: re-running %s with the edited input…', 'prautoblogger' ),
|
||||
PRAutoBlogger_Stage_Display_Map::label( $request['stage'] )
|
||||
)
|
||||
);
|
||||
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'queued' => true,
|
||||
'message' => __( 'Re-run queued. The pipeline will pick it up shortly — progress appears here and on the board.', 'prautoblogger' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: queue re-run-from-here (rebuild this stage + downstream).
|
||||
*
|
||||
* @return void Sends JSON.
|
||||
*/
|
||||
public function on_rerun_from(): void {
|
||||
$request = $this->authorize_and_resolve();
|
||||
|
||||
$check = PRAutoBlogger_Rerun_Eligibility::check_rebuild(
|
||||
$request['run_id'],
|
||||
$request['post_id'],
|
||||
$request['stage'],
|
||||
$request['item_key']
|
||||
);
|
||||
if ( ! $check['ok'] ) {
|
||||
wp_send_json_error( array( 'message' => $check['reason'] ), 400 );
|
||||
return;
|
||||
}
|
||||
|
||||
PRAutoBlogger_Rerun_Job_Support::queue(
|
||||
PRAutoBlogger_Rerun_Executor::REBUILD_ACTION,
|
||||
array( $request['run_id'], $request['post_id'], $request['stage'], $request['item_key'] ),
|
||||
sprintf(
|
||||
/* translators: %s: stage display label. */
|
||||
__( 'Queued: re-running the pipeline from %s…', 'prautoblogger' ),
|
||||
PRAutoBlogger_Stage_Display_Map::label( $request['stage'] )
|
||||
)
|
||||
);
|
||||
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'queued' => true,
|
||||
'message' => __( 'Re-run from here queued. Stages will rebuild in sequence — progress appears here and on the board.', 'prautoblogger' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: read-only stage-status poll for the dossier.
|
||||
*
|
||||
* @return void Sends JSON: run status, per-stage state, live message.
|
||||
*/
|
||||
public function on_stage_status(): void {
|
||||
$request = $this->authorize_and_resolve( false );
|
||||
|
||||
$stages = array();
|
||||
foreach ( PRAutoBlogger_Run_Stage_State::stages_for_item( $request['run_id'], $request['item_key'] ) as $row ) {
|
||||
$stages[] = array(
|
||||
'stage' => (string) ( $row['stage'] ?? '' ),
|
||||
'agent_role' => (string) ( $row['agent_role'] ?? '' ),
|
||||
'status' => (string) ( $row['status'] ?? '' ),
|
||||
'stale' => ! empty( $row['stale'] ),
|
||||
'human_modified' => ! empty( $row['human_modified'] ),
|
||||
'attempt' => (int) ( $row['attempt'] ?? 1 ),
|
||||
'finished_at' => (string) ( $row['finished_at'] ?? '' ),
|
||||
);
|
||||
}
|
||||
|
||||
$live = get_transient( PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT );
|
||||
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'run_status' => (string) ( PRAutoBlogger_Run_State::get_status( $request['run_id'] ) ?? '' ),
|
||||
'stages' => $stages,
|
||||
'live' => is_array( $live ) ? $live : null,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared gate: nonce + capability, then derive run identity from the
|
||||
* POSTed post_id server-side. Sends a JSON error (and exits) on any
|
||||
* failure.
|
||||
*
|
||||
* @param bool $require_stage Whether the request must name a stage row.
|
||||
* @return array{post_id: int, run_id: string, item_key: string, stage: string, agent_role: string}
|
||||
*/
|
||||
private function authorize_and_resolve( bool $require_stage = true ): array {
|
||||
check_ajax_referer( self::NONCE_ACTION, 'nonce' );
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
|
||||
}
|
||||
|
||||
$post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0;
|
||||
$run_id = $post_id > 0 ? (string) get_post_meta( $post_id, '_prautoblogger_run_id', true ) : '';
|
||||
if ( 0 === $post_id || '' === $run_id ) {
|
||||
wp_send_json_error( array( 'message' => __( 'No generation run is recorded for this article.', 'prautoblogger' ) ), 400 );
|
||||
}
|
||||
$idea_hash = (string) get_post_meta( $post_id, '_prautoblogger_idea_hash', true );
|
||||
$item_key = '' !== $idea_hash ? 'idea:' . $idea_hash : '';
|
||||
|
||||
$stage = isset( $_POST['stage'] ) ? sanitize_key( (string) wp_unslash( $_POST['stage'] ) ) : '';
|
||||
$agent_role = isset( $_POST['agent_role'] ) ? sanitize_key( (string) wp_unslash( $_POST['agent_role'] ) ) : '';
|
||||
if ( $require_stage && '' === $stage ) {
|
||||
wp_send_json_error( array( 'message' => __( 'No stage specified.', 'prautoblogger' ) ), 400 );
|
||||
}
|
||||
|
||||
return array(
|
||||
'post_id' => $post_id,
|
||||
'run_id' => $run_id,
|
||||
'item_key' => $item_key,
|
||||
'stage' => $stage,
|
||||
'agent_role' => $agent_role,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The base body an edit forks from: latest saved fork, else the
|
||||
* stage's recorded request_json.
|
||||
*
|
||||
* @param array<string, mixed> $request Resolved request context.
|
||||
* @return array<string, mixed>|null Decoded body, or null when absent.
|
||||
*/
|
||||
private function resolve_base_body( array $request ): ?array {
|
||||
$fork = PRAutoBlogger_Stage_Input_Store::latest_fork( $request['run_id'], $request['stage'], $request['agent_role'], $request['item_key'] );
|
||||
if ( null !== $fork && ! empty( $fork['request_json'] ) ) {
|
||||
return PRAutoBlogger_Stage_Replay::decode_body( (string) $fork['request_json'] );
|
||||
}
|
||||
$log_rows = ( new PRAutoBlogger_Dossier_Gen_Log_Query() )->get_by_run( $request['run_id'] );
|
||||
$rows = $log_rows[ $request['stage'] ] ?? array();
|
||||
foreach ( array_reverse( $rows ) as $row ) {
|
||||
if ( ! empty( $row['request_json'] ) ) {
|
||||
$body = PRAutoBlogger_Stage_Replay::decode_body( (string) $row['request_json'] );
|
||||
if ( null !== $body ) {
|
||||
return $body;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute edited message contents into the base body. Structure
|
||||
* is locked: same message count, same roles, all contents non-empty
|
||||
* strings — so every fork stays a replayable chat body.
|
||||
*
|
||||
* @param array<string, mixed> $base Decoded base body.
|
||||
* @param array<int, mixed>|null $messages Edited messages payload.
|
||||
* @return array<string, mixed>|null Merged body, or null when invalid.
|
||||
*/
|
||||
private function merge_messages( array $base, ?array $messages ): ?array {
|
||||
if ( null === $messages || count( $messages ) !== count( $base['messages'] ) ) {
|
||||
return null;
|
||||
}
|
||||
$merged = $base;
|
||||
foreach ( array_values( $messages ) as $i => $message ) {
|
||||
if ( ! is_array( $message ) || ! isset( $message['role'], $message['content'] )
|
||||
|| ! is_string( $message['role'] ) || ! is_string( $message['content'] ) ) {
|
||||
return null;
|
||||
}
|
||||
if ( ( $base['messages'][ $i ]['role'] ?? null ) !== $message['role'] ) {
|
||||
return null;
|
||||
}
|
||||
if ( '' === trim( $message['content'] ) ) {
|
||||
return null;
|
||||
}
|
||||
$merged['messages'][ $i ]['content'] = $message['content'];
|
||||
}
|
||||
return $merged;
|
||||
}
|
||||
}
|
||||
275
includes/admin/class-dossier-data-assembler.php
Normal file
275
includes/admin/class-dossier-data-assembler.php
Normal file
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Assembles the view model for the Article Dossier page.
|
||||
*
|
||||
* Orchestrates 5 queries (all indexed by run_id or post_id):
|
||||
* 1. wp_prautoblogger_runs -- run ledger row
|
||||
* 2. wp_prautoblogger_run_stages -- all stage rows for the run
|
||||
* 3. wp_prautoblogger_generation_log -- per-stage cost + raw trace
|
||||
* 4. wp_prautoblogger_run_decisions -- rationale per stage
|
||||
* 5. post meta (WP object cache) -- run_id + verdict + keywords
|
||||
*
|
||||
* Legacy posts (no run row) return ['has_run' => false] -- never notices.
|
||||
* Amortized research rows (pv=null, role='') render gracefully.
|
||||
*
|
||||
* v0.20.0 (M3): stage rows are filtered to THIS post's item (item_key
|
||||
* from the idea-hash meta; multi-article runs no longer collide) and
|
||||
* gen_log rows exclude entries linked to OTHER posts; per-stage edit/
|
||||
* re-run affordance data + run spend ride the view model
|
||||
* (Dossier_Rerun_Panel_Data); log-only stages (image_a/image_b/
|
||||
* llm_research/... -- generation_log rows with no run_stages row) and
|
||||
* the post's pipeline attachments are exposed so the image sections
|
||||
* render from their REAL data sources (QA M2 F3).
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Dossier_Page::render_page().
|
||||
* Dependencies: PRAutoBlogger_Dossier_Gen_Log_Query, PRAutoBlogger_Stage_Display_Map,
|
||||
* PRAutoBlogger_Run_Stage_State.
|
||||
*
|
||||
* @see admin/class-dossier-gen-log-query.php -- Raw generation_log queries.
|
||||
* @see core/class-run-stage-state.php -- Stage I/O access.
|
||||
* @see ARCHITECTURE.md -- Substrate schema.
|
||||
*/
|
||||
class PRAutoBlogger_Dossier_Data_Assembler {
|
||||
|
||||
/** @var PRAutoBlogger_Dossier_Gen_Log_Query */
|
||||
private PRAutoBlogger_Dossier_Gen_Log_Query $log_query;
|
||||
|
||||
/**
|
||||
* @param PRAutoBlogger_Dossier_Gen_Log_Query|null $log_query Injectable for tests.
|
||||
*/
|
||||
public function __construct( ?PRAutoBlogger_Dossier_Gen_Log_Query $log_query = null ) {
|
||||
$this->log_query = $log_query ?? new PRAutoBlogger_Dossier_Gen_Log_Query();
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the full dossier view model for a post.
|
||||
*
|
||||
* @param int $post_id WordPress post ID (0 = bogus/not found).
|
||||
* @return array<string, mixed> View model consumed by dossier-page.php.
|
||||
*/
|
||||
public function assemble( int $post_id ): array {
|
||||
$base = array(
|
||||
'post_id' => $post_id,
|
||||
'has_run' => false,
|
||||
'post_title' => '',
|
||||
'post_status' => '',
|
||||
'verdict' => '',
|
||||
'tier' => '',
|
||||
'run_id' => '',
|
||||
'run' => null,
|
||||
'stages' => array(),
|
||||
'decisions' => array(),
|
||||
'gen_log_index' => array(),
|
||||
'item_key' => '',
|
||||
'eligibility' => array(
|
||||
'ok' => false,
|
||||
'reason' => '',
|
||||
),
|
||||
'spend' => PRAutoBlogger_Dossier_Rerun_Panel_Data::spend( null ),
|
||||
'human_modified_any' => false,
|
||||
'stale_any' => false,
|
||||
'log_only_stages' => array(),
|
||||
'images' => array(),
|
||||
);
|
||||
|
||||
if ( $post_id <= 0 ) {
|
||||
return $base;
|
||||
}
|
||||
|
||||
$post = get_post( $post_id );
|
||||
if ( ! ( $post instanceof WP_Post ) ) {
|
||||
return $base;
|
||||
}
|
||||
|
||||
$base['post_title'] = $post->post_title;
|
||||
$base['post_status'] = $post->post_status;
|
||||
$base['verdict'] = (string) get_post_meta( $post_id, '_prautoblogger_editor_verdict', true );
|
||||
$base['tier'] = (string) get_post_meta( $post_id, '_prautoblogger_article_type', true );
|
||||
|
||||
$run_id = (string) get_post_meta( $post_id, '_prautoblogger_run_id', true );
|
||||
if ( '' === $run_id ) {
|
||||
return $base;
|
||||
}
|
||||
$base['run_id'] = $run_id;
|
||||
|
||||
$run = $this->get_run_row( $run_id );
|
||||
if ( null === $run ) {
|
||||
return $base;
|
||||
}
|
||||
|
||||
$idea_hash = (string) get_post_meta( $post_id, '_prautoblogger_idea_hash', true );
|
||||
$item_key = '' !== $idea_hash ? 'idea:' . $idea_hash : '';
|
||||
$base['item_key'] = $item_key;
|
||||
|
||||
$base['has_run'] = true;
|
||||
$base['run'] = $run;
|
||||
$base['stages'] = $this->get_stage_rows( $run_id, $item_key );
|
||||
$base['decisions'] = $this->get_decision_rows( $run_id );
|
||||
$base['gen_log_index'] = $this->filter_log_for_post( $this->log_query->get_by_run( $run_id ), $post_id );
|
||||
$base['eligibility'] = PRAutoBlogger_Rerun_Eligibility::check( $run_id, $post_id );
|
||||
$base['spend'] = PRAutoBlogger_Dossier_Rerun_Panel_Data::spend( $run );
|
||||
|
||||
foreach ( $base['stages'] as $role_key => $stage_row ) {
|
||||
$stage_name = (string) ( $stage_row['stage'] ?? '' );
|
||||
$rerun = PRAutoBlogger_Dossier_Rerun_Panel_Data::for_stage(
|
||||
$stage_row,
|
||||
$base['gen_log_index'][ $stage_name ] ?? array(),
|
||||
$run_id,
|
||||
$base['eligibility']
|
||||
);
|
||||
$base['stages'][ $role_key ]['rerun'] = $rerun;
|
||||
$base['human_modified_any'] = $base['human_modified_any'] || $rerun['human_modified'];
|
||||
$base['stale_any'] = $base['stale_any'] || $rerun['stale'];
|
||||
}
|
||||
|
||||
// QA M2 F3: stages that exist only in generation_log (image_a/
|
||||
// image_b, llm_research, image_prompt_rewrite, ...) render from
|
||||
// their real source instead of silently disappearing.
|
||||
$run_stage_names = array();
|
||||
foreach ( $base['stages'] as $stage_row ) {
|
||||
$run_stage_names[ (string) ( $stage_row['stage'] ?? '' ) ] = true;
|
||||
}
|
||||
$base['log_only_stages'] = array_values( array_diff( array_keys( $base['gen_log_index'] ), array_keys( $run_stage_names ) ) );
|
||||
$base['images'] = PRAutoBlogger_Dossier_Image_Data::for_post( $post_id );
|
||||
|
||||
return $base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the run ledger row.
|
||||
*
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function get_run_row( string $run_id ): ?array {
|
||||
global $wpdb;
|
||||
if ( null === $wpdb ) {
|
||||
return null;
|
||||
}
|
||||
$table = $wpdb->prefix . 'prautoblogger_runs';
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$row = $wpdb->get_row(
|
||||
$wpdb->prepare( "SELECT * FROM {$table} WHERE run_id = %s", $run_id ),
|
||||
ARRAY_A
|
||||
);
|
||||
return is_array( $row ) ? $row : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch run_stages rows for a run, indexed by stage name — scoped to
|
||||
* one item (+ run-level rows) when an item key is known.
|
||||
*
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @param string $item_key Item key ('' = no scoping, M2 behavior).
|
||||
* @return array<string, array<string, mixed>> Stage rows keyed by stage name.
|
||||
*/
|
||||
private function get_stage_rows( string $run_id, string $item_key = '' ): array {
|
||||
if ( ! PRAutoBlogger_Run_Stage_State::is_available() ) {
|
||||
return array();
|
||||
}
|
||||
global $wpdb;
|
||||
if ( null === $wpdb ) {
|
||||
return array();
|
||||
}
|
||||
$table = PRAutoBlogger_Run_Stage_State::table_name();
|
||||
// v0.20.0: scope to THIS post's item + run-level rows. When the
|
||||
// idea-hash meta is absent (pre-v0.18 posts) fall back to the
|
||||
// unfiltered M2 behavior (single-item runs are unaffected).
|
||||
if ( '' !== $item_key ) {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare( "SELECT * FROM {$table} WHERE run_id = %s AND item_key IN (%s, '') ORDER BY id ASC", $run_id, $item_key ),
|
||||
ARRAY_A
|
||||
);
|
||||
} else {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare( "SELECT * FROM {$table} WHERE run_id = %s ORDER BY id ASC", $run_id ),
|
||||
ARRAY_A
|
||||
);
|
||||
}
|
||||
|
||||
$index = array();
|
||||
foreach ( ( $rows ?? array() ) as $row ) {
|
||||
if ( ! is_array( $row ) ) {
|
||||
continue;
|
||||
}
|
||||
$stage = (string) ( $row['stage'] ?? '' );
|
||||
$role_key = $stage . ':' . ( $row['agent_role'] ?? '' );
|
||||
$row['display_output'] = $this->extract_output( $row );
|
||||
$index[ $role_key ] = $row;
|
||||
}
|
||||
return $index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop gen_log rows linked to OTHER posts (multi-article runs share
|
||||
* one run_id). Rows with no post linkage (NULL/0 — run-level work
|
||||
* and pre-link entries) are kept.
|
||||
*
|
||||
* @param array<string, array<int, array<string, mixed>>> $log_index Rows keyed by stage.
|
||||
* @param int $post_id This dossier's post.
|
||||
* @return array<string, array<int, array<string, mixed>>>
|
||||
*/
|
||||
private function filter_log_for_post( array $log_index, int $post_id ): array {
|
||||
$filtered = array();
|
||||
foreach ( $log_index as $stage => $rows ) {
|
||||
foreach ( $rows as $row ) {
|
||||
$row_post = (int) ( $row['post_id'] ?? 0 );
|
||||
if ( 0 !== $row_post && $row_post !== $post_id ) {
|
||||
continue;
|
||||
}
|
||||
$filtered[ $stage ][] = $row;
|
||||
}
|
||||
}
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the text output from a run_stages meta_json blob.
|
||||
* Returns null when meta_json is NULL (pruned) or absent.
|
||||
*
|
||||
* @param array<string, mixed> $row Stage row.
|
||||
* @return string|null
|
||||
*/
|
||||
private function extract_output( array $row ): ?string {
|
||||
if ( empty( $row['meta_json'] ) ) {
|
||||
return null;
|
||||
}
|
||||
$meta = json_decode( (string) $row['meta_json'], true );
|
||||
return ( is_array( $meta ) && isset( $meta['output'] ) ) ? (string) $meta['output'] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all run_decisions rows, indexed by stage name.
|
||||
*
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @return array<string, array<string, mixed>> Decision rows keyed by stage.
|
||||
*/
|
||||
private function get_decision_rows( string $run_id ): array {
|
||||
global $wpdb;
|
||||
if ( null === $wpdb ) {
|
||||
return array();
|
||||
}
|
||||
$table = $wpdb->prefix . 'prautoblogger_run_decisions';
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare( "SELECT * FROM {$table} WHERE run_id = %s ORDER BY id ASC", $run_id ),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
$index = array();
|
||||
foreach ( ( $rows ?? array() ) as $row ) {
|
||||
if ( ! is_array( $row ) ) {
|
||||
continue;
|
||||
}
|
||||
$stage = (string) ( $row['stage'] ?? 'unknown' );
|
||||
$index[ $stage ] = $row;
|
||||
}
|
||||
return $index;
|
||||
}
|
||||
}
|
||||
95
includes/admin/class-dossier-gen-log-query.php
Normal file
95
includes/admin/class-dossier-gen-log-query.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Raw generation_log queries for the Article Dossier (M2).
|
||||
*
|
||||
* Returns per-stage generation log data indexed by stage name: model,
|
||||
* prompt_version, agent_role, token counts, estimated cost, request_json
|
||||
* (raw trace), and response_status. Amortized research rows (pv=null,
|
||||
* role='') are included and rendered gracefully by the template.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Dossier_Data_Assembler::assemble().
|
||||
* Dependencies: WordPress $wpdb.
|
||||
*
|
||||
* @see admin/class-dossier-data-assembler.php -- Calls get_by_run().
|
||||
* @see ARCHITECTURE.md -- generation_log schema.
|
||||
*/
|
||||
class PRAutoBlogger_Dossier_Gen_Log_Query {
|
||||
|
||||
/**
|
||||
* Fetch all generation_log rows for a run, indexed by stage.
|
||||
*
|
||||
* Multiple rows per stage (e.g. multi-step draft) are accumulated as an
|
||||
* array so the template can render multiple raw-trace entries per section.
|
||||
*
|
||||
* SECURITY: request_json is stored without API keys (never logged per
|
||||
* architecture). The template must escape this via esc_html() before render.
|
||||
*
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @return array<string, array<int, array<string, mixed>>> Rows keyed by stage.
|
||||
*/
|
||||
public function get_by_run( string $run_id ): array {
|
||||
global $wpdb;
|
||||
if ( null === $wpdb || '' === $run_id ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT id, stage, model, agent_role, prompt_version,
|
||||
prompt_tokens, completion_tokens, estimated_cost,
|
||||
request_json, response_status, error_message, created_at
|
||||
FROM {$table}
|
||||
WHERE run_id = %s
|
||||
ORDER BY id ASC",
|
||||
$run_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
$index = array();
|
||||
foreach ( ( $rows ?? array() ) as $row ) {
|
||||
if ( ! is_array( $row ) ) {
|
||||
continue;
|
||||
}
|
||||
$stage = (string) ( $row['stage'] ?? 'unknown' );
|
||||
if ( ! isset( $index[ $stage ] ) ) {
|
||||
$index[ $stage ] = array();
|
||||
}
|
||||
$index[ $stage ][] = $row;
|
||||
}
|
||||
return $index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate cost + token totals per stage for the sidebar receipt.
|
||||
*
|
||||
* @param array<string, array<int, array<string, mixed>>> $log_index Output of get_by_run().
|
||||
* @return array<string, array{cost: float, prompt_tokens: int, completion_tokens: int, calls: int}>
|
||||
*/
|
||||
public function aggregate_per_stage( array $log_index ): array {
|
||||
$totals = array();
|
||||
foreach ( $log_index as $stage => $rows ) {
|
||||
$cost = 0.0;
|
||||
$prompt_tokens = 0;
|
||||
$completion_tokens = 0;
|
||||
foreach ( $rows as $row ) {
|
||||
$cost += (float) ( $row['estimated_cost'] ?? 0 );
|
||||
$prompt_tokens += (int) ( $row['prompt_tokens'] ?? 0 );
|
||||
$completion_tokens += (int) ( $row['completion_tokens'] ?? 0 );
|
||||
}
|
||||
$totals[ $stage ] = array(
|
||||
'cost' => $cost,
|
||||
'prompt_tokens' => $prompt_tokens,
|
||||
'completion_tokens' => $completion_tokens,
|
||||
'calls' => count( $rows ),
|
||||
);
|
||||
}
|
||||
return $totals;
|
||||
}
|
||||
}
|
||||
82
includes/admin/class-dossier-image-data.php
Normal file
82
includes/admin/class-dossier-image-data.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Resolves a post's pipeline image attachments for the dossier (M3/F3).
|
||||
*
|
||||
* What: The image sections' REAL data source — the attachments the
|
||||
* image pipeline produced (featured + composed og/square variants,
|
||||
* discovered via the `_prautoblogger_image_role` attachment meta
|
||||
* and the post thumbnail), paired in the template with the
|
||||
* image_a/image_b generation_log rows. Replaces the M2 sections
|
||||
* that keyed off run_stages rows the image pipeline never writes
|
||||
* (QA M2 F3 — wiring the dossier to real data was the smaller,
|
||||
* safer change vs. teaching the live image pipeline to write
|
||||
* run_stages; the pipeline restructure is Phase 2b).
|
||||
* Who triggers it: PRAutoBlogger_Dossier_Data_Assembler::assemble().
|
||||
* Dependencies: get_children(), attachment meta, wp_get_attachment_image_url().
|
||||
*
|
||||
* @see admin/class-dossier-data-assembler.php — Caller.
|
||||
* @see templates/admin/dossier-log-stage-section.php — Renders the pairs.
|
||||
* @see ARCHITECTURE.md #24 — M3 design (F3 note).
|
||||
*/
|
||||
class PRAutoBlogger_Dossier_Image_Data {
|
||||
|
||||
/**
|
||||
* The post's pipeline attachments (featured/og/square) via the
|
||||
* `_prautoblogger_image_role` attachment meta — the image sections'
|
||||
* real data source (QA M2 F3).
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @return array<int, array{id: int, role: string, url: string, is_featured: bool}>
|
||||
*/
|
||||
public static function for_post( int $post_id ): array {
|
||||
$featured_id = (int) get_post_thumbnail_id( $post_id );
|
||||
$children = get_children(
|
||||
array(
|
||||
'post_parent' => $post_id,
|
||||
'post_type' => 'attachment',
|
||||
'numberposts' => 12,
|
||||
)
|
||||
);
|
||||
|
||||
$images = array();
|
||||
$seen = array();
|
||||
foreach ( (array) $children as $attachment ) {
|
||||
$att_id = (int) ( $attachment->ID ?? 0 );
|
||||
if ( $att_id <= 0 ) {
|
||||
continue;
|
||||
}
|
||||
$role = (string) get_post_meta( $att_id, '_prautoblogger_image_role', true );
|
||||
if ( '' === $role && $att_id !== $featured_id ) {
|
||||
continue; // Not a pipeline attachment.
|
||||
}
|
||||
$url = (string) wp_get_attachment_image_url( $att_id, 'medium' );
|
||||
if ( '' === $url ) {
|
||||
continue;
|
||||
}
|
||||
$seen[ $att_id ] = true;
|
||||
$images[] = array(
|
||||
'id' => $att_id,
|
||||
'role' => '' !== $role ? $role : 'featured',
|
||||
'url' => $url,
|
||||
'is_featured' => $att_id === $featured_id,
|
||||
);
|
||||
}
|
||||
// Featured image attached elsewhere (not a child) still renders.
|
||||
if ( $featured_id > 0 && ! isset( $seen[ $featured_id ] ) ) {
|
||||
$url = (string) wp_get_attachment_image_url( $featured_id, 'medium' );
|
||||
if ( '' !== $url ) {
|
||||
$images[] = array(
|
||||
'id' => $featured_id,
|
||||
'role' => 'featured',
|
||||
'url' => $url,
|
||||
'is_featured' => true,
|
||||
);
|
||||
}
|
||||
}
|
||||
return $images;
|
||||
}
|
||||
}
|
||||
172
includes/admin/class-dossier-page.php
Normal file
172
includes/admin/class-dossier-page.php
Normal file
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Registers and renders the Article Dossier admin page (M2).
|
||||
*
|
||||
* The dossier is link-accessed -- it is NOT a visible submenu item. It uses
|
||||
* the canonical hidden-admin-page pattern: register under parent 'options.php'
|
||||
* so WordPress computes the hookname as `admin_page_prautoblogger-dossier` at
|
||||
* BOTH registration time AND request time. The page is hidden from nav by
|
||||
* construction (options.php is a built-in WP page with no visible submenu block)
|
||||
* so no $submenu mutation is needed or permitted.
|
||||
*
|
||||
* Why NOT the hide-by-unset pattern (the v0.19.2 bug):
|
||||
* add_submenu_page('prautoblogger-settings', ...) → hookname 'prautoblogger_page_prautoblogger-dossier'
|
||||
* post-registration unset($submenu['prautoblogger-settings'][$idx])
|
||||
* → at request time get_admin_page_parent() no longer finds the slug in $submenu
|
||||
* → WP recomputes hookname in the 'admin_page_*' orphan namespace
|
||||
* → no callback registered there → wp_die(403).
|
||||
* See ARCHITECTURE.md §22b + CONVENTIONS.md §Hidden Admin Pages for the full incident
|
||||
* history (board 404 v0.19.1, dossier 403 v0.19.2).
|
||||
*
|
||||
* Board cards and post-metabox deep-link here via
|
||||
* admin.php?page=prautoblogger-dossier&post_id=X. Deep-link URLs are parent-agnostic
|
||||
* (they reference only the page slug) so they are unchanged by this fix.
|
||||
*
|
||||
* Design contract: Proposal C "Editorial Record" -- warm editorial dossier,
|
||||
* verdict pills, receipt-style cost sidebar, per-stage I/O with raw-trace toggle.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger::register_admin_hooks() on `admin_menu` (priority 12).
|
||||
* Dependencies: PRAutoBlogger_Dossier_Data_Assembler, templates/admin/dossier-page.php.
|
||||
*
|
||||
* @see admin/class-dossier-data-assembler.php -- Builds the view model.
|
||||
* @see templates/admin/dossier-page.php -- HTML template.
|
||||
* @see ARCHITECTURE.md -- §22b (hidden admin page convention).
|
||||
* @see CONVENTIONS.md -- §Hidden Admin Pages.
|
||||
*/
|
||||
class PRAutoBlogger_Dossier_Page {
|
||||
|
||||
/** Admin page slug. */
|
||||
public const PAGE_SLUG = 'prautoblogger-dossier';
|
||||
|
||||
/**
|
||||
* Parent slug used at registration.
|
||||
* 'options.php' is the canonical WP hidden-page parent: the hookname resolves
|
||||
* to `admin_page_{slug}` at both registration time and request time, so there is
|
||||
* no hookname mismatch and no need to manipulate $submenu.
|
||||
*/
|
||||
public const PARENT_SLUG = 'options.php';
|
||||
|
||||
/** Nonce action for any future stateful actions. */
|
||||
public const NONCE_ACTION = 'prautoblogger_dossier';
|
||||
|
||||
/**
|
||||
* Register the dossier as a hidden admin page.
|
||||
*
|
||||
* Uses the canonical options.php-parent pattern so the hookname is stable at
|
||||
* both registration time and request time. No $submenu manipulation required
|
||||
* or permitted (see CONVENTIONS.md §Hidden Admin Pages).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function on_register_menu(): void {
|
||||
add_submenu_page(
|
||||
self::PARENT_SLUG,
|
||||
__( 'Article Dossier', 'prautoblogger' ),
|
||||
__( 'Dossier', 'prautoblogger' ),
|
||||
'manage_options',
|
||||
self::PAGE_SLUG,
|
||||
array( $this, 'render_page' )
|
||||
);
|
||||
// No $submenu mutation. options.php-parent pages are hidden by construction.
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the dossier page.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function render_page(): void {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
return;
|
||||
}
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only dossier; no state changes.
|
||||
$post_id = isset( $_GET['post_id'] ) ? absint( $_GET['post_id'] ) : 0;
|
||||
$assembler = new PRAutoBlogger_Dossier_Data_Assembler();
|
||||
$dossier = $assembler->assemble( $post_id );
|
||||
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/dossier-page.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue dossier-specific CSS + JS.
|
||||
*
|
||||
* The hook suffix for an options.php-parent page is `admin_page_{slug}`.
|
||||
*
|
||||
* @param string $hook_suffix Current admin page hook suffix.
|
||||
* @return void
|
||||
*/
|
||||
public function on_enqueue_assets( string $hook_suffix ): void {
|
||||
// options.php-parent pages resolve to 'admin_page_{slug}' (not 'prautoblogger_page_*').
|
||||
$dossier_hook = 'admin_page_' . self::PAGE_SLUG;
|
||||
if ( $hook_suffix !== $dossier_hook ) {
|
||||
return;
|
||||
}
|
||||
|
||||
wp_enqueue_style(
|
||||
'prautoblogger-dossier',
|
||||
PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/dossier.css',
|
||||
array( 'prautoblogger-admin' ),
|
||||
PRAUTOBLOGGER_VERSION
|
||||
);
|
||||
|
||||
wp_enqueue_script(
|
||||
'prautoblogger-dossier',
|
||||
PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/dossier.js',
|
||||
array(),
|
||||
PRAUTOBLOGGER_VERSION,
|
||||
true
|
||||
);
|
||||
|
||||
// M3: edit + re-run layer (separate files; dossier.css is not grown).
|
||||
wp_enqueue_style(
|
||||
'prautoblogger-dossier-edit',
|
||||
PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/dossier-edit.css',
|
||||
array( 'prautoblogger-dossier' ),
|
||||
PRAUTOBLOGGER_VERSION
|
||||
);
|
||||
wp_enqueue_script(
|
||||
'prautoblogger-dossier-edit',
|
||||
PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/dossier-edit.js',
|
||||
array(),
|
||||
PRAUTOBLOGGER_VERSION,
|
||||
true
|
||||
);
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only context resolution for script config.
|
||||
$post_id = isset( $_GET['post_id'] ) ? absint( $_GET['post_id'] ) : 0;
|
||||
wp_localize_script(
|
||||
'prautoblogger-dossier-edit',
|
||||
'prabDossierEdit',
|
||||
array(
|
||||
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||
'nonce' => wp_create_nonce( PRAutoBlogger_Dossier_Actions::NONCE_ACTION ),
|
||||
'postId' => $post_id,
|
||||
// Settings read at point of use -- same poll setting as the board.
|
||||
'pollInterval' => max( 3, (int) get_option( 'prautoblogger_board_poll_interval', 5 ) ),
|
||||
'strings' => array(
|
||||
'saving' => __( 'Saving…', 'prautoblogger' ),
|
||||
'error' => __( 'Request failed — try again.', 'prautoblogger' ),
|
||||
'queued' => __( 'Queued…', 'prautoblogger' ),
|
||||
'rerunFromLabel' => __( 'Re-run from here', 'prautoblogger' ),
|
||||
'confirmReplay' => __( 'Queue a re-run of this stage with your edited input? This makes a new LLM call billed against this run\'s ceiling, and all downstream stages will be marked stale (nothing re-runs automatically).', 'prautoblogger' ),
|
||||
'confirmRerunFrom' => __( 'Queue a re-run of this stage AND every stage after it? Each re-run stage makes new LLM calls billed against this run\'s ceiling. The article draft will be refreshed; published posts are never touched.', 'prautoblogger' ),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the URL to the dossier for a given post.
|
||||
*
|
||||
* Deep-link URLs use admin.php?page=<slug> which is parent-agnostic.
|
||||
* This method is unchanged from v0.19.2 -- only the registration parent changed.
|
||||
*
|
||||
* @param int $post_id WordPress post ID.
|
||||
* @return string Admin URL to the dossier page.
|
||||
*/
|
||||
public static function url_for_post( int $post_id ): string {
|
||||
return admin_url( 'admin.php?page=' . self::PAGE_SLUG . '&post_id=' . $post_id );
|
||||
}
|
||||
}
|
||||
181
includes/admin/class-dossier-rerun-panel-data.php
Normal file
181
includes/admin/class-dossier-rerun-panel-data.php
Normal file
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Builds the edit + re-run view-model layer for the dossier (v0.20.0, M3).
|
||||
*
|
||||
* What: Per-stage affordance data (editable? why not? latest fork,
|
||||
* prefill messages from the fork or the stage's recorded
|
||||
* request_json) plus the run-level spend strip (settled + reserved
|
||||
* vs ceiling, warn fraction — guardrail 4's visible cost
|
||||
* implication) and the overall eligibility verdict. Pure
|
||||
* assembly: reads the rows the Dossier_Data_Assembler already
|
||||
* fetched plus the stage_inputs store; performs NO mutations.
|
||||
* Disabled affordances always carry an operator-readable reason —
|
||||
* the UI never hides why an edit is unavailable.
|
||||
* Who triggers it: PRAutoBlogger_Dossier_Data_Assembler::assemble().
|
||||
* Dependencies: Rerun_Eligibility (policy), Stage_Input_Store (forks),
|
||||
* Stage_Replay (body decode).
|
||||
*
|
||||
* @see admin/class-dossier-data-assembler.php — Caller.
|
||||
* @see templates/admin/dossier-edit-panel.php — Renders this data.
|
||||
* @see ARCHITECTURE.md #24 — Edit + re-run design.
|
||||
*/
|
||||
class PRAutoBlogger_Dossier_Rerun_Panel_Data {
|
||||
|
||||
/** Default warn threshold as a fraction of the per-run ceiling. */
|
||||
private const DEFAULT_WARN_FRACTION = 0.8;
|
||||
|
||||
/**
|
||||
* Run-level spend strip data (guardrail 4 visibility).
|
||||
*
|
||||
* @param array<string, mixed>|null $run Run ledger row, or null.
|
||||
* @return array{settled: float, reserved: float, ceiling: float, fraction: float, warn: bool}
|
||||
*/
|
||||
public static function spend( ?array $run ): array {
|
||||
$settled = null !== $run ? (float) ( $run['settled_usd'] ?? 0 ) : 0.0;
|
||||
$reserved = null !== $run ? (float) ( $run['reserved_usd'] ?? 0 ) : 0.0;
|
||||
$ceiling = null !== $run ? (float) ( $run['ceiling_usd'] ?? 0 ) : 0.0;
|
||||
$fraction = $ceiling > 0 ? ( $settled + $reserved ) / $ceiling : 0.0;
|
||||
|
||||
/**
|
||||
* Filters the spend fraction at which re-run confirms warn about
|
||||
* approaching the per-run ceiling.
|
||||
*
|
||||
* @param float $warn_fraction Fraction of ceiling (default 0.8).
|
||||
*/
|
||||
$warn_fraction = (float) apply_filters( 'prautoblogger_rerun_ceiling_warn_fraction', self::DEFAULT_WARN_FRACTION );
|
||||
|
||||
return array(
|
||||
'settled' => $settled,
|
||||
'reserved' => $reserved,
|
||||
'ceiling' => $ceiling,
|
||||
'fraction' => $fraction,
|
||||
'warn' => $ceiling > 0 && $fraction >= $warn_fraction,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit + re-run affordance data for one stage row.
|
||||
*
|
||||
* @param array<string, mixed> $stage_row run_stages row.
|
||||
* @param array<int, array<string, mixed>> $log_rows generation_log rows for the stage.
|
||||
* @param string $run_id Run UUID.
|
||||
* @param array{ok: bool, reason: string} $eligibility Overall run eligibility.
|
||||
* @return array<string, mixed> Affordance view model (see keys below).
|
||||
*/
|
||||
public static function for_stage( array $stage_row, array $log_rows, string $run_id, array $eligibility ): array {
|
||||
$stage = (string) ( $stage_row['stage'] ?? '' );
|
||||
$role = (string) ( $stage_row['agent_role'] ?? '' );
|
||||
$item_key = (string) ( $stage_row['item_key'] ?? '' );
|
||||
|
||||
$data = array(
|
||||
'stale' => ! empty( $stage_row['stale'] ),
|
||||
'human_modified' => ! empty( $stage_row['human_modified'] ),
|
||||
'attempt' => (int) ( $stage_row['attempt'] ?? 1 ),
|
||||
'editable' => false,
|
||||
'edit_reason' => '',
|
||||
'fork' => null,
|
||||
'prefill' => null,
|
||||
'rerun_from' => $eligibility['ok'] && PRAutoBlogger_Rerun_Eligibility::is_chain_stage( $stage ),
|
||||
);
|
||||
|
||||
if ( ! PRAutoBlogger_Rerun_Eligibility::is_editable_stage( $stage ) ) {
|
||||
$data['edit_reason'] = self::policy_reason( $stage );
|
||||
return $data;
|
||||
}
|
||||
if ( ! $eligibility['ok'] ) {
|
||||
$data['edit_reason'] = $eligibility['reason'];
|
||||
return $data;
|
||||
}
|
||||
|
||||
$fork = PRAutoBlogger_Stage_Input_Store::latest_fork( $run_id, $stage, $role, $item_key );
|
||||
if ( null !== $fork ) {
|
||||
$data['fork'] = array(
|
||||
'version' => (int) $fork['version'],
|
||||
'author' => (string) $fork['author'],
|
||||
'created_at' => (string) $fork['created_at'],
|
||||
'has_body' => ! empty( $fork['request_json'] ),
|
||||
);
|
||||
}
|
||||
|
||||
$base_json = ( null !== $fork && ! empty( $fork['request_json'] ) )
|
||||
? (string) $fork['request_json']
|
||||
: self::latest_request_json( $log_rows );
|
||||
|
||||
if ( null === $base_json ) {
|
||||
$days = (int) get_option( 'prautoblogger_request_json_retention_days', PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS );
|
||||
$data['edit_reason'] = sprintf(
|
||||
/* translators: %d: retention days. */
|
||||
__( 'No persisted input for this stage — request bodies are recorded from v0.20.0 onward and retained for %d days.', 'prautoblogger' ),
|
||||
$days
|
||||
);
|
||||
return $data;
|
||||
}
|
||||
|
||||
$body = PRAutoBlogger_Stage_Replay::decode_body( $base_json );
|
||||
if ( null === $body ) {
|
||||
$data['edit_reason'] = __( 'The persisted input for this stage is not a replayable chat request.', 'prautoblogger' );
|
||||
return $data;
|
||||
}
|
||||
|
||||
$data['editable'] = true;
|
||||
$data['prefill'] = array(
|
||||
'model' => (string) $body['model'],
|
||||
'messages' => $body['messages'],
|
||||
);
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a non-writer stage has no edit affordance (always shown —
|
||||
* the UI never silently hides the option).
|
||||
*
|
||||
* @param string $stage Stage name.
|
||||
* @return string Operator-readable reason.
|
||||
*/
|
||||
private static function policy_reason( string $stage ): string {
|
||||
if ( 'review' === $stage ) {
|
||||
return __( 'The review input is derived entirely from the article content — edit a writing stage, then use "Re-run from here" on this one.', 'prautoblogger' );
|
||||
}
|
||||
if ( 'publish' === $stage ) {
|
||||
return __( 'Publish is not an LLM stage — use "Re-run from here" to refresh the post from current outputs.', 'prautoblogger' );
|
||||
}
|
||||
if ( in_array( $stage, array( 'analysis', 'research', 'llm_research' ), true ) ) {
|
||||
return __( 'Run-level stage: its output fed idea selection for the whole run and cannot be re-flowed into a single article.', 'prautoblogger' );
|
||||
}
|
||||
if ( 0 === strpos( $stage, 'image' ) ) {
|
||||
return __( 'Image stages are not replayable yet (Phase 2b restructure).', 'prautoblogger' );
|
||||
}
|
||||
return __( 'This stage cannot be replayed with an edited input.', 'prautoblogger' );
|
||||
}
|
||||
|
||||
/**
|
||||
* The most recent recorded request body among a stage's log rows
|
||||
* (success rows preferred, then latest id).
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $log_rows generation_log rows.
|
||||
* @return string|null
|
||||
*/
|
||||
private static function latest_request_json( array $log_rows ): ?string {
|
||||
$best = null;
|
||||
foreach ( $log_rows as $row ) {
|
||||
if ( empty( $row['request_json'] ) ) {
|
||||
continue;
|
||||
}
|
||||
if ( null === $best ) {
|
||||
$best = $row;
|
||||
continue;
|
||||
}
|
||||
$row_success = ( 'success' === ( $row['response_status'] ?? '' ) );
|
||||
$best_success = ( 'success' === ( $best['response_status'] ?? '' ) );
|
||||
if ( ( $row_success && ! $best_success )
|
||||
|| ( $row_success === $best_success && (int) ( $row['id'] ?? 0 ) > (int) ( $best['id'] ?? 0 ) ) ) {
|
||||
$best = $row;
|
||||
}
|
||||
}
|
||||
return null !== $best ? (string) $best['request_json'] : null;
|
||||
}
|
||||
}
|
||||
136
includes/admin/class-ideas-browser-query.php
Normal file
136
includes/admin/class-ideas-browser-query.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Query helpers for PRAutoBlogger_Ideas_Browser.
|
||||
*
|
||||
* Extracted to keep class-ideas-browser.php under 300 lines (CONVENTIONS §1).
|
||||
* All methods are static; class is never instantiated.
|
||||
*/
|
||||
class PRAutoBlogger_Ideas_Browser_Query {
|
||||
|
||||
/** Fall-through if setting is absent. */
|
||||
public const PER_PAGE_DEFAULT = 30;
|
||||
|
||||
/** Transient key prefix for per-idea generation status. */
|
||||
public const STATUS_PREFIX = 'prab_idea_gen_';
|
||||
|
||||
/** How long to keep per-idea status (seconds). */
|
||||
public const STATUS_TTL = 600;
|
||||
|
||||
/** Query analysis results with optional filtering and pagination. */
|
||||
public static function query_ideas( int $paged, string $type ): array {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
|
||||
$where = array();
|
||||
$params = array();
|
||||
|
||||
if ( '' !== $type ) {
|
||||
$where[] = 'analysis_type = %s';
|
||||
$params[] = $type;
|
||||
}
|
||||
|
||||
$where_sql = empty( $where ) ? '' : 'WHERE ' . implode( ' AND ', $where );
|
||||
$per_page = max( 5, (int) get_option( 'prautoblogger_ideas_per_page', self::PER_PAGE_DEFAULT ) );
|
||||
$offset = ( $paged - 1 ) * $per_page;
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
empty( $params ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||
? "SELECT COUNT(*) FROM {$table} {$where_sql}" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||
: $wpdb->prepare( "SELECT COUNT(*) FROM {$table} {$where_sql}", ...$params ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
||||
);
|
||||
|
||||
$order_sql = 'ORDER BY analyzed_at DESC, relevance_score DESC';
|
||||
$limit_sql = sprintf( 'LIMIT %d OFFSET %d', $per_page, $offset );
|
||||
$full_sql = "SELECT * FROM {$table} {$where_sql} {$order_sql} {$limit_sql}";
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$rows = empty( $params ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||
? $wpdb->get_results( $full_sql, ARRAY_A ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||
: $wpdb->get_results( $wpdb->prepare( $full_sql, ...$params ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
||||
|
||||
return array(
|
||||
'rows' => $rows ?? array(),
|
||||
'total' => $total,
|
||||
);
|
||||
}
|
||||
|
||||
/** Get counts per analysis_type for the filter badges. */
|
||||
public static function get_type_counts(): array {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
"SELECT analysis_type, COUNT(*) AS cnt FROM {$table} GROUP BY analysis_type ORDER BY cnt DESC",
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
$counts = array();
|
||||
foreach ( $rows ?? array() as $row ) {
|
||||
$counts[ $row['analysis_type'] ] = (int) $row['cnt'];
|
||||
}
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an analysis result row and map it to Article_Idea constructor data.
|
||||
*
|
||||
* @param int $idea_id Analysis result row ID.
|
||||
* @return array<string, mixed>|null Article_Idea-compatible array, or null.
|
||||
*/
|
||||
public static function load_idea_data( int $idea_id ): ?array {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$row = $wpdb->get_row( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $idea_id ),
|
||||
ARRAY_A
|
||||
);
|
||||
if ( ! $row ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$meta = json_decode( $row['metadata_json'] ?? '{}', true ) ?? array();
|
||||
return array(
|
||||
'topic' => $row['topic'],
|
||||
'article_type' => $row['analysis_type'],
|
||||
'suggested_title' => $meta['suggested_title'] ?? $row['topic'],
|
||||
'summary' => $row['summary'] ?? '',
|
||||
'score' => (float) $row['relevance_score'],
|
||||
'analysis_id' => (int) $row['id'],
|
||||
'source_ids' => json_decode( $row['source_ids_json'] ?? '[]', true ) ?? array(),
|
||||
'key_points' => $meta['key_points'] ?? array(),
|
||||
'target_keywords' => $meta['target_keywords'] ?? array(),
|
||||
);
|
||||
}
|
||||
|
||||
/** Find the most recent post created by a specific run_id. */
|
||||
public static function find_post_by_run_id( string $run_id ): ?int {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$post_id = $wpdb->get_var(
|
||||
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
"SELECT DISTINCT post_id FROM {$table} WHERE run_id = %s AND post_id IS NOT NULL LIMIT 1",
|
||||
$run_id
|
||||
)
|
||||
);
|
||||
return null !== $post_id ? (int) $post_id : null;
|
||||
}
|
||||
|
||||
/** Update the per-idea status transient with a stage message. */
|
||||
public static function update_idea_stage( int $idea_id, string $stage ): void {
|
||||
$key = self::STATUS_PREFIX . $idea_id;
|
||||
$current = get_transient( $key );
|
||||
if ( is_array( $current ) ) {
|
||||
$current['stage'] = $stage;
|
||||
set_transient( $key, $current, self::STATUS_TTL );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,20 +8,14 @@ declare(strict_types=1);
|
||||
*
|
||||
* Triggered by: PRAutoBlogger::register_admin_hooks() on `admin_menu`.
|
||||
* Dependencies: WordPress $wpdb, Article_Worker, Cost_Tracker, Generation_Lock.
|
||||
* Query helpers: class-ideas-browser-query.php (split for 300-line rule, CONVENTIONS §1).
|
||||
*
|
||||
* @see core/class-article-worker.php — Generates articles from ideas.
|
||||
* @see templates/admin/ideas-browser.php — Renders the HTML.
|
||||
* @see class-ideas-browser-query.php — Static query helpers.
|
||||
*/
|
||||
class PRAutoBlogger_Ideas_Browser {
|
||||
|
||||
private const PER_PAGE = 30;
|
||||
|
||||
/** Transient prefix for per-idea generation status. */
|
||||
private const STATUS_PREFIX = 'prab_idea_gen_';
|
||||
|
||||
/** How long to keep per-idea status (seconds). */
|
||||
private const STATUS_TTL = 600;
|
||||
|
||||
/** Register the Ideas submenu page under PRAutoBlogger. */
|
||||
public function on_register_menu(): void {
|
||||
add_submenu_page(
|
||||
@@ -43,11 +37,12 @@ class PRAutoBlogger_Ideas_Browser {
|
||||
$paged = isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1;
|
||||
$type_filter = isset( $_GET['type'] ) ? sanitize_key( $_GET['type'] ) : '';
|
||||
|
||||
$result = $this->query_ideas( $paged, $type_filter );
|
||||
$rows = $result['rows'];
|
||||
$total = $result['total'];
|
||||
$total_pages = (int) ceil( $total / self::PER_PAGE );
|
||||
$type_counts = $this->get_type_counts();
|
||||
$result = PRAutoBlogger_Ideas_Browser_Query::query_ideas( $paged, $type_filter );
|
||||
$rows = $result['rows'];
|
||||
$total = $result['total'];
|
||||
$per_page_view = max( 5, (int) get_option( 'prautoblogger_ideas_per_page', PRAutoBlogger_Ideas_Browser_Query::PER_PAGE_DEFAULT ) );
|
||||
$total_pages = (int) ceil( $total / $per_page_view );
|
||||
$type_counts = PRAutoBlogger_Ideas_Browser_Query::get_type_counts();
|
||||
|
||||
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/ideas-browser.php';
|
||||
}
|
||||
@@ -74,23 +69,26 @@ class PRAutoBlogger_Ideas_Browser {
|
||||
return;
|
||||
}
|
||||
|
||||
$status_prefix = PRAutoBlogger_Ideas_Browser_Query::STATUS_PREFIX;
|
||||
$status_ttl = PRAutoBlogger_Ideas_Browser_Query::STATUS_TTL;
|
||||
|
||||
// Build Article_Idea from the analysis row.
|
||||
$idea_data = self::load_idea_data( $idea_id );
|
||||
$idea_data = PRAutoBlogger_Ideas_Browser_Query::load_idea_data( $idea_id );
|
||||
if ( null === $idea_data ) {
|
||||
wp_send_json_error( array( 'message' => 'Idea not found.' ) );
|
||||
return;
|
||||
}
|
||||
|
||||
// Store idea for the cron handler and set initial "running" status.
|
||||
set_transient( self::STATUS_PREFIX . 'data_' . $idea_id, $idea_data, self::STATUS_TTL );
|
||||
set_transient( $status_prefix . 'data_' . $idea_id, $idea_data, $status_ttl );
|
||||
set_transient(
|
||||
self::STATUS_PREFIX . $idea_id,
|
||||
$status_prefix . $idea_id,
|
||||
array(
|
||||
'status' => 'running',
|
||||
'stage' => __( 'Starting generation…', 'prautoblogger' ),
|
||||
'started' => time(),
|
||||
),
|
||||
self::STATUS_TTL
|
||||
$status_ttl
|
||||
);
|
||||
|
||||
// Schedule background cron — passes idea_id as argument.
|
||||
@@ -124,7 +122,7 @@ class PRAutoBlogger_Ideas_Browser {
|
||||
}
|
||||
|
||||
$idea_id = absint( $_POST['idea_id'] ?? 0 );
|
||||
$status = get_transient( self::STATUS_PREFIX . $idea_id );
|
||||
$status = get_transient( PRAutoBlogger_Ideas_Browser_Query::STATUS_PREFIX . $idea_id );
|
||||
if ( ! is_array( $status ) ) {
|
||||
wp_send_json_success( array( 'status' => 'idle' ) );
|
||||
return;
|
||||
@@ -149,16 +147,19 @@ class PRAutoBlogger_Ideas_Browser {
|
||||
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
@set_time_limit( 300 );
|
||||
|
||||
$key = self::STATUS_PREFIX . $idea_id;
|
||||
$idea_data = get_transient( self::STATUS_PREFIX . 'data_' . $idea_id );
|
||||
$prefix = PRAutoBlogger_Ideas_Browser_Query::STATUS_PREFIX;
|
||||
$ttl = PRAutoBlogger_Ideas_Browser_Query::STATUS_TTL;
|
||||
$key = $prefix . $idea_id;
|
||||
|
||||
$idea_data = get_transient( $prefix . 'data_' . $idea_id );
|
||||
if ( ! is_array( $idea_data ) ) {
|
||||
set_transient(
|
||||
$key,
|
||||
array(
|
||||
'status' => 'error',
|
||||
'status' => 'error',
|
||||
'message' => 'Idea data expired.',
|
||||
),
|
||||
self::STATUS_TTL
|
||||
$ttl
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -167,10 +168,10 @@ class PRAutoBlogger_Ideas_Browser {
|
||||
set_transient(
|
||||
$key,
|
||||
array(
|
||||
'status' => 'error',
|
||||
'status' => 'error',
|
||||
'message' => 'Another generation is running.',
|
||||
),
|
||||
self::STATUS_TTL
|
||||
$ttl
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -180,7 +181,7 @@ class PRAutoBlogger_Ideas_Browser {
|
||||
$cost_tracker = new PRAutoBlogger_Cost_Tracker();
|
||||
$cost_tracker->set_run_id( wp_generate_uuid4() );
|
||||
|
||||
self::update_idea_stage( $idea_id, __( 'Generating article draft…', 'prautoblogger' ) );
|
||||
PRAutoBlogger_Ideas_Browser_Query::update_idea_stage( $idea_id, __( 'Generating article draft…', 'prautoblogger' ) );
|
||||
$worker = new PRAutoBlogger_Article_Worker( $cost_tracker );
|
||||
$result = $worker->generate( $idea );
|
||||
|
||||
@@ -188,7 +189,7 @@ class PRAutoBlogger_Ideas_Browser {
|
||||
PRAutoBlogger_Post_Assembler::amortize_research_costs( $cost_tracker->get_run_id() );
|
||||
|
||||
// Find the generated post ID for the "View" link.
|
||||
$post_id = self::find_post_by_run_id( $cost_tracker->get_run_id() );
|
||||
$post_id = PRAutoBlogger_Ideas_Browser_Query::find_post_by_run_id( $cost_tracker->get_run_id() );
|
||||
|
||||
set_transient(
|
||||
$key,
|
||||
@@ -199,7 +200,7 @@ class PRAutoBlogger_Ideas_Browser {
|
||||
'cost' => $result['cost'],
|
||||
'post_id' => $post_id,
|
||||
),
|
||||
self::STATUS_TTL
|
||||
$ttl
|
||||
);
|
||||
} catch ( \Throwable $e ) {
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
@@ -209,129 +210,14 @@ class PRAutoBlogger_Ideas_Browser {
|
||||
set_transient(
|
||||
$key,
|
||||
array(
|
||||
'status' => 'error',
|
||||
'status' => 'error',
|
||||
'message' => $e->getMessage(),
|
||||
),
|
||||
self::STATUS_TTL
|
||||
$ttl
|
||||
);
|
||||
}
|
||||
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
delete_transient( self::STATUS_PREFIX . 'data_' . $idea_id );
|
||||
}
|
||||
|
||||
// ── Private helpers ─────────────────────────────────────────────────
|
||||
|
||||
/** Update the per-idea status transient with a stage message. */
|
||||
private static function update_idea_stage( int $idea_id, string $stage ): void {
|
||||
$key = self::STATUS_PREFIX . $idea_id;
|
||||
$current = get_transient( $key );
|
||||
if ( is_array( $current ) ) {
|
||||
$current['stage'] = $stage;
|
||||
set_transient( $key, $current, self::STATUS_TTL );
|
||||
}
|
||||
}
|
||||
|
||||
/** Find the most recent post created by a specific run_id. */
|
||||
private static function find_post_by_run_id( string $run_id ): ?int {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$post_id = $wpdb->get_var(
|
||||
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
"SELECT DISTINCT post_id FROM {$table} WHERE run_id = %s AND post_id IS NOT NULL LIMIT 1",
|
||||
$run_id
|
||||
)
|
||||
);
|
||||
return null !== $post_id ? (int) $post_id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an analysis result row and map it to Article_Idea constructor data.
|
||||
*
|
||||
* @param int $idea_id Analysis result row ID.
|
||||
* @return array<string, mixed>|null Article_Idea-compatible array, or null.
|
||||
*/
|
||||
private static function load_idea_data( int $idea_id ): ?array {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$row = $wpdb->get_row( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $idea_id ),
|
||||
ARRAY_A
|
||||
);
|
||||
if ( ! $row ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$meta = json_decode( $row['metadata_json'] ?? '{}', true ) ?? array();
|
||||
return array(
|
||||
'topic' => $row['topic'],
|
||||
'article_type' => $row['analysis_type'],
|
||||
'suggested_title' => $meta['suggested_title'] ?? $row['topic'],
|
||||
'summary' => $row['summary'] ?? '',
|
||||
'score' => (float) $row['relevance_score'],
|
||||
'analysis_id' => (int) $row['id'],
|
||||
'source_ids' => json_decode( $row['source_ids_json'] ?? '[]', true ) ?? array(),
|
||||
'key_points' => $meta['key_points'] ?? array(),
|
||||
'target_keywords' => $meta['target_keywords'] ?? array(),
|
||||
);
|
||||
}
|
||||
|
||||
/** Query analysis results with optional filtering and pagination. */
|
||||
private function query_ideas( int $paged, string $type ): array {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
|
||||
$where = array();
|
||||
$params = array();
|
||||
|
||||
if ( '' !== $type ) {
|
||||
$where[] = 'analysis_type = %s';
|
||||
$params[] = $type;
|
||||
}
|
||||
|
||||
$where_sql = empty( $where ) ? '' : 'WHERE ' . implode( ' AND ', $where );
|
||||
$offset = ( $paged - 1 ) * self::PER_PAGE;
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
empty( $params ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||
? "SELECT COUNT(*) FROM {$table} {$where_sql}" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||
: $wpdb->prepare( "SELECT COUNT(*) FROM {$table} {$where_sql}", ...$params ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
||||
);
|
||||
|
||||
$order_sql = 'ORDER BY analyzed_at DESC, relevance_score DESC';
|
||||
$limit_sql = sprintf( 'LIMIT %d OFFSET %d', self::PER_PAGE, $offset );
|
||||
$full_sql = "SELECT * FROM {$table} {$where_sql} {$order_sql} {$limit_sql}";
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$rows = empty( $params ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||
? $wpdb->get_results( $full_sql, ARRAY_A ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||
: $wpdb->get_results( $wpdb->prepare( $full_sql, ...$params ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
||||
|
||||
return array(
|
||||
'rows' => $rows ?? array(),
|
||||
'total' => $total,
|
||||
);
|
||||
}
|
||||
|
||||
/** Get counts per analysis_type for the filter badges. */
|
||||
private function get_type_counts(): array {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
"SELECT analysis_type, COUNT(*) AS cnt FROM {$table} GROUP BY analysis_type ORDER BY cnt DESC",
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
$counts = array();
|
||||
foreach ( $rows ?? array() as $row ) {
|
||||
$counts[ $row['analysis_type'] ] = (int) $row['cnt'];
|
||||
}
|
||||
return $counts;
|
||||
delete_transient( $prefix . 'data_' . $idea_id );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,21 +4,22 @@ declare(strict_types=1);
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Adds a metabox to posts showing PRAutoBlogger generation metadata.
|
||||
* Adds a metabox to PRAutoBlogger-generated posts.
|
||||
*
|
||||
* Only appears on posts that were generated by PRAutoBlogger (checked via
|
||||
* _prautoblogger_generated post meta).
|
||||
* M2: Slimmed to a "View generation dossier" link. Full generation metadata
|
||||
* lives on the Article Dossier page (PRAutoBlogger_Dossier_Page).
|
||||
*
|
||||
* Triggered by: PRAutoBlogger::register_admin_hooks() on `add_meta_boxes`.
|
||||
* Dependencies: WordPress post meta API.
|
||||
* Dependencies: PRAutoBlogger_Dossier_Page (for URL helper).
|
||||
*
|
||||
* @see class-prautoblogger.php — Registers the hook.
|
||||
* @see core/class-publisher.php — Writes the metadata this class displays.
|
||||
* @see class-prautoblogger.php -- Registers the hook.
|
||||
* @see admin/class-dossier-page.php -- Dossier target; provides url_for_post().
|
||||
* @see core/class-publisher.php -- Writes the run_id meta this class reads.
|
||||
*/
|
||||
class PRAutoBlogger_Post_Metabox {
|
||||
|
||||
/**
|
||||
* Register the metabox for the post editor.
|
||||
* Register the metabox for PRAutoBlogger-generated posts only.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
@@ -35,7 +36,7 @@ class PRAutoBlogger_Post_Metabox {
|
||||
|
||||
add_meta_box(
|
||||
'prautoblogger_generation_info',
|
||||
__( 'PRAutoBlogger Generation Info', 'prautoblogger' ),
|
||||
__( 'PRAutoBlogger', 'prautoblogger' ),
|
||||
array( $this, 'render_metabox' ),
|
||||
'post',
|
||||
'side',
|
||||
@@ -44,13 +45,12 @@ class PRAutoBlogger_Post_Metabox {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the generation metadata metabox.
|
||||
*
|
||||
* @param \WP_Post $post
|
||||
* Render the dossier-link metabox.
|
||||
*
|
||||
* @param \WP_Post $post Current post.
|
||||
* @return void
|
||||
*/
|
||||
public function render_metabox( \WP_Post $post ): void {
|
||||
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/metabox-generation-info.php';
|
||||
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/metabox-dossier-link.php';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,290 +35,194 @@ class PRAutoBlogger_Settings_Fields_Extended {
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function get_fields(): array {
|
||||
return array(
|
||||
// ── Sources (extended) ──────────────────────────────────────
|
||||
return array_merge(
|
||||
array(
|
||||
'id' => 'prautoblogger_research_model',
|
||||
'label' => __( 'Research Model', 'prautoblogger' ),
|
||||
'type' => 'model_select',
|
||||
'section' => 'prautoblogger_sources',
|
||||
'default' => PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL,
|
||||
'capability' => 'text→text',
|
||||
'description' => __( 'Model for LLM Deep Research. Pick a reasoning-capable model (e.g. Grok 4.1 Fast, DeepSeek-R1) for best results. Only used when LLM Deep Research is enabled above.', 'prautoblogger' ),
|
||||
'badge' => __( 'Reasoning', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_research_prompt',
|
||||
'label' => __( 'Research Prompt', 'prautoblogger' ),
|
||||
'type' => 'textarea',
|
||||
'section' => 'prautoblogger_sources',
|
||||
'default' => 'Conduct a deep research sweep of the {niche} space and identify the most substantive, actionable topics that practitioners and curious newcomers are searching for or actively discussing.' . "\n\n" . 'Focus on:' . "\n" . '- Practical questions real readers would search for (not theoretical debates)' . "\n" . '- Emerging trends gaining momentum in the community' . "\n" . '- Common misconceptions that deserve clarification' . "\n" . '- Comparative analysis (product vs. product, protocol vs. protocol)' . "\n" . '- Safety and efficacy topics that drive concern or curiosity' . "\n" . '- Beginner-to-advanced mix — cover entry-level and depth-focused topics' . "\n\n" . 'Aim for topics that feel timely, underserved by existing content, and likely to drive reader engagement. Return 8-12 findings with detailed analysis in each.',
|
||||
'description' => __( 'The research brief sent to the LLM. Use {niche} as a placeholder for your niche description. The system prompt handles output format — this controls WHAT to research.', 'prautoblogger' ),
|
||||
),
|
||||
|
||||
// ── AI Models (extended) ────────────────────────────────────
|
||||
array(
|
||||
'id' => 'prautoblogger_reasoning_enabled',
|
||||
'label' => __( 'Enable Reasoning', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_models',
|
||||
'default' => '0',
|
||||
'description' => __( 'Send reasoning instructions to models that support it (e.g. Grok, DeepSeek-R1). Reasoning tokens are billed as output tokens — expect higher costs per call. Models that don\'t support reasoning will ignore this.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_reasoning_effort',
|
||||
'label' => __( 'Reasoning Effort', 'prautoblogger' ),
|
||||
'type' => 'select',
|
||||
'section' => 'prautoblogger_models',
|
||||
'default' => 'medium',
|
||||
'options' => array(
|
||||
'xhigh' => __( 'Extra High — maximum depth, highest cost', 'prautoblogger' ),
|
||||
'high' => __( 'High — thorough reasoning', 'prautoblogger' ),
|
||||
'medium' => __( 'Medium — balanced (recommended)', 'prautoblogger' ),
|
||||
'low' => __( 'Low — light reasoning, lower cost', 'prautoblogger' ),
|
||||
'minimal' => __( 'Minimal — barely any reasoning', 'prautoblogger' ),
|
||||
// ── Sources (extended) ──────────────────────────────────────
|
||||
array(
|
||||
'id' => 'prautoblogger_research_model',
|
||||
'label' => __( 'Research Model', 'prautoblogger' ),
|
||||
'type' => 'model_select',
|
||||
'section' => 'prautoblogger_sources',
|
||||
'default' => PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL,
|
||||
'capability' => 'text→text',
|
||||
'description' => __( 'Model for LLM Deep Research. Pick a reasoning-capable model (e.g. Grok 4.1 Fast, DeepSeek-R1) for best results. Only used when LLM Deep Research is enabled above.', 'prautoblogger' ),
|
||||
'badge' => __( 'Reasoning', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_research_prompt',
|
||||
'label' => __( 'Research Prompt', 'prautoblogger' ),
|
||||
'type' => 'textarea',
|
||||
'section' => 'prautoblogger_sources',
|
||||
'default' => 'Conduct a deep research sweep of the {niche} space and identify the most substantive, actionable topics that practitioners and curious newcomers are searching for or actively discussing.' . "\n\n" . 'Focus on:' . "\n" . '- Practical questions real readers would search for (not theoretical debates)' . "\n" . '- Emerging trends gaining momentum in the community' . "\n" . '- Common misconceptions that deserve clarification' . "\n" . '- Comparative analysis (product vs. product, protocol vs. protocol)' . "\n" . '- Safety and efficacy topics that drive concern or curiosity' . "\n" . '- Beginner-to-advanced mix — cover entry-level and depth-focused topics' . "\n\n" . 'Aim for topics that feel timely, underserved by existing content, and likely to drive reader engagement. Return 8-12 findings with detailed analysis in each.',
|
||||
'description' => __( 'The research brief sent to the LLM. Use {niche} as a placeholder for your niche description. The system prompt handles output format — this controls WHAT to research.', 'prautoblogger' ),
|
||||
),
|
||||
'description' => __( 'How much effort the model spends reasoning before answering. Higher effort = more reasoning tokens = higher cost. Only applies when reasoning is enabled above. When the Reasoning Token Cap below is set (default), the cap is sent instead of the effort level — set the cap to 0 to use effort only.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_reasoning_max_tokens',
|
||||
'label' => __( 'Reasoning Token Cap', 'prautoblogger' ),
|
||||
'type' => 'number',
|
||||
'section' => 'prautoblogger_models',
|
||||
'default' => PRAUTOBLOGGER_DEFAULT_REASONING_MAX_TOKENS,
|
||||
'min' => 0,
|
||||
'max' => 32000,
|
||||
'description' => __( 'Hard ceiling on thinking tokens per call when reasoning is enabled. The completion budget is raised by this amount so reasoning can never crowd out the article text — an uncapped high effort can spend the entire budget thinking and return an empty draft. 0 = no cap (uncapped effort mode, not recommended).', 'prautoblogger' ),
|
||||
),
|
||||
|
||||
// ── Schedule & Budget ───────────────────────────────────────
|
||||
array(
|
||||
'id' => 'prautoblogger_daily_article_target',
|
||||
'label' => __( 'Articles per Day', 'prautoblogger' ),
|
||||
'type' => 'number',
|
||||
'section' => 'prautoblogger_schedule',
|
||||
'default' => 1,
|
||||
'min' => 1,
|
||||
'max' => 10,
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_schedule_time',
|
||||
'label' => __( 'Generation Time', 'prautoblogger' ),
|
||||
'type' => 'time',
|
||||
'section' => 'prautoblogger_schedule',
|
||||
'default' => '03:00',
|
||||
'description' => sprintf(
|
||||
/* translators: %s: site timezone string, e.g. "Asia/Singapore". */
|
||||
esc_html__( "Daily generation runs at this time in your site's configured timezone (Settings → General → Timezone). Current site timezone: %s.", 'prautoblogger' ),
|
||||
esc_html( function_exists( 'wp_timezone_string' ) ? wp_timezone_string() : 'UTC' )
|
||||
// ── AI Models (extended) ────────────────────────────────────
|
||||
array(
|
||||
'id' => 'prautoblogger_reasoning_enabled',
|
||||
'label' => __( 'Enable Reasoning', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_models',
|
||||
'default' => '0',
|
||||
'description' => __( 'Send reasoning instructions to models that support it (e.g. Grok, DeepSeek-R1). Reasoning tokens are billed as output tokens — expect higher costs per call. Models that don\'t support reasoning will ignore this.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_reasoning_effort',
|
||||
'label' => __( 'Reasoning Effort', 'prautoblogger' ),
|
||||
'type' => 'select',
|
||||
'section' => 'prautoblogger_models',
|
||||
'default' => 'medium',
|
||||
'options' => array(
|
||||
'xhigh' => __( 'Extra High — maximum depth, highest cost', 'prautoblogger' ),
|
||||
'high' => __( 'High — thorough reasoning', 'prautoblogger' ),
|
||||
'medium' => __( 'Medium — balanced (recommended)', 'prautoblogger' ),
|
||||
'low' => __( 'Low — light reasoning, lower cost', 'prautoblogger' ),
|
||||
'minimal' => __( 'Minimal — barely any reasoning', 'prautoblogger' ),
|
||||
),
|
||||
'description' => __( 'How much effort the model spends reasoning before answering. Higher effort = more reasoning tokens = higher cost. Only applies when reasoning is enabled above.', 'prautoblogger' ),
|
||||
),
|
||||
|
||||
// ── Board Settings ─────────────────────────────────────────
|
||||
array(
|
||||
'id' => 'prautoblogger_board_poll_interval',
|
||||
'label' => __( 'Board Poll Interval (seconds)', 'prautoblogger' ),
|
||||
'type' => 'number',
|
||||
'section' => 'prautoblogger_schedule',
|
||||
'default' => 5,
|
||||
'min' => 3,
|
||||
'max' => 60,
|
||||
'description' => __( 'How often the kanban board polls for status updates (seconds). Minimum 3s. Doubles automatically when no article is actively generating.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_board_published_window_days',
|
||||
'label' => __( 'Published Column Window (days)', 'prautoblogger' ),
|
||||
'type' => 'number',
|
||||
'section' => 'prautoblogger_schedule',
|
||||
'default' => 7,
|
||||
'min' => 1,
|
||||
'max' => 90,
|
||||
'description' => __( 'How many days back the Published column shows articles. Increase to see older published articles on the board.', 'prautoblogger' ),
|
||||
),
|
||||
|
||||
// ── Schedule & Budget ───────────────────────────────────────
|
||||
array(
|
||||
'id' => 'prautoblogger_daily_article_target',
|
||||
'label' => __( 'Articles per Day', 'prautoblogger' ),
|
||||
'type' => 'number',
|
||||
'section' => 'prautoblogger_schedule',
|
||||
'default' => 1,
|
||||
'min' => 1,
|
||||
'max' => 10,
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_schedule_time',
|
||||
'label' => __( 'Generation Time', 'prautoblogger' ),
|
||||
'type' => 'time',
|
||||
'section' => 'prautoblogger_schedule',
|
||||
'default' => '03:00',
|
||||
'description' => sprintf(
|
||||
/* translators: %s: site timezone string, e.g. "Asia/Singapore". */
|
||||
esc_html__( "Daily generation runs at this time in your site's configured timezone (Settings → General → Timezone). Current site timezone: %s.", 'prautoblogger' ),
|
||||
esc_html( function_exists( 'wp_timezone_string' ) ? wp_timezone_string() : 'UTC' )
|
||||
),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_monthly_budget_usd',
|
||||
'label' => __( 'Monthly Budget (USD)', 'prautoblogger' ),
|
||||
'type' => 'number',
|
||||
'section' => 'prautoblogger_schedule',
|
||||
'default' => 50,
|
||||
'min' => 0,
|
||||
'step' => '0.01',
|
||||
'description' => __( 'Hard stop when reached. 0 = unlimited.', 'prautoblogger' ),
|
||||
),
|
||||
|
||||
// ── Publishing ──────────────────────────────────────────────
|
||||
array(
|
||||
'id' => 'prautoblogger_auto_publish',
|
||||
'label' => __( 'Auto-Publish', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_publishing',
|
||||
'default' => '0',
|
||||
'description' => __( 'Automatically publish editor-approved posts. When off, all posts go to the Review Queue as drafts.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_default_author',
|
||||
'label' => __( 'Default Author', 'prautoblogger' ),
|
||||
'type' => 'author_select',
|
||||
'section' => 'prautoblogger_publishing',
|
||||
'default' => 0,
|
||||
'description' => __( 'WordPress user assigned as author of generated posts.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_default_category',
|
||||
'label' => __( 'Default Category', 'prautoblogger' ),
|
||||
'type' => 'category_select',
|
||||
'section' => 'prautoblogger_publishing',
|
||||
'default' => 0,
|
||||
'description' => __( 'Fallback category for generated posts.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_log_level',
|
||||
'label' => __( 'Log Level', 'prautoblogger' ),
|
||||
'type' => 'select',
|
||||
'section' => 'prautoblogger_publishing',
|
||||
'default' => 'info',
|
||||
'options' => array(
|
||||
'error' => __( 'Error — only failures', 'prautoblogger' ),
|
||||
'warning' => __( 'Warning — errors + warnings', 'prautoblogger' ),
|
||||
'info' => __( 'Info — key events (recommended)', 'prautoblogger' ),
|
||||
'debug' => __( 'Debug — everything (verbose)', 'prautoblogger' ),
|
||||
),
|
||||
'description' => __( 'Controls detail level in the Activity Log.', 'prautoblogger' ),
|
||||
),
|
||||
|
||||
// ── Analytics ───────────────────────────────────────────────
|
||||
array(
|
||||
'id' => 'prautoblogger_ga4_property_id',
|
||||
'label' => __( 'GA4 Property ID', 'prautoblogger' ),
|
||||
'type' => 'text',
|
||||
'section' => 'prautoblogger_analytics',
|
||||
'description' => __( 'Your GA4 Property ID — digits only, e.g. 123456789 (the code adds the "properties/" prefix internally). Find it in GA4 → Admin → Property details. Leave blank to skip GA4.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_ga4_credentials_json',
|
||||
'label' => __( 'Service Account JSON', 'prautoblogger' ),
|
||||
'type' => 'password',
|
||||
'section' => 'prautoblogger_analytics',
|
||||
'description' => __( 'Paste the full JSON key file for a service account with Analytics read access.', 'prautoblogger' ),
|
||||
),
|
||||
|
||||
// ── Display ────────────────────────────────────────────────
|
||||
array(
|
||||
'id' => 'prautoblogger_article_font_family',
|
||||
'label' => __( 'Article Font', 'prautoblogger' ),
|
||||
'type' => 'select',
|
||||
'section' => 'prautoblogger_display',
|
||||
'default' => 'default',
|
||||
'options' => PRAutoBlogger_Article_Typography::get_font_choices(),
|
||||
'description' => __( 'Font family used for generated article body text. Serif fonts improve long-form readability.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_article_font_size',
|
||||
'label' => __( 'Article Font Size (px)', 'prautoblogger' ),
|
||||
'type' => 'number',
|
||||
'section' => 'prautoblogger_display',
|
||||
'default' => 0,
|
||||
'min' => 0,
|
||||
'max' => 32,
|
||||
'description' => __( 'Base font size for article body text. 0 uses the theme default (13px). Recommended: 16–18px for comfortable reading.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_table_borders',
|
||||
'label' => __( 'Table Borders', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_display',
|
||||
'default' => '1',
|
||||
'description' => __( 'Add visible borders, padding, and alternating row colors to tables in generated articles.', 'prautoblogger' ),
|
||||
),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_monthly_budget_usd',
|
||||
'label' => __( 'Monthly Budget (USD)', 'prautoblogger' ),
|
||||
'type' => 'number',
|
||||
'section' => 'prautoblogger_schedule',
|
||||
'default' => 50,
|
||||
'min' => 0,
|
||||
'step' => '0.01',
|
||||
'description' => __( 'Hard stop when reached. 0 = unlimited.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_request_json_retention_days',
|
||||
'label' => __( 'Audit Payload Retention (days)', 'prautoblogger' ),
|
||||
'type' => 'number',
|
||||
'section' => 'prautoblogger_schedule',
|
||||
'default' => PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS,
|
||||
'min' => 0,
|
||||
'step' => '1',
|
||||
'description' => __( 'Heavy audit payloads (request_json on the generation log, stage output snapshots) are emptied after this many days by the daily cleanup cron. Cost/token rows are kept forever. 0 = keep payloads forever.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_per_run_cost_ceiling_usd',
|
||||
'label' => __( 'Per-Run Cost Ceiling (USD)', 'prautoblogger' ),
|
||||
'type' => 'number',
|
||||
'section' => 'prautoblogger_schedule',
|
||||
'default' => PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD,
|
||||
'min' => 0,
|
||||
'step' => '0.01',
|
||||
'description' => __( 'Hard per-run stop: every API call reserves its worst-case cost against this ceiling before dispatch; a breach halts the run and routes it to the Review Queue. Snapshotted at run start. 0 = disabled.', 'prautoblogger' ),
|
||||
),
|
||||
|
||||
// ── Publishing ──────────────────────────────────────────────
|
||||
array(
|
||||
'id' => 'prautoblogger_auto_publish',
|
||||
'label' => __( 'Auto-Publish', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_publishing',
|
||||
'default' => '0',
|
||||
'description' => __( 'Automatically publish editor-approved posts. When off, all posts go to the Review Queue as drafts.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_default_author',
|
||||
'label' => __( 'Default Author', 'prautoblogger' ),
|
||||
'type' => 'author_select',
|
||||
'section' => 'prautoblogger_publishing',
|
||||
'default' => 0,
|
||||
'description' => __( 'WordPress user assigned as author of generated posts.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_default_category',
|
||||
'label' => __( 'Default Category', 'prautoblogger' ),
|
||||
'type' => 'category_select',
|
||||
'section' => 'prautoblogger_publishing',
|
||||
'default' => 0,
|
||||
'description' => __( 'Fallback category for generated posts.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_log_level',
|
||||
'label' => __( 'Log Level', 'prautoblogger' ),
|
||||
'type' => 'select',
|
||||
'section' => 'prautoblogger_publishing',
|
||||
'default' => 'info',
|
||||
'options' => array(
|
||||
'error' => __( 'Error — only failures', 'prautoblogger' ),
|
||||
'warning' => __( 'Warning — errors + warnings', 'prautoblogger' ),
|
||||
'info' => __( 'Info — key events (recommended)', 'prautoblogger' ),
|
||||
'debug' => __( 'Debug — everything (verbose)', 'prautoblogger' ),
|
||||
),
|
||||
'description' => __( 'Controls detail level in the Activity Log.', 'prautoblogger' ),
|
||||
),
|
||||
|
||||
// ── Analytics ───────────────────────────────────────────────
|
||||
array(
|
||||
'id' => 'prautoblogger_ga4_property_id',
|
||||
'label' => __( 'GA4 Property ID', 'prautoblogger' ),
|
||||
'type' => 'text',
|
||||
'section' => 'prautoblogger_analytics',
|
||||
'description' => __( 'Your GA4 Property ID — digits only, e.g. 123456789 (the code adds the "properties/" prefix internally). Find it in GA4 → Admin → Property details. Leave blank to skip GA4.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_ga4_credentials_json',
|
||||
'label' => __( 'Service Account JSON', 'prautoblogger' ),
|
||||
'type' => 'password',
|
||||
'section' => 'prautoblogger_analytics',
|
||||
'description' => __( 'Paste the full JSON key file for a service account with Analytics read access.', 'prautoblogger' ),
|
||||
),
|
||||
|
||||
// ── Display ────────────────────────────────────────────────
|
||||
array(
|
||||
'id' => 'prautoblogger_article_font_family',
|
||||
'label' => __( 'Article Font', 'prautoblogger' ),
|
||||
'type' => 'select',
|
||||
'section' => 'prautoblogger_display',
|
||||
'default' => 'default',
|
||||
'options' => PRAutoBlogger_Article_Typography::get_font_choices(),
|
||||
'description' => __( 'Font family used for generated article body text. Serif fonts improve long-form readability.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_article_font_size',
|
||||
'label' => __( 'Article Font Size (px)', 'prautoblogger' ),
|
||||
'type' => 'number',
|
||||
'section' => 'prautoblogger_display',
|
||||
'default' => 0,
|
||||
'min' => 0,
|
||||
'max' => 32,
|
||||
'description' => __( 'Base font size for article body text. 0 uses the theme default (13px). Recommended: 16–18px for comfortable reading.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_table_borders',
|
||||
'label' => __( 'Table Borders', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_display',
|
||||
'default' => '1',
|
||||
'description' => __( 'Add visible borders, padding, and alternating row colors to tables in generated articles.', 'prautoblogger' ),
|
||||
),
|
||||
|
||||
// ── Images ─────────────────────────────────────────────────
|
||||
array(
|
||||
'id' => 'prautoblogger_image_enabled',
|
||||
'label' => __( 'Enable Image Generation', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => '0',
|
||||
'description' => __( 'Generate images for each published article.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_image_b_enabled',
|
||||
'label' => __( 'Generate Second Image (B)', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => '1',
|
||||
'description' => __( 'Generate a second image from source data for A/B testing. Disabling saves one image generation + one LLM prompt rewrite per article.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_image_model',
|
||||
'label' => __( 'Image Model', 'prautoblogger' ),
|
||||
'type' => 'model_select',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL,
|
||||
'capability' => 'image_generation',
|
||||
'description' => __( 'Pick an image model. The provider (Runware or OpenRouter) is derived from the model registry on save, so mismatched pairs are no longer possible.', 'prautoblogger' ),
|
||||
'badge' => __( 'Quality', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_runware_api_key',
|
||||
'label' => __( 'Runware API Key', 'prautoblogger' ),
|
||||
'type' => 'password',
|
||||
'section' => 'prautoblogger_images',
|
||||
'description' => __( 'Required when using a Runware FLUX model. Get your key at runware.ai/signup.', 'prautoblogger' ),
|
||||
'icon' => '🔑',
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_image_style_template',
|
||||
'label' => __( 'Style Template', 'prautoblogger' ),
|
||||
'type' => 'textarea',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE,
|
||||
'description' => __( 'The full image prompt template. Must contain exactly one {{ topic_summary }} token, which is filled with a 1-2 sentence topic/mechanism summary per article. The rest is brand-locked editorial style. Replaces the old Style Suffix (now deprecated). Changing mid-run causes visible style drift.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_image_prompt_instructions',
|
||||
'label' => __( 'Image Prompt Instructions', 'prautoblogger' ),
|
||||
'type' => 'textarea',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => PRAutoBlogger_Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT,
|
||||
'description' => __( 'System prompt given to the rewriter LLM that turns each article into a SCENE (a 1-2 sentence editorial topic/mechanism summary, substituted into the Style Template) plus a CAPTION (HTML text shown below the image). Changing this reshapes the look of all future images. Leave blank to use the default.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_image_nsfw_retry',
|
||||
'label' => __( 'Retry NSFW-Blocked Images', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => '1',
|
||||
'description' => __( 'When the provider rejects an image prompt as NSFW, retry once with a generic fallback scene built from the article title. Disable to fail fast if the filter gets trigger-happy.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_image_compose_enabled',
|
||||
'label' => __( 'Compose Branded Variants', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => '1',
|
||||
'description' => __( 'Render branded social variants (teal band, logo, baked caption) from each generated image via the local deterministic composer. Auto-degrades to plain resizing or pass-through if the server lacks an image library — publishing is never blocked.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_image_compose_variants',
|
||||
'label' => __( 'Composed Variant Set', 'prautoblogger' ),
|
||||
'type' => 'text',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => 'og,square',
|
||||
'description' => __( 'Comma-separated variants to render for Image A. Supported: og (1200×630 social share), square (1080×1080 card). Unknown values are ignored.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_image_featured_mark_enabled',
|
||||
'label' => __( 'Featured Image Corner Mark', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => '1',
|
||||
'description' => __( 'Overlay a small semi-transparent Peptide Repo mark in the bottom-right corner of featured images, so scraped or re-shared images carry attribution.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_runware_model_catalog',
|
||||
'label' => __( 'Runware Model Catalog', 'prautoblogger' ),
|
||||
'type' => 'runware_catalog_sync',
|
||||
'section' => 'prautoblogger_images',
|
||||
'description' => __( 'Live model catalog sync status and on-demand refresh.', 'prautoblogger' ),
|
||||
),
|
||||
PRAutoBlogger_Settings_Fields_Images::get_fields()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
123
includes/admin/class-settings-fields-images.php
Normal file
123
includes/admin/class-settings-fields-images.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Image settings fields for PRAutoBlogger.
|
||||
*
|
||||
* Declarative field definitions for the Images section of the settings page.
|
||||
* Extracted from PRAutoBlogger_Settings_Fields_Extended for 300-line compliance.
|
||||
*
|
||||
* What: Returns the images-section field array consumed by the settings registry.
|
||||
* Who triggers: PRAutoBlogger_Settings_Fields_Extended::get_fields() via merge.
|
||||
* Dependencies: PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL, PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE,
|
||||
* PRAutoBlogger_Image_Prompt_Builder, PRAutoBlogger_Image_Model_Registry.
|
||||
*
|
||||
* @see admin/class-settings-fields-extended.php -- Merges these fields in get_fields().
|
||||
* @see admin/class-admin-page.php -- Renders the fields.
|
||||
* @see CONVENTIONS.md -- "How To: Add a New Admin Setting".
|
||||
*/
|
||||
class PRAutoBlogger_Settings_Fields_Images {
|
||||
|
||||
/**
|
||||
* Get image generation settings fields.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function get_fields(): array {
|
||||
return array(
|
||||
|
||||
// ── Images ─────────────────────────────────────────────────
|
||||
array(
|
||||
'id' => 'prautoblogger_image_enabled',
|
||||
'label' => __( 'Enable Image Generation', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => '0',
|
||||
'description' => __( 'Generate images for each published article.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_image_b_enabled',
|
||||
'label' => __( 'Generate Second Image (B)', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => '1',
|
||||
'description' => __( 'Generate a second image from source data for A/B testing. Disabling saves one image generation + one LLM prompt rewrite per article.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_image_model',
|
||||
'label' => __( 'Image Model', 'prautoblogger' ),
|
||||
'type' => 'model_select',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL,
|
||||
'capability' => 'image_generation',
|
||||
'description' => __( 'Pick an image model. The provider (Runware or OpenRouter) is derived from the model registry on save, so mismatched pairs are no longer possible.', 'prautoblogger' ),
|
||||
'badge' => __( 'Quality', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_runware_api_key',
|
||||
'label' => __( 'Runware API Key', 'prautoblogger' ),
|
||||
'type' => 'password',
|
||||
'section' => 'prautoblogger_images',
|
||||
'description' => __( 'Required when using a Runware FLUX model. Get your key at runware.ai/signup.', 'prautoblogger' ),
|
||||
'icon' => '🔑',
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_image_style_template',
|
||||
'label' => __( 'Style Template', 'prautoblogger' ),
|
||||
'type' => 'textarea',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE,
|
||||
'description' => __( 'The full image prompt template. Must contain exactly one {{ topic_summary }} token, which is filled with a 1-2 sentence topic/mechanism summary per article. The rest is brand-locked editorial style. Replaces the old Style Suffix (now deprecated). Changing mid-run causes visible style drift.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_image_prompt_instructions',
|
||||
'label' => __( 'Image Prompt Instructions', 'prautoblogger' ),
|
||||
'type' => 'textarea',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => PRAutoBlogger_Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT,
|
||||
'description' => __( 'System prompt given to the rewriter LLM that turns each article into a SCENE (a 1-2 sentence editorial topic/mechanism summary, substituted into the Style Template) plus a CAPTION (HTML text shown below the image). Changing this reshapes the look of all future images. Leave blank to use the default.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_image_nsfw_retry',
|
||||
'label' => __( 'Retry NSFW-Blocked Images', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => '1',
|
||||
'description' => __( 'When the provider rejects an image prompt as NSFW, retry once with a generic fallback scene built from the article title. Disable to fail fast if the filter gets trigger-happy.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_image_compose_enabled',
|
||||
'label' => __( 'Compose Branded Variants', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => '1',
|
||||
'description' => __( 'Render branded social variants (teal band, logo, baked caption) from each generated image via the local deterministic composer. Auto-degrades to plain resizing or pass-through if the server lacks an image library — publishing is never blocked.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_image_compose_variants',
|
||||
'label' => __( 'Composed Variant Set', 'prautoblogger' ),
|
||||
'type' => 'text',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => 'og,square',
|
||||
'description' => __( 'Comma-separated variants to render for Image A. Supported: og (1200×630 social share), square (1080×1080 card). Unknown values are ignored.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_image_featured_mark_enabled',
|
||||
'label' => __( 'Featured Image Corner Mark', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => '1',
|
||||
'description' => __( 'Overlay a small semi-transparent Peptide Repo mark in the bottom-right corner of featured images, so scraped or re-shared images carry attribution.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_runware_model_catalog',
|
||||
'label' => __( 'Runware Model Catalog', 'prautoblogger' ),
|
||||
'type' => 'runware_catalog_sync',
|
||||
'section' => 'prautoblogger_images',
|
||||
'description' => __( 'Live model catalog sync status and on-demand refresh.', 'prautoblogger' ),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
107
includes/admin/class-settings-sanitizer.php
Normal file
107
includes/admin/class-settings-sanitizer.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Settings field sanitizer for PRAutoBlogger.
|
||||
*
|
||||
* Centralises all option sanitization logic: encryption for API keys, JSON
|
||||
* encoding for multi-value fields, numeric coercion, image-model registry
|
||||
* validation. Extracted from PRAutoBlogger_Admin_Page for 300-line compliance.
|
||||
*
|
||||
* What: Static utility called by register_setting() sanitize_callback.
|
||||
* Who triggers: WordPress Settings API on option save; PRAutoBlogger_Admin_Page::sanitize_field().
|
||||
* Dependencies: PRAutoBlogger_Encryption, PRAutoBlogger_Image_Model_Registry,
|
||||
* PRAutoBlogger_Image_Template_Filler.
|
||||
*
|
||||
* @see admin/class-admin-page.php -- Registers settings and delegates here.
|
||||
* @see admin/class-image-model-registry.php -- Validates image model slug on save.
|
||||
* @see CONVENTIONS.md -- "How To: Add a New Admin Setting".
|
||||
*/
|
||||
class PRAutoBlogger_Settings_Sanitizer {
|
||||
|
||||
/**
|
||||
* Sanitize a settings field value.
|
||||
*
|
||||
* @param mixed $value The submitted value.
|
||||
* @return mixed Sanitized value.
|
||||
*/
|
||||
public static function sanitize_field( $value ) {
|
||||
$option_name = '';
|
||||
$filter = current_filter();
|
||||
if ( 0 === strpos( $filter, 'sanitize_option_' ) ) {
|
||||
$option_name = substr( $filter, strlen( 'sanitize_option_' ) );
|
||||
}
|
||||
|
||||
$encrypted = array( 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json', 'prautoblogger_runware_api_key' );
|
||||
if ( in_array( $option_name, $encrypted, true ) ) {
|
||||
// Empty value means password field wasn't touched — keep existing.
|
||||
if ( '' === $value ) {
|
||||
return get_option( $option_name, '' );
|
||||
}
|
||||
|
||||
// Already encrypted (has "enc:" prefix) — return as-is.
|
||||
// This is the primary defence against double-encryption:
|
||||
// PRAutoBlogger_Encryption::encrypt() also checks for this prefix,
|
||||
// so even if this callback is called multiple times, the value
|
||||
// is encrypted exactly once and never re-encrypted.
|
||||
if ( PRAutoBlogger_Encryption::is_encrypted( $value ) ) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
// New plaintext value — encrypt it (adds "enc:" prefix).
|
||||
return PRAutoBlogger_Encryption::encrypt( sanitize_text_field( $value ) );
|
||||
}
|
||||
|
||||
$json_fields = array( 'prautoblogger_target_subreddits', 'prautoblogger_topic_exclusions', 'prautoblogger_enabled_sources' );
|
||||
if ( in_array( $option_name, $json_fields, true ) ) {
|
||||
// PHP array from checkboxes — sanitize each item and re-encode.
|
||||
if ( is_array( $value ) ) {
|
||||
return wp_json_encode( array_values( array_map( 'sanitize_text_field', $value ) ) );
|
||||
}
|
||||
|
||||
$trimmed = trim( (string) $value );
|
||||
|
||||
// Already JSON-encoded array — decode, sanitize each item, re-encode.
|
||||
// This prevents sanitize_text_field() from mangling the JSON string,
|
||||
// which caused json_decode() on read to return null → empty subreddits.
|
||||
if ( '[' === substr( $trimmed, 0, 1 ) ) {
|
||||
$decoded = json_decode( $trimmed, true );
|
||||
if ( is_array( $decoded ) ) {
|
||||
return wp_json_encode( array_values( array_map( 'sanitize_text_field', $decoded ) ) );
|
||||
}
|
||||
}
|
||||
|
||||
// Comma-separated plain text — split, sanitize, encode as JSON array.
|
||||
$items = array_filter( array_map( 'trim', explode( ',', $trimmed ) ) );
|
||||
return wp_json_encode( array_values( array_map( 'sanitize_text_field', $items ) ) );
|
||||
}
|
||||
|
||||
$numeric = array( 'prautoblogger_daily_article_target', 'prautoblogger_monthly_budget_usd', 'prautoblogger_min_word_count', 'prautoblogger_max_word_count', 'prautoblogger_default_author', 'prautoblogger_default_category', 'prautoblogger_pullpush_cache_ttl', 'prautoblogger_reddit_posts_per_subreddit', 'prautoblogger_board_poll_interval', 'prautoblogger_board_published_window_days', 'prautoblogger_board_column_limit', 'prautoblogger_ideas_per_page' );
|
||||
if ( in_array( $option_name, $numeric, true ) ) {
|
||||
return is_numeric( $value ) ? $value : 0;
|
||||
}
|
||||
if ( 'prautoblogger_image_model' === $option_name ) {
|
||||
return self::sanitize_image_model( (string) $value );
|
||||
}
|
||||
if ( 'prautoblogger_image_style_template' === $option_name ) {
|
||||
// Multi-line template: validation + textarea-safe sanitisation
|
||||
// (incl. the single-token check, brief A5) lives in the filler.
|
||||
return PRAutoBlogger_Image_Template_Filler::sanitize_for_save( (string) $value );
|
||||
}
|
||||
return sanitize_text_field( (string) $value );
|
||||
}
|
||||
|
||||
/** Validate model id against the registry and persist the derived provider. */
|
||||
private static function sanitize_image_model( string $value ): string {
|
||||
$candidate = sanitize_text_field( $value );
|
||||
$provider = PRAutoBlogger_Image_Model_Registry::provider_for( $candidate );
|
||||
if ( '' !== $provider ) {
|
||||
update_option( 'prautoblogger_image_provider', $provider );
|
||||
return $candidate;
|
||||
}
|
||||
add_settings_error( 'prautoblogger_image_model', 'prautoblogger_image_model_unknown', sprintf( esc_html__( 'Image model "%s" is not in the registry. Keeping the previous selection.', 'prautoblogger' ), esc_html( $candidate ) ) );
|
||||
return (string) get_option( 'prautoblogger_image_model', PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL );
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,14 @@ class PRAutoBlogger_WP_CLI_Commands {
|
||||
}
|
||||
|
||||
// Command: wp prautoblogger opik:eval
|
||||
\WP_CLI::add_command(
|
||||
'prautoblogger generate',
|
||||
array( self::class, 'generate_command' ),
|
||||
array(
|
||||
'shortdesc' => 'Queue a new article generation run on chained-cron checkpoints',
|
||||
)
|
||||
);
|
||||
|
||||
\WP_CLI::add_command(
|
||||
'prautoblogger opik:eval',
|
||||
array( self::class, 'opik_eval_command' ),
|
||||
@@ -69,4 +77,27 @@ class PRAutoBlogger_WP_CLI_Commands {
|
||||
|
||||
exit( $result['items_run'] > 0 ? 0 : 1 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a new article generation run on chained-cron checkpoints.
|
||||
*
|
||||
* Routes through PRAutoBlogger_Generation_Checkpoint_Runner::kick_off()
|
||||
* (same path as the board "New Article" button). Does NOT execute
|
||||
* synchronously -- check the board or Activity Log for progress.
|
||||
* This retires the `do_action("prautoblogger_manual_generation")` workaround.
|
||||
*
|
||||
* ## OPTIONS
|
||||
*
|
||||
* ## EXAMPLES
|
||||
*
|
||||
* wp prautoblogger generate
|
||||
*
|
||||
* @param array $args Positional args (unused).
|
||||
* @param array $assoc_args Associative args (unused).
|
||||
* @return void
|
||||
*/
|
||||
public static function generate_command( array $args, array $assoc_args ): void {
|
||||
PRAutoBlogger_Generation_Checkpoint_Runner::kick_off();
|
||||
\WP_CLI::success( 'Generation queued on chained-cron checkpoints. Check the board or Activity Log for progress.' );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,10 @@ class PRAutoBlogger_Activator {
|
||||
'prautoblogger_table_borders' => '1',
|
||||
'prautoblogger_schedule_time' => '03:00',
|
||||
'prautoblogger_log_level' => 'info',
|
||||
'prautoblogger_image_nsfw_retry' => '1',
|
||||
'prautoblogger_image_nsfw_retry' => '1',
|
||||
// M4 (v0.21.0): board column card limit + ideas page size -- settings-backed.
|
||||
'prautoblogger_board_column_limit' => PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT,
|
||||
'prautoblogger_ideas_per_page' => PRAUTOBLOGGER_DEFAULT_IDEAS_PER_PAGE,
|
||||
);
|
||||
|
||||
foreach ( $defaults as $key => $value ) {
|
||||
|
||||
129
includes/class-db-migrations.php
Normal file
129
includes/class-db-migrations.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* One-time and version-gated DB/option migrations for PRAutoBlogger.
|
||||
*
|
||||
* Extracted from PRAutoBlogger (class-prautoblogger.php) in v0.19.2 so the
|
||||
* main orchestrator stays under the 300-line cap. All migration logic lives
|
||||
* here; the orchestrator holds thin proxy methods for backward compat.
|
||||
*
|
||||
* Each migration is idempotent: guarded by a stored option flag so it never
|
||||
* runs more than once per site.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger::on_check_db_version() (admin_init)
|
||||
* PRAutoBlogger::on_check_db_version_for_cron() (init, cron-only).
|
||||
* Dependencies: PRAutoBlogger_Activator, PRAutoBlogger_Image_Model_Registry,
|
||||
* PRAutoBlogger_Encryption, PRAutoBlogger_Migrate_Remove_Cloudflare_V0100.
|
||||
*
|
||||
* @see class-prautoblogger.php -- Orchestrator; holds proxy methods.
|
||||
* @see class-activator.php -- Schema install / activate logic called here.
|
||||
* @see ARCHITECTURE.md -- DB version history and migration discipline.
|
||||
*/
|
||||
class PRAutoBlogger_DB_Migrations {
|
||||
|
||||
/**
|
||||
* Run all pending migrations.
|
||||
*
|
||||
* Called on `admin_init` (and on `init` for cron contexts). Idempotent.
|
||||
*
|
||||
* Side effects: may update database schema and wp_options rows.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function run(): void {
|
||||
$stored_version = get_option( 'prautoblogger_db_version', '0' );
|
||||
if ( version_compare( $stored_version, PRAUTOBLOGGER_DB_VERSION, '<' ) ) {
|
||||
PRAutoBlogger_Activator::activate();
|
||||
}
|
||||
|
||||
// One-time migration: switch to Gemini 2.5 Flash Lite for cost/speed.
|
||||
if ( ! get_option( 'prautoblogger_migrated_gemini_flash_lite' ) ) {
|
||||
update_option( 'prautoblogger_analysis_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL );
|
||||
update_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
|
||||
update_option( 'prautoblogger_editor_model', PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL );
|
||||
update_option( 'prautoblogger_migrated_gemini_flash_lite', '1' );
|
||||
}
|
||||
|
||||
// One-time migration (v0.8.0): the admin no longer has an independent
|
||||
// Image Provider dropdown; provider is derived from the image model on
|
||||
// save. Auto-heal any existing site where the saved provider doesn't
|
||||
// match the saved model's provider. Runs once.
|
||||
if ( ! get_option( 'prautoblogger_migrated_image_provider_v080' ) ) {
|
||||
$saved_model = (string) get_option( 'prautoblogger_image_model', '' );
|
||||
$provider = PRAutoBlogger_Image_Model_Registry::provider_for( $saved_model );
|
||||
if ( '' !== $provider ) {
|
||||
update_option( 'prautoblogger_image_provider', $provider );
|
||||
}
|
||||
update_option( 'prautoblogger_migrated_image_provider_v080', '1' );
|
||||
}
|
||||
|
||||
// One-time migration (v0.8.2): reschedule the daily-generation cron in
|
||||
// the site's configured timezone. Pre-v0.8.2 activator interpreted the
|
||||
// admin "Generation Time" input as UTC; after v0.8.2 it honours the
|
||||
// site timezone. Clears the stale UTC-scheduled event and re-queues
|
||||
// using the timezone-aware helper. See class-activator.php.
|
||||
PRAutoBlogger_Activator::reschedule_daily_in_site_timezone_v082();
|
||||
|
||||
// One-time migration (v3): switch to single-panel newspaper comic style.
|
||||
if ( ! get_option( 'prautoblogger_migrated_style_suffix_v3' ) ) {
|
||||
$known_old_prefixes = array(
|
||||
'Style: a screengrab from a 1995', // v1: infomercial.
|
||||
'Style: premium scientific lifestyle', // v2: photography.
|
||||
);
|
||||
$current = get_option( 'prautoblogger_image_style_suffix', '' );
|
||||
$is_old = ( '' === $current );
|
||||
foreach ( $known_old_prefixes as $prefix ) {
|
||||
if ( false !== strpos( $current, $prefix ) ) {
|
||||
$is_old = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ( $is_old ) {
|
||||
update_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
|
||||
}
|
||||
update_option( 'prautoblogger_migrated_style_suffix_v3', '1' );
|
||||
}
|
||||
|
||||
// One-time migration (v4): remove caption-in-image instruction from style suffix.
|
||||
if ( ! get_option( 'prautoblogger_migrated_style_suffix_v4' ) ) {
|
||||
$current = get_option( 'prautoblogger_image_style_suffix', '' );
|
||||
if ( false !== strpos( $current, 'caption text in a clean sans-serif font below the panel' ) ) {
|
||||
update_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
|
||||
}
|
||||
update_option( 'prautoblogger_migrated_style_suffix_v4', '1' );
|
||||
}
|
||||
|
||||
// One-time migration (v0.16.0): editorial pivot -- comic Style Suffix
|
||||
// replaced by editorial Style Template. Old value mirrored to a
|
||||
// deprecated-keyed option for one version cycle.
|
||||
if ( ! get_option( 'prautoblogger_migrated_style_template_v0160' ) ) {
|
||||
$old_suffix = get_option( 'prautoblogger_image_style_suffix', '' );
|
||||
if ( '' !== $old_suffix && false === get_option( 'prautoblogger_image_style_suffix_deprecated', false ) ) {
|
||||
update_option( 'prautoblogger_image_style_suffix_deprecated', $old_suffix );
|
||||
}
|
||||
if ( '' === (string) get_option( 'prautoblogger_image_style_template', '' ) ) {
|
||||
update_option( 'prautoblogger_image_style_template', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE );
|
||||
}
|
||||
update_option( 'prautoblogger_migrated_style_template_v0160', '1' );
|
||||
}
|
||||
|
||||
// v0.9.0 -- Runware as default image model. v0.10.0 -- remove CF Workers AI.
|
||||
PRAutoBlogger_Activator::migrate_default_image_v090();
|
||||
PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
|
||||
|
||||
// One-time migration: re-wrap existing encrypted values with "enc:" prefix.
|
||||
if ( ! get_option( 'prautoblogger_migrated_enc_prefix' ) ) {
|
||||
$enc_options = array( 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json', 'prautoblogger_runware_api_key' );
|
||||
foreach ( $enc_options as $opt ) {
|
||||
$val = get_option( $opt, '' );
|
||||
if ( '' !== $val && ! PRAutoBlogger_Encryption::is_encrypted( $val ) ) {
|
||||
delete_option( $opt );
|
||||
}
|
||||
}
|
||||
update_option( 'prautoblogger_migrated_enc_prefix', '1' );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,68 +85,21 @@ class PRAutoBlogger_Executor {
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron handler: runs pipeline orchestration + first article.
|
||||
* Cron handler: kick off the chained-cron orchestration tick (v0.21.0, M4).
|
||||
*
|
||||
* If the pipeline queues additional articles (2..N), they will fire as
|
||||
* chained cron events — each in its own PHP process. The lock is released
|
||||
* by the pipeline when the last article completes, not here.
|
||||
* Delegates to Generation_Checkpoint_Runner::on_orchestrate_tick() which
|
||||
* runs Tick 1 (collect -> analyze -> score), then schedules Tick 2..N for
|
||||
* article generation. This replaces the former monolithic pipeline run and
|
||||
* retires the SSH-only `do_action("prautoblogger_manual_generation")` workaround
|
||||
* (see ARCHITECTURE.md ss25).
|
||||
*
|
||||
* Lock acquisition and status transient writes are fully owned by the
|
||||
* checkpoint runner; this method is a thin routing wrapper.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function on_manual_generation(): void {
|
||||
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
@ignore_user_abort( true );
|
||||
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
@set_time_limit( 300 );
|
||||
|
||||
if ( ! PRAutoBlogger_Generation_Lock::acquire() ) {
|
||||
set_transient(
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
|
||||
array(
|
||||
'status' => 'error',
|
||||
'message' => __( 'Could not acquire generation lock. Another run may be in progress.', 'prautoblogger' ),
|
||||
),
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->poller()->update_generation_stage( __( 'Collecting sources from Reddit...', 'prautoblogger' ) );
|
||||
|
||||
$runner = ( new PRAutoBlogger_Pipeline_Runner() )->set_skip_dedup( true );
|
||||
$results = $runner->run();
|
||||
|
||||
// If no articles were queued (target=1 or 0 ideas), finalize here.
|
||||
// When articles ARE queued, the pipeline handles status + lock release.
|
||||
if ( ! get_option( 'prautoblogger_article_queue' ) ) {
|
||||
set_transient(
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
|
||||
array(
|
||||
'status' => 'complete',
|
||||
'generated' => $results['generated'],
|
||||
'published' => $results['published'],
|
||||
'rejected' => $results['rejected'],
|
||||
'cost' => $results['cost'],
|
||||
),
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
|
||||
);
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
}
|
||||
} catch ( \Throwable $e ) {
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
sprintf( 'Manual generation %s: %s', get_class( $e ), $e->getMessage() ),
|
||||
'pipeline'
|
||||
);
|
||||
set_transient(
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
|
||||
array(
|
||||
'status' => 'error',
|
||||
'message' => $e->getMessage(),
|
||||
),
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
|
||||
);
|
||||
$this->mark_current_run_failed();
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
}
|
||||
PRAutoBlogger_Generation_Checkpoint_Runner::on_orchestrate_tick();
|
||||
}
|
||||
|
||||
/** Cron handler: process next queued article. Chained by Pipeline_Runner. */
|
||||
|
||||
@@ -4,12 +4,14 @@ declare(strict_types=1);
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Creates / updates the Pipeline v2 substrate tables via `dbDelta` (db 1.2.0).
|
||||
* Creates / updates the Pipeline v2 substrate tables via `dbDelta` (db 1.3.0).
|
||||
*
|
||||
* What: Owns the five v0.18.0 substrate tables — the versioned prompt
|
||||
* What: Owns the six pipeline substrate tables — the versioned prompt
|
||||
* registry (`prompts`), the audit child tables (`run_sources`,
|
||||
* `run_decisions`), and the run ledger / state machine tables
|
||||
* (`runs`, `run_stages`). Kept separate from the original
|
||||
* (`runs`, `run_stages`), and the immutable stage-input version
|
||||
* store (`stage_inputs`, v0.20.0 / db 1.3.0 — edit + re-run forks
|
||||
* and idea seeds). Kept separate from the original
|
||||
* PRAutoBlogger_Schema_Installer so both classes stay under the
|
||||
* 300-line cap and the v1.1.0 schema remains byte-stable.
|
||||
* Who triggers it: PRAutoBlogger_Activator::activate() — on activation and
|
||||
@@ -33,7 +35,7 @@ class PRAutoBlogger_Pipeline_Schema_Installer {
|
||||
* plugin's forward-only schema policy. Safe to re-run on every
|
||||
* version mismatch.
|
||||
*
|
||||
* Side effects: up to five CREATE TABLE / ALTER TABLE statements.
|
||||
* Side effects: up to six CREATE TABLE / ALTER TABLE statements.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
@@ -91,6 +93,7 @@ class PRAutoBlogger_Pipeline_Schema_Installer {
|
||||
verdict VARCHAR(50) NOT NULL,
|
||||
rationale TEXT DEFAULT NULL,
|
||||
citation_score FLOAT DEFAULT NULL,
|
||||
human_modified TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY run_id (run_id)
|
||||
@@ -112,7 +115,8 @@ class PRAutoBlogger_Pipeline_Schema_Installer {
|
||||
updated_at DATETIME NOT NULL,
|
||||
finished_at DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (run_id),
|
||||
KEY status (status)
|
||||
KEY status (status),
|
||||
KEY started_at (started_at)
|
||||
) {$charset_collate};";
|
||||
|
||||
// Per-run per-stage state rows. The idempotency key is
|
||||
@@ -130,6 +134,8 @@ class PRAutoBlogger_Pipeline_Schema_Installer {
|
||||
attempt SMALLINT UNSIGNED NOT NULL DEFAULT 1,
|
||||
cost_usd DECIMAL(10,6) NOT NULL DEFAULT 0,
|
||||
meta_json LONGTEXT DEFAULT NULL,
|
||||
human_modified TINYINT(1) NOT NULL DEFAULT 0,
|
||||
stale TINYINT(1) NOT NULL DEFAULT 0,
|
||||
started_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
finished_at DATETIME DEFAULT NULL,
|
||||
@@ -138,10 +144,37 @@ class PRAutoBlogger_Pipeline_Schema_Installer {
|
||||
KEY status_updated (status, updated_at)
|
||||
) {$charset_collate};";
|
||||
|
||||
// Immutable stage-input version store (v0.20.0 / db 1.3.0, M3
|
||||
// edit + re-run). Rows are INSERT-only: a human edit creates the
|
||||
// next (scope, version) row -- the original input (the
|
||||
// generation_log.request_json of the executed call) is never
|
||||
// overwritten (CPO guardrail 1). source='seed' rows persist the
|
||||
// Article_Idea payload at worker start so re-run-from-here can
|
||||
// reconstruct the exact idea (item_key hash stability); 'human'
|
||||
// rows are edit forks. Human fork bodies are retention-pruned
|
||||
// like all heavy payloads; seed rows (~1KB) are structural and
|
||||
// kept for the life of the run.
|
||||
$sql_stage_inputs = "CREATE TABLE {$prefix}stage_inputs (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
run_id VARCHAR(36) NOT NULL,
|
||||
stage VARCHAR(50) NOT NULL DEFAULT '',
|
||||
agent_role VARCHAR(50) NOT NULL DEFAULT '',
|
||||
item_key VARCHAR(64) NOT NULL DEFAULT '',
|
||||
version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
source VARCHAR(20) NOT NULL DEFAULT 'human',
|
||||
request_json LONGTEXT DEFAULT NULL,
|
||||
author VARCHAR(100) NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY scope_version (run_id, stage, agent_role, item_key, version),
|
||||
KEY run_id (run_id)
|
||||
) {$charset_collate};";
|
||||
|
||||
dbDelta( $sql_prompts );
|
||||
dbDelta( $sql_run_sources );
|
||||
dbDelta( $sql_run_decisions );
|
||||
dbDelta( $sql_runs );
|
||||
dbDelta( $sql_run_stages );
|
||||
dbDelta( $sql_stage_inputs );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,20 +6,12 @@ declare(strict_types=1);
|
||||
*
|
||||
* Main orchestrator for the PRAutoBlogger plugin.
|
||||
*
|
||||
* Registers all WordPress hooks (actions and filters) and wires up dependencies.
|
||||
* This is the ONLY place hooks are registered — individual classes contain only
|
||||
* business logic, not hook registration.
|
||||
* Registers all WordPress hooks and wires up dependencies.
|
||||
* DB/option migrations extracted to PRAutoBlogger_DB_Migrations (v0.19.2).
|
||||
*
|
||||
* Triggered by: prautoblogger() singleton in prautoblogger.php, called on `plugins_loaded`.
|
||||
* Dependencies: All other plugin classes (loaded via autoloader), PRAutoBlogger_Executor.
|
||||
*
|
||||
* @see prautoblogger.php — Plugin bootstrap that instantiates this class.
|
||||
* @see class-executor.php — Cron/AJAX handlers for generation, model registry.
|
||||
* @see class-ajax-handlers.php — Non-generation AJAX endpoints (images, models, test).
|
||||
* @see class-generation-lock.php — DB mutex for single-writer generation.
|
||||
* @see core/class-pipeline-runner.php — Executes the generation pipeline.
|
||||
* @see ARCHITECTURE.md — Data flow diagram showing how classes interact.
|
||||
* @see CONVENTIONS.md — Hook naming conventions.
|
||||
* @see prautoblogger.php -- Plugin bootstrap.
|
||||
* @see class-db-migrations.php -- One-time DB/option migrations.
|
||||
* @see ARCHITECTURE.md -- Data flow diagram.
|
||||
*/
|
||||
class PRAutoBlogger {
|
||||
|
||||
@@ -28,14 +20,12 @@ class PRAutoBlogger {
|
||||
/** @var PRAutoBlogger_Executor Handles generation cron/AJAX, model registry. */
|
||||
private PRAutoBlogger_Executor $executor;
|
||||
|
||||
/** @var PRAutoBlogger_Ajax_Handlers Handles non-generation AJAX (images, models, test). */
|
||||
/** @var PRAutoBlogger_Ajax_Handlers Handles non-generation AJAX. */
|
||||
private PRAutoBlogger_Ajax_Handlers $ajax_handlers;
|
||||
|
||||
/**
|
||||
* Register all hooks and initialize the plugin.
|
||||
* Called once on `plugins_loaded`. Idempotent.
|
||||
*
|
||||
* Side effects: registers WordPress actions and filters.
|
||||
*/
|
||||
public function run(): void {
|
||||
if ( $this->initialized ) {
|
||||
@@ -46,9 +36,7 @@ class PRAutoBlogger {
|
||||
$this->ajax_handlers = new PRAutoBlogger_Ajax_Handlers( $this->executor->get_model_registry() );
|
||||
|
||||
add_action( 'admin_init', array( $this, 'on_check_db_version' ) );
|
||||
// v0.18.0: cron requests must also self-heal the schema — after a
|
||||
// deploy, scheduled generation can run before any admin page load,
|
||||
// and new-column inserts would silently fail on the old schema.
|
||||
// v0.18.0: cron requests must also self-heal the schema.
|
||||
add_action( 'init', array( $this, 'on_check_db_version_for_cron' ) );
|
||||
add_filter( 'cron_schedules', array( $this, 'filter_add_cron_schedules' ) );
|
||||
|
||||
@@ -65,13 +53,30 @@ class PRAutoBlogger {
|
||||
do_action( 'prautoblogger_loaded' );
|
||||
}
|
||||
|
||||
/** Register admin-only hooks (settings, notices, metabox, dashboard widget). */
|
||||
/**
|
||||
* Register admin-only hooks (settings, notices, metabox, board, dossier, widget).
|
||||
*
|
||||
* Menu-ordering rule (v0.19.1): parent menu must register at priority 10 before
|
||||
* any submenu (priority 11+). WordPress resolves the submenu hookname via
|
||||
* get_plugin_page_hookname() at add_submenu_page() time; if the parent slot in
|
||||
* $admin_page_hooks is unset, WP falls back to admin_page_* -> 404 at request
|
||||
* time. See ARCHITECTURE.md §Board for the full trace.
|
||||
*/
|
||||
private function register_admin_hooks(): void {
|
||||
$admin_page = new PRAutoBlogger_Admin_Page();
|
||||
add_action( 'admin_menu', array( $admin_page, 'on_register_menu' ) );
|
||||
add_action( 'admin_menu', array( $admin_page, 'on_register_menu' ), 10 );
|
||||
add_action( 'admin_init', array( $admin_page, 'on_register_settings' ) );
|
||||
add_action( 'admin_enqueue_scripts', array( $admin_page, 'on_enqueue_assets' ) );
|
||||
|
||||
$board_page = new PRAutoBlogger_Board_Page();
|
||||
add_action( 'admin_menu', array( $board_page, 'on_register_menu' ), 11 );
|
||||
add_action( 'admin_enqueue_scripts', array( $board_page, 'on_enqueue_assets' ) );
|
||||
|
||||
// Dossier: link-accessed hidden submenu, priority 12 (after board).
|
||||
$dossier_page = new PRAutoBlogger_Dossier_Page();
|
||||
add_action( 'admin_menu', array( $dossier_page, 'on_register_menu' ), 12 );
|
||||
add_action( 'admin_enqueue_scripts', array( $dossier_page, 'on_enqueue_assets' ) );
|
||||
|
||||
add_action( 'admin_notices', array( new PRAutoBlogger_Admin_Notices(), 'on_display_notices' ) );
|
||||
add_action( 'add_meta_boxes', array( new PRAutoBlogger_Post_Metabox(), 'on_register_metabox' ) );
|
||||
add_action( 'admin_menu', array( new PRAutoBlogger_Metrics_Page(), 'on_register_menu' ) );
|
||||
@@ -86,7 +91,6 @@ class PRAutoBlogger {
|
||||
|
||||
( new PRAutoBlogger_Post_List_Columns() )->register();
|
||||
|
||||
// Block false update notifications — our slug collides with another plugin.
|
||||
add_filter( 'site_transient_update_plugins', array( $this, 'filter_block_false_updates' ) );
|
||||
}
|
||||
|
||||
@@ -106,13 +110,7 @@ class PRAutoBlogger {
|
||||
private function register_cron_hooks(): void {
|
||||
add_action( 'prautoblogger_daily_generation', array( $this->executor, 'on_daily_generation' ) );
|
||||
add_action( 'prautoblogger_collect_metrics', array( $this->executor, 'on_collect_metrics' ) );
|
||||
|
||||
// Manual "Generate Now" runs as a one-shot cron event to avoid
|
||||
// Hostinger's 120-second web-server connection timeout.
|
||||
add_action( 'prautoblogger_manual_generation', array( $this->executor, 'on_manual_generation' ) );
|
||||
|
||||
// Chained article generation — each queued article fires as its own
|
||||
// cron event so it gets a fresh PHP process and execution time budget.
|
||||
add_action(
|
||||
PRAutoBlogger_Pipeline_Runner::CRON_ACTION,
|
||||
array( $this->executor, 'on_process_article_queue' )
|
||||
@@ -120,59 +118,48 @@ class PRAutoBlogger {
|
||||
|
||||
$registry = $this->executor->get_model_registry();
|
||||
add_action( 'prautoblogger_refresh_model_registry', array( $registry, 'refresh' ) );
|
||||
// v0.15.0: daily Runware model catalog sync to keep the live model list fresh.
|
||||
add_action( 'prautoblogger_sync_runware_models', array( new PRAutoBlogger_Runware_Model_Catalog(), 'sync' ) );
|
||||
|
||||
// Opik observability: async trace/span dispatch.
|
||||
add_action(
|
||||
'prautoblogger_opik_dispatch',
|
||||
static function (): void {
|
||||
if ( ! defined( 'PRAUTOBLOGGER_OPIK_API_KEY' ) || empty( PRAUTOBLOGGER_OPIK_API_KEY ) ) {
|
||||
return;
|
||||
}
|
||||
$client = new PRAutoBlogger_Opik_Client(
|
||||
$client = new PRAutoBlogger_Opik_Client(
|
||||
PRAUTOBLOGGER_OPIK_API_KEY,
|
||||
PRAUTOBLOGGER_OPIK_WORKSPACE,
|
||||
PRAUTOBLOGGER_OPIK_URL_OVERRIDE
|
||||
);
|
||||
$queue = new PRAutoBlogger_Opik_Span_Queue();
|
||||
$dispatcher = new PRAutoBlogger_Opik_Dispatcher( $client, $queue );
|
||||
$dispatcher = new PRAutoBlogger_Opik_Dispatcher( $client, new PRAutoBlogger_Opik_Span_Queue() );
|
||||
$dispatcher->dispatch();
|
||||
update_option( 'prautoblogger_opik_last_dispatch', time() );
|
||||
}
|
||||
);
|
||||
|
||||
// Opik settings registration.
|
||||
if ( is_admin() ) {
|
||||
new PRAutoBlogger_Opik_Settings();
|
||||
}
|
||||
|
||||
// Single-idea generation from the Ideas browser page.
|
||||
// v0.20.0 (M3): operator re-run jobs — chained-cron, never synchronous.
|
||||
$rerun_executor = new PRAutoBlogger_Rerun_Executor();
|
||||
add_action( PRAutoBlogger_Rerun_Executor::REPLAY_ACTION, array( $rerun_executor, 'on_replay_job' ), 10, 5 );
|
||||
add_action( PRAutoBlogger_Rerun_Executor::REBUILD_ACTION, array( $rerun_executor, 'on_rebuild_job' ), 10, 4 );
|
||||
|
||||
// v0.21.0 (M4): chained-cron checkpoint ticks for new article generation.
|
||||
add_action( PRAutoBlogger_Generation_Checkpoint_Runner::ORCHESTRATE_ACTION, array( 'PRAutoBlogger_Generation_Checkpoint_Runner', 'on_orchestrate_tick' ) );
|
||||
add_action( PRAutoBlogger_Generation_Checkpoint_Runner::GENERATE_ACTION, array( 'PRAutoBlogger_Generation_Checkpoint_Runner', 'on_generate_tick' ) );
|
||||
|
||||
add_action( 'prautoblogger_generate_from_idea', array( 'PRAutoBlogger_Ideas_Browser', 'on_cron_generate_from_idea' ) );
|
||||
|
||||
// v0.8.1: daily reaper for orphan `llm_research` rows (when a
|
||||
// pipeline dies before amortize_research_costs runs).
|
||||
add_action( 'prautoblogger_reap_orphan_research_rows', array( 'PRAutoBlogger_Research_Reaper', 'on_cron' ) );
|
||||
|
||||
// v0.18.0: stuck-run sweep + audit-payload retention rides the SAME
|
||||
// existing daily cron (no new schedule). See class-run-reaper.php.
|
||||
add_action( 'prautoblogger_reap_orphan_research_rows', array( 'PRAutoBlogger_Run_Reaper', 'on_cron' ) );
|
||||
|
||||
// v0.8.1: WP-CLI manual trigger for the reaper. Only registers when
|
||||
// WP-CLI is present; no-op in normal HTTP requests.
|
||||
if ( defined( 'WP_CLI' ) && WP_CLI ) {
|
||||
\WP_CLI::add_command(
|
||||
'prautoblogger reap-research',
|
||||
static function (): void {
|
||||
$stats = PRAutoBlogger_Research_Reaper::reap();
|
||||
\WP_CLI::success(
|
||||
sprintf(
|
||||
'Reaped %d, deleted %d (stale), skipped %d.',
|
||||
(int) $stats['reaped'],
|
||||
(int) $stats['deleted'],
|
||||
(int) $stats['skipped']
|
||||
)
|
||||
);
|
||||
\WP_CLI::success( sprintf( 'Reaped %d, deleted %d, skipped %d.', (int) $stats['reaped'], (int) $stats['deleted'], (int) $stats['skipped'] ) );
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -180,11 +167,9 @@ class PRAutoBlogger {
|
||||
|
||||
/** Register AJAX handlers for admin actions. */
|
||||
private function register_ajax_hooks(): void {
|
||||
// Generation AJAX (start + status polling) stays on executor.
|
||||
add_action( 'wp_ajax_prautoblogger_generate_now', array( $this->executor, 'on_ajax_generate_now' ) );
|
||||
add_action( 'wp_ajax_prautoblogger_generation_status', array( $this->executor, 'on_ajax_generation_status' ) );
|
||||
|
||||
// Non-generation AJAX (images, models, test) on dedicated handler.
|
||||
add_action( 'wp_ajax_prautoblogger_generate_image', array( $this->ajax_handlers, 'on_ajax_generate_image' ) );
|
||||
add_action( 'wp_ajax_prautoblogger_test_connection', array( $this->ajax_handlers, 'on_ajax_test_connection' ) );
|
||||
add_action( 'wp_ajax_prautoblogger_get_models', array( $this->ajax_handlers, 'on_ajax_get_models' ) );
|
||||
@@ -196,6 +181,14 @@ class PRAutoBlogger {
|
||||
add_action( 'wp_ajax_prautoblogger_reject_post', array( $review_queue, 'on_ajax_reject_post' ) );
|
||||
|
||||
add_action( 'wp_ajax_prautoblogger_clear_logs', array( new PRAutoBlogger_Log_Viewer(), 'on_ajax_clear_logs' ) );
|
||||
add_action( 'wp_ajax_' . PRAutoBlogger_Board_Page::AJAX_ACTION, array( new PRAutoBlogger_Board_Page(), 'on_ajax_board_status' ) );
|
||||
|
||||
// v0.20.0 (M3): dossier edit + re-run endpoints (validate + queue only).
|
||||
$dossier_actions = new PRAutoBlogger_Dossier_Actions();
|
||||
add_action( 'wp_ajax_prautoblogger_dossier_save_input', array( $dossier_actions, 'on_save_input' ) );
|
||||
add_action( 'wp_ajax_prautoblogger_dossier_rerun_stage', array( $dossier_actions, 'on_rerun_stage' ) );
|
||||
add_action( 'wp_ajax_prautoblogger_dossier_rerun_from', array( $dossier_actions, 'on_rerun_from' ) );
|
||||
add_action( 'wp_ajax_prautoblogger_dossier_stage_status', array( $dossier_actions, 'on_stage_status' ) );
|
||||
|
||||
$ideas = new PRAutoBlogger_Ideas_Browser();
|
||||
add_action( 'wp_ajax_prautoblogger_generate_from_idea', array( $ideas, 'on_ajax_generate_from_idea' ) );
|
||||
@@ -203,121 +196,16 @@ class PRAutoBlogger {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if database needs migration after plugin update.
|
||||
* Compares stored DB version with PRAUTOBLOGGER_DB_VERSION; re-runs activation if different.
|
||||
*
|
||||
* Side effects: may update database schema and db_version option.
|
||||
* Check if database needs migration. Delegates to PRAutoBlogger_DB_Migrations::run().
|
||||
* Hooked on `admin_init`.
|
||||
*/
|
||||
public function on_check_db_version(): void {
|
||||
$stored_version = get_option( 'prautoblogger_db_version', '0' );
|
||||
if ( version_compare( $stored_version, PRAUTOBLOGGER_DB_VERSION, '<' ) ) {
|
||||
PRAutoBlogger_Activator::activate();
|
||||
}
|
||||
|
||||
// One-time migration: switch to Gemini 2.5 Flash Lite for cost/speed.
|
||||
if ( ! get_option( 'prautoblogger_migrated_gemini_flash_lite' ) ) {
|
||||
update_option( 'prautoblogger_analysis_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL );
|
||||
update_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
|
||||
update_option( 'prautoblogger_editor_model', PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL );
|
||||
update_option( 'prautoblogger_migrated_gemini_flash_lite', '1' );
|
||||
}
|
||||
|
||||
// One-time migration (v0.8.0): the admin no longer has an independent
|
||||
// Image Provider dropdown; provider is derived from the image model on
|
||||
// save. Auto-heal any existing site where the saved provider doesn't
|
||||
// match the saved model's provider (the root cause of posts 650/657
|
||||
// silently missing their featured image on 2026-04-20). Runs once.
|
||||
if ( ! get_option( 'prautoblogger_migrated_image_provider_v080' ) ) {
|
||||
$saved_model = (string) get_option( 'prautoblogger_image_model', '' );
|
||||
$provider = PRAutoBlogger_Image_Model_Registry::provider_for( $saved_model );
|
||||
if ( '' !== $provider ) {
|
||||
update_option( 'prautoblogger_image_provider', $provider );
|
||||
}
|
||||
update_option( 'prautoblogger_migrated_image_provider_v080', '1' );
|
||||
}
|
||||
|
||||
// One-time migration (v0.8.2): reschedule the daily-generation cron in
|
||||
// the site's configured timezone. Pre-v0.8.2 activator interpreted the
|
||||
// admin "Generation Time" input as UTC; after v0.8.2 it honours the
|
||||
// site timezone. Clears the stale UTC-scheduled event and re-queues
|
||||
// using the timezone-aware helper. See class-activator.php.
|
||||
PRAutoBlogger_Activator::reschedule_daily_in_site_timezone_v082();
|
||||
|
||||
// One-time migration (v3): switch to single-panel newspaper comic style.
|
||||
// Replaces both the old infomercial pastiche and the short-lived premium
|
||||
// photography style. Force-update unless the user has a truly custom value.
|
||||
if ( ! get_option( 'prautoblogger_migrated_style_suffix_v3' ) ) {
|
||||
$known_old_prefixes = array(
|
||||
'Style: a screengrab from a 1995', // v1: infomercial.
|
||||
'Style: premium scientific lifestyle', // v2: photography.
|
||||
);
|
||||
$current = get_option( 'prautoblogger_image_style_suffix', '' );
|
||||
$is_old = ( '' === $current );
|
||||
foreach ( $known_old_prefixes as $prefix ) {
|
||||
if ( false !== strpos( $current, $prefix ) ) {
|
||||
$is_old = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ( $is_old ) {
|
||||
update_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
|
||||
}
|
||||
update_option( 'prautoblogger_migrated_style_suffix_v3', '1' );
|
||||
}
|
||||
// One-time migration (v4): remove caption-in-image instruction from style suffix.
|
||||
// Captions are now inserted as HTML below the image, not baked into the image.
|
||||
if ( ! get_option( 'prautoblogger_migrated_style_suffix_v4' ) ) {
|
||||
$current = get_option( 'prautoblogger_image_style_suffix', '' );
|
||||
// Detect v3 style suffix (contains "caption text" instruction).
|
||||
if ( false !== strpos( $current, 'caption text in a clean sans-serif font below the panel' ) ) {
|
||||
update_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
|
||||
}
|
||||
update_option( 'prautoblogger_migrated_style_suffix_v4', '1' );
|
||||
}
|
||||
|
||||
// One-time migration (v0.16.0): editorial pivot. The comic Style Suffix
|
||||
// is replaced by the editorial Style Template. Mirror the old suffix
|
||||
// value into a deprecated-keyed option for one version cycle (so a
|
||||
// customised value is recoverable / auditable) and seed the new
|
||||
// template with its editorial default. The old option is intentionally
|
||||
// NOT deleted this cycle. See docs/proposals/2026-05-29-image-pipeline-in-plugin-brief.md §8 Commit 1.
|
||||
if ( ! get_option( 'prautoblogger_migrated_style_template_v0160' ) ) {
|
||||
$old_suffix = get_option( 'prautoblogger_image_style_suffix', '' );
|
||||
if ( '' !== $old_suffix && false === get_option( 'prautoblogger_image_style_suffix_deprecated', false ) ) {
|
||||
update_option( 'prautoblogger_image_style_suffix_deprecated', $old_suffix );
|
||||
}
|
||||
// Only seed the new template if the admin has not already set one.
|
||||
if ( '' === (string) get_option( 'prautoblogger_image_style_template', '' ) ) {
|
||||
update_option( 'prautoblogger_image_style_template', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE );
|
||||
}
|
||||
update_option( 'prautoblogger_migrated_style_template_v0160', '1' );
|
||||
}
|
||||
|
||||
// v0.9.0 — Runware as default image model. v0.10.0 — remove CF Workers AI.
|
||||
PRAutoBlogger_Activator::migrate_default_image_v090();
|
||||
PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
|
||||
|
||||
// One-time migration: re-wrap existing encrypted values with "enc:" prefix.
|
||||
if ( ! get_option( 'prautoblogger_migrated_enc_prefix' ) ) {
|
||||
$enc_options = array( 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json', 'prautoblogger_runware_api_key' );
|
||||
foreach ( $enc_options as $opt ) {
|
||||
$val = get_option( $opt, '' );
|
||||
if ( '' !== $val && ! PRAutoBlogger_Encryption::is_encrypted( $val ) ) {
|
||||
delete_option( $opt );
|
||||
}
|
||||
}
|
||||
update_option( 'prautoblogger_migrated_enc_prefix', '1' );
|
||||
}
|
||||
PRAutoBlogger_DB_Migrations::run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the db-version check on cron requests (admin_init never fires
|
||||
* there). Gated to wp_doing_cron() so normal frontend requests pay
|
||||
* nothing beyond one autoloaded option read.
|
||||
*
|
||||
* Side effects: may run the same migrations as on_check_db_version().
|
||||
*
|
||||
* @return void
|
||||
* Run DB migrations during cron requests (hooked on `init`).
|
||||
* Only fires when wp_doing_cron() is true.
|
||||
*/
|
||||
public function on_check_db_version_for_cron(): void {
|
||||
if ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ) {
|
||||
@@ -328,7 +216,7 @@ class PRAutoBlogger {
|
||||
/**
|
||||
* Add custom cron schedules (six-hourly for metrics collection).
|
||||
*
|
||||
* @param array<string, array{interval: int, display: string}> $schedules Existing schedules.
|
||||
* @param array<string, array{interval: int, display: string}> $schedules Existing.
|
||||
* @return array<string, array{interval: int, display: string}>
|
||||
*/
|
||||
public function filter_add_cron_schedules( array $schedules ): array {
|
||||
@@ -346,7 +234,6 @@ class PRAutoBlogger {
|
||||
|
||||
/**
|
||||
* Block false update notifications from wordpress.org.
|
||||
* Our plugin slug may collide with a different plugin in the directory.
|
||||
*
|
||||
* @param object $transient The update_plugins transient data.
|
||||
* @return object Modified transient.
|
||||
@@ -363,7 +250,7 @@ class PRAutoBlogger {
|
||||
return $this->executor;
|
||||
}
|
||||
|
||||
// ── Backward-compatible proxies ──
|
||||
// -- Backward-compatible proxies --
|
||||
|
||||
/** @see PRAutoBlogger_Executor::on_daily_generation() */
|
||||
public function on_daily_generation(): void {
|
||||
|
||||
@@ -71,7 +71,8 @@ class PRAutoBlogger_Schema_Installer {
|
||||
metadata_json LONGTEXT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY type_relevance (analysis_type, relevance_score),
|
||||
KEY analyzed_at (analyzed_at)
|
||||
KEY analyzed_at (analyzed_at),
|
||||
KEY analyzed_type (analyzed_at, analysis_type)
|
||||
) {$charset_collate};";
|
||||
|
||||
// Generation log — every API call with cost tracking. `run_id`
|
||||
@@ -100,7 +101,9 @@ class PRAutoBlogger_Schema_Installer {
|
||||
KEY post_id (post_id),
|
||||
KEY run_id (run_id),
|
||||
KEY created_at (created_at),
|
||||
KEY stage (stage)
|
||||
KEY stage (stage),
|
||||
KEY run_id_post_id (run_id, post_id),
|
||||
KEY created_status (created_at, response_status)
|
||||
) {$charset_collate};";
|
||||
|
||||
// Content scores — post performance metrics for self-improvement.
|
||||
|
||||
@@ -58,6 +58,13 @@ class PRAutoBlogger_Article_Worker {
|
||||
$run_id = (string) ( $this->cost_tracker->get_run_id() ?? '' );
|
||||
$item = PRAutoBlogger_Run_Stage_State::item_key_for_idea( $idea );
|
||||
|
||||
// v0.20.0: persist the EXACT idea payload once per item so
|
||||
// re-run-from-here can reconstruct it (rebuilding from post
|
||||
// fields would risk an item_key hash mismatch -> duplicate post).
|
||||
if ( '' !== $run_id ) {
|
||||
PRAutoBlogger_Stage_Input_Store::save_seed( $run_id, $item, (string) wp_json_encode( $idea->to_array() ) );
|
||||
}
|
||||
|
||||
$result = array(
|
||||
'generated' => 0,
|
||||
'published' => 0,
|
||||
|
||||
@@ -125,6 +125,37 @@ class PRAutoBlogger_Audit_Writer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flag the run_decisions rows of given stages as human-modified
|
||||
* (v0.20.0 / CPO guardrail 2). Used in two cases: the decision rows of
|
||||
* a stage whose edited input enters execution, and decisions recorded
|
||||
* during a re-run-from-here window on an item that carries a human
|
||||
* edit (derived-from-edited-content). Self-healing no-op when the
|
||||
* column/table is missing.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param array<string> $stages Stage names to flag.
|
||||
* @param string|null $since Optional MySQL datetime — only flag rows created at/after it.
|
||||
* @return int Rows flagged.
|
||||
*/
|
||||
public static function flag_decisions_human_modified( string $run_id, array $stages, ?string $since = null ): int {
|
||||
if ( '' === $run_id || empty( $stages ) || ! self::is_available() ) {
|
||||
return 0;
|
||||
}
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'prautoblogger_run_decisions';
|
||||
$placeholders = implode( ',', array_fill( 0, count( $stages ), '%s' ) );
|
||||
$sql = "UPDATE {$table} SET human_modified = 1 WHERE run_id = %s AND stage IN ({$placeholders})";
|
||||
$params = array_merge( array( $run_id ), array_values( $stages ) );
|
||||
if ( null !== $since ) {
|
||||
$sql .= ' AND created_at >= %s';
|
||||
$params[] = $since;
|
||||
}
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared -- $placeholders is a fixed %s list.
|
||||
$updated = $wpdb->query( $wpdb->prepare( $sql, $params ) );
|
||||
return is_numeric( $updated ) ? (int) $updated : 0;
|
||||
}
|
||||
|
||||
/** Reset per-request caches (tests). */
|
||||
public static function flush_cache(): void {
|
||||
self::$tables_ok = null;
|
||||
|
||||
@@ -36,8 +36,9 @@ class PRAutoBlogger_Cost_Reporter {
|
||||
$first_of_month = gmdate( 'Y-m-01 00:00:00' );
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$result = $wpdb->get_var(
|
||||
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$wpdb->prepare(
|
||||
"SELECT COALESCE(SUM(estimated_cost), 0) FROM {$table} WHERE created_at >= %s AND response_status = 'success'",
|
||||
$first_of_month
|
||||
)
|
||||
@@ -64,8 +65,8 @@ class PRAutoBlogger_Cost_Reporter {
|
||||
$start_date = gmdate( 'Y-m-d', time() - ( $days * DAY_IN_SECONDS ) );
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$results = $wpdb->get_results(
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$wpdb->prepare(
|
||||
"SELECT DATE(created_at) as day, SUM(estimated_cost) as total_cost
|
||||
FROM {$table}
|
||||
@@ -102,8 +103,8 @@ class PRAutoBlogger_Cost_Reporter {
|
||||
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$results = $wpdb->get_results(
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$wpdb->prepare(
|
||||
"SELECT stage,
|
||||
SUM(estimated_cost) as total_cost,
|
||||
@@ -142,6 +143,7 @@ class PRAutoBlogger_Cost_Reporter {
|
||||
}
|
||||
return ( $this->get_monthly_spend() / $budget ) * 100.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get average input/output token counts for given stages over a time period.
|
||||
*
|
||||
|
||||
@@ -69,7 +69,9 @@ class PRAutoBlogger_Cost_Tracker {
|
||||
/**
|
||||
* Log an API call with its token usage and estimated cost.
|
||||
*
|
||||
* Side effects: database insert into prab_generation_log.
|
||||
* Side effects: database insert into prab_generation_log. Consumes the
|
||||
* Request_Recorder stash (v0.20.0 / B1) into the row's request_json so
|
||||
* every chat call's input is persisted (retention-pruned after R days).
|
||||
*
|
||||
* @param int|null $post_id WordPress post ID (null during generation, set later).
|
||||
* @param string $stage Pipeline stage (see PRAutoBlogger_Stage_Display_Map).
|
||||
@@ -117,6 +119,7 @@ class PRAutoBlogger_Cost_Tracker {
|
||||
'estimated_cost' => $cost,
|
||||
'response_status' => $response_status,
|
||||
'error_message' => $error_message,
|
||||
'request_json' => PRAutoBlogger_Request_Recorder::consume(),
|
||||
'agent_role' => $agent_role ?? PRAutoBlogger_Stage_Display_Map::default_agent_role( $stage ),
|
||||
'prompt_version' => $this->resolve_prompt_version( $stage, $prompt_key ),
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
|
||||
71
includes/core/class-generation-checkpoint-helpers.php
Normal file
71
includes/core/class-generation-checkpoint-helpers.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Private helpers for PRAutoBlogger_Generation_Checkpoint_Runner (v0.21.0, M4).
|
||||
*
|
||||
* Extracted to keep class-generation-checkpoint-runner.php under the 300-line
|
||||
* cap (CONVENTIONS §1). Contains the three internal-only static methods:
|
||||
* finalize() — write final transient, mark run done, release lock.
|
||||
* cleanup_queue() — delete queue + run-id options on abort paths.
|
||||
* fire_cron_now() — non-blocking loopback to fire cron events immediately.
|
||||
*
|
||||
* Only PRAutoBlogger_Generation_Checkpoint_Runner should call this class.
|
||||
*
|
||||
* @see core/class-generation-checkpoint-runner.php — Caller / orchestrator.
|
||||
* @see ARCHITECTURE.md ss25 — Checkpoint design.
|
||||
*/
|
||||
class PRAutoBlogger_Generation_Checkpoint_Helpers {
|
||||
|
||||
/** Option key for the checkpoint queue (mirrors Runner constant). */
|
||||
private const QUEUE_KEY = 'prautoblogger_article_queue';
|
||||
|
||||
/** Option key for the in-progress run UUID (mirrors Runner constant). */
|
||||
private const RUN_ID_KEY = 'prautoblogger_checkpoint_run_id';
|
||||
|
||||
/**
|
||||
* Finalize a completed run: write final transient, mark run done, release lock.
|
||||
*
|
||||
* @param array $results Generation counters (generated/published/rejected/cost).
|
||||
* @param string $run_id Run UUID (read from option if blank).
|
||||
* @return void
|
||||
*/
|
||||
public static function finalize( array $results, string $run_id = '' ): void {
|
||||
if ( '' === $run_id ) {
|
||||
$run_id = (string) get_option( self::RUN_ID_KEY, '' );
|
||||
}
|
||||
if ( '' !== $run_id ) {
|
||||
PRAutoBlogger_Run_State::mark_status( $run_id, 'done' );
|
||||
}
|
||||
PRAutoBlogger_Pipeline_Status::write_final( $results );
|
||||
PRAutoBlogger_Pipeline_Status::log_summary( $results );
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
delete_option( self::RUN_ID_KEY );
|
||||
}
|
||||
|
||||
/** Remove the persistent queue and run-id option on abort paths. */
|
||||
public static function cleanup_queue(): void {
|
||||
delete_option( self::QUEUE_KEY );
|
||||
delete_option( self::RUN_ID_KEY );
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-blocking loopback to fire cron events immediately.
|
||||
* Mirrors Pipeline_Runner::schedule_next() exactly.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function fire_cron_now(): void {
|
||||
spawn_cron();
|
||||
wp_remote_post(
|
||||
site_url( 'wp-cron.php?doing_wp_cron=' . sprintf( '%.22F', microtime( true ) ) ),
|
||||
array(
|
||||
'timeout' => 0.01,
|
||||
'blocking' => false,
|
||||
'sslverify' => false,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
267
includes/core/class-generation-checkpoint-runner.php
Normal file
267
includes/core/class-generation-checkpoint-runner.php
Normal file
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Chained-cron checkpoint runner for in-admin manual article generation (v0.21.0, M4).
|
||||
*
|
||||
* What: Replaces the monolithic on_manual_generation() path with a two-tick
|
||||
* checkpoint machine so Hostinger's background-PHP-process kill cannot
|
||||
* abort a mid-pipeline run:
|
||||
* Tick 1 (ORCHESTRATE): collect sources -> analyze patterns -> score ideas ->
|
||||
* persist idea queue to option -> schedule Tick 2.
|
||||
* Tick 2..N (GENERATE): pop one idea from queue -> Article_Worker ->
|
||||
* update transient -> schedule next tick or finalize.
|
||||
*
|
||||
* Why separate from Rerun_Executor: Rerun_Executor targets existing run_stages
|
||||
* rows (replay/rebuild). This class creates a brand-new run from scratch.
|
||||
*
|
||||
* SSH retirement (v0.21.0): the former `wp eval 'do_action("prautoblogger_manual_generation")'`
|
||||
* workaround fired on_manual_generation() synchronously in one PHP process --
|
||||
* bypassing the nonce and running the full pipeline without checkpoints.
|
||||
* That path is retired: Executor::on_manual_generation() now delegates here,
|
||||
* and `wp prautoblogger generate` queues via kick_off() (see ARCHITECTURE.md ss25).
|
||||
*
|
||||
* Cost governor: Run_State::ceiling_setting() is applied per-run; a halted run
|
||||
* aborts all remaining ticks (same guarantee as Pipeline_Runner's queued-article path).
|
||||
*
|
||||
* Who triggers it: PRAutoBlogger_Executor::on_manual_generation() (cron),
|
||||
* PRAutoBlogger_WP_CLI_Commands (wp prautoblogger generate).
|
||||
* Dependencies: Pipeline_Runner (orchestration), Run_State (run ledger),
|
||||
* Pipeline_Status (transient), Generation_Lock, Cost_Tracker, Logger.
|
||||
*
|
||||
* @see includes/class-executor.php -- Cron/AJAX entry points.
|
||||
* @see core/class-pipeline-runner.php -- Orchestration + article worker.
|
||||
* @see core/class-run-state.php -- Per-run ledger (cost governor).
|
||||
* @see core/class-pipeline-status.php -- Transient broadcast helpers.
|
||||
* @see core/class-generation-checkpoint-helpers.php -- finalize/cleanup/fire helpers.
|
||||
* @see ARCHITECTURE.md ss25 -- Checkpoint generation path design.
|
||||
*/
|
||||
class PRAutoBlogger_Generation_Checkpoint_Runner {
|
||||
|
||||
/** Option key for the checkpoint queue (shared with Pipeline_Runner article queue). */
|
||||
private const QUEUE_KEY = 'prautoblogger_article_queue';
|
||||
|
||||
/** Option key for the in-progress run UUID (generation tick). */
|
||||
private const RUN_ID_KEY = 'prautoblogger_checkpoint_run_id';
|
||||
|
||||
/** WP-Cron action for the orchestration tick. */
|
||||
public const ORCHESTRATE_ACTION = 'prautoblogger_gen_orchestrate';
|
||||
|
||||
/** WP-Cron action for each article-generation tick. */
|
||||
public const GENERATE_ACTION = 'prautoblogger_gen_tick';
|
||||
|
||||
/**
|
||||
* Schedule the orchestration tick and write the initial status transient.
|
||||
* Called by on_ajax_generate_now() (via Executor) and by the WP-CLI command.
|
||||
* Returns immediately -- ALL pipeline work happens in cron.
|
||||
*
|
||||
* Side effects: cron scheduling, transient write.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function kick_off(): void {
|
||||
if ( wp_next_scheduled( self::ORCHESTRATE_ACTION ) ) {
|
||||
return; // Idempotent: already queued.
|
||||
}
|
||||
wp_schedule_single_event( time(), self::ORCHESTRATE_ACTION );
|
||||
PRAutoBlogger_Pipeline_Status::write_initial();
|
||||
PRAutoBlogger_Generation_Checkpoint_Helpers::fire_cron_now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron Tick 1: collect -> analyze -> score. Persists ideas, creates run row,
|
||||
* schedules Tick 2 for the first article, then returns.
|
||||
*
|
||||
* Side effects: source/analysis DB writes, run row insert, option write,
|
||||
* status transient update, cron scheduling.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function on_orchestrate_tick(): void {
|
||||
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
@ignore_user_abort( true );
|
||||
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
@set_time_limit( 300 );
|
||||
|
||||
if ( ! PRAutoBlogger_Generation_Lock::acquire() ) {
|
||||
PRAutoBlogger_Pipeline_Status::write_error(
|
||||
__( 'Could not acquire generation lock. Another run may be in progress.', 'prautoblogger' )
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
$run_id = wp_generate_uuid4();
|
||||
update_option( self::RUN_ID_KEY, $run_id, false );
|
||||
PRAutoBlogger_Run_State::ensure_run( $run_id );
|
||||
|
||||
$cost_tracker = new PRAutoBlogger_Cost_Tracker();
|
||||
$cost_tracker->set_run_id( $run_id );
|
||||
|
||||
try {
|
||||
if ( $cost_tracker->is_budget_exceeded() ) {
|
||||
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
|
||||
PRAutoBlogger_Pipeline_Status::write_error(
|
||||
__( 'Monthly API budget exceeded. Generation halted.', 'prautoblogger' )
|
||||
);
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
return;
|
||||
}
|
||||
|
||||
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Collecting sources…', 'prautoblogger' ) );
|
||||
$runner = ( new PRAutoBlogger_Pipeline_Runner() )->set_skip_dedup( true );
|
||||
$ideas = $runner->orchestrate_only( $cost_tracker );
|
||||
|
||||
if ( empty( $ideas ) ) {
|
||||
PRAutoBlogger_Run_State::mark_status( $run_id, 'done' );
|
||||
PRAutoBlogger_Pipeline_Status::write_final(
|
||||
array(
|
||||
'generated' => 0,
|
||||
'published' => 0,
|
||||
'rejected' => 0,
|
||||
'cost' => 0.0,
|
||||
)
|
||||
);
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
delete_option( self::RUN_ID_KEY );
|
||||
return;
|
||||
}
|
||||
|
||||
$serialized = array_map(
|
||||
static fn( PRAutoBlogger_Article_Idea $i ) => $i->to_array(),
|
||||
$ideas
|
||||
);
|
||||
update_option(
|
||||
self::QUEUE_KEY,
|
||||
array(
|
||||
'run_id' => $run_id,
|
||||
'ideas' => $serialized,
|
||||
'results' => array(
|
||||
'generated' => 0,
|
||||
'published' => 0,
|
||||
'rejected' => 0,
|
||||
'cost' => 0.0,
|
||||
),
|
||||
),
|
||||
false
|
||||
);
|
||||
|
||||
PRAutoBlogger_Pipeline_Status::broadcast(
|
||||
sprintf(
|
||||
/* translators: %d: number of ideas found */
|
||||
__( 'Found %d idea(s) — starting generation…', 'prautoblogger' ),
|
||||
count( $ideas )
|
||||
)
|
||||
);
|
||||
|
||||
if ( ! wp_next_scheduled( self::GENERATE_ACTION ) ) {
|
||||
wp_schedule_single_event( time(), self::GENERATE_ACTION );
|
||||
}
|
||||
PRAutoBlogger_Generation_Checkpoint_Helpers::fire_cron_now();
|
||||
|
||||
} catch ( \Throwable $e ) {
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
sprintf( 'Orchestrate tick %s: %s', get_class( $e ), $e->getMessage() ),
|
||||
'pipeline'
|
||||
);
|
||||
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
|
||||
PRAutoBlogger_Pipeline_Status::write_error( $e->getMessage() );
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
delete_option( self::RUN_ID_KEY );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron Tick 2..N: generate one article, persist totals, reschedule or finalize.
|
||||
*
|
||||
* Side effects: LLM API calls, DB writes, post creation, transient updates,
|
||||
* cron scheduling.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function on_generate_tick(): void {
|
||||
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
@ignore_user_abort( true );
|
||||
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
@set_time_limit( 300 );
|
||||
|
||||
$queue = get_option( self::QUEUE_KEY );
|
||||
if ( ! is_array( $queue ) || empty( $queue['ideas'] ) ) {
|
||||
PRAutoBlogger_Generation_Checkpoint_Helpers::finalize(
|
||||
array(
|
||||
'generated' => 0,
|
||||
'published' => 0,
|
||||
'rejected' => 0,
|
||||
'cost' => 0.0,
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
$run_id = (string) ( $queue['run_id'] ?? '' );
|
||||
$run_status = PRAutoBlogger_Run_State::get_status( $run_id );
|
||||
if ( in_array( $run_status, array( 'halted', 'failed' ), true ) ) {
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf( 'Generate tick: run %s is %s -- aborting.', $run_id, $run_status ),
|
||||
'pipeline'
|
||||
);
|
||||
PRAutoBlogger_Generation_Checkpoint_Helpers::cleanup_queue();
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
return;
|
||||
}
|
||||
|
||||
$idea_data = array_shift( $queue['ideas'] );
|
||||
$idea = new PRAutoBlogger_Article_Idea( $idea_data );
|
||||
$cost_tracker = new PRAutoBlogger_Cost_Tracker();
|
||||
$cost_tracker->set_run_id( $run_id );
|
||||
|
||||
if ( $cost_tracker->is_budget_exceeded() ) {
|
||||
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
|
||||
PRAutoBlogger_Generation_Checkpoint_Helpers::cleanup_queue();
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
PRAutoBlogger_Pipeline_Status::write_error(
|
||||
__( 'Monthly API budget exceeded. Generation halted.', 'prautoblogger' )
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Persist consumed queue BEFORE generating (prevents orphan-recovery duplication).
|
||||
if ( ! empty( $queue['ideas'] ) ) {
|
||||
update_option( self::QUEUE_KEY, $queue, false );
|
||||
} else {
|
||||
delete_option( self::QUEUE_KEY );
|
||||
}
|
||||
|
||||
try {
|
||||
$worker = new PRAutoBlogger_Article_Worker( $cost_tracker );
|
||||
$result = $worker->generate( $idea );
|
||||
|
||||
$queue['results']['generated'] += $result['generated'];
|
||||
$queue['results']['published'] += $result['published'];
|
||||
$queue['results']['rejected'] += $result['rejected'];
|
||||
$queue['results']['cost'] += $result['cost'];
|
||||
|
||||
if ( ! empty( $queue['ideas'] ) ) {
|
||||
update_option( self::QUEUE_KEY, $queue, false );
|
||||
PRAutoBlogger_Pipeline_Status::update_queue_progress( $queue );
|
||||
if ( ! wp_next_scheduled( self::GENERATE_ACTION ) ) {
|
||||
wp_schedule_single_event( time(), self::GENERATE_ACTION );
|
||||
}
|
||||
PRAutoBlogger_Generation_Checkpoint_Helpers::fire_cron_now();
|
||||
} else {
|
||||
PRAutoBlogger_Post_Assembler::amortize_research_costs( $run_id );
|
||||
PRAutoBlogger_Generation_Checkpoint_Helpers::finalize( $queue['results'], $run_id );
|
||||
}
|
||||
} catch ( \Throwable $e ) {
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
sprintf( 'Generate tick %s for run %s: %s', get_class( $e ), $run_id, $e->getMessage() ),
|
||||
'pipeline'
|
||||
);
|
||||
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
|
||||
PRAutoBlogger_Generation_Checkpoint_Helpers::cleanup_queue();
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
PRAutoBlogger_Pipeline_Status::write_error( $e->getMessage() );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,10 @@ declare(strict_types=1);
|
||||
* API is unavailable.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Pipeline_Runner (step 3).
|
||||
* Dependencies: Semantic_Dedup, WP_Query.
|
||||
* Dependencies: Semantic_Dedup, Keyword_Extractor, WP_Query.
|
||||
*
|
||||
* @see core/class-semantic-dedup.php — Embedding + keyword dedup engine.
|
||||
* @see core/class-semantic-dedup.php — Embedding + keyword dedup engine.
|
||||
* @see core/class-keyword-extractor.php — Keyword tokeniser (fallback dedup).
|
||||
* @see core/class-content-analyzer.php — Produces the analysis results we consume.
|
||||
* @see core/class-content-generator.php — Consumes the ranked ideas we produce.
|
||||
* @see ARCHITECTURE.md — Data flow step 3.
|
||||
@@ -67,7 +68,7 @@ class PRAutoBlogger_Idea_Scorer {
|
||||
|
||||
if ( ! $this->skip_dedup ) {
|
||||
$topic = $result->get_topic();
|
||||
$topic_keywords = $this->extract_meaningful_keywords( $topic );
|
||||
$topic_keywords = PRAutoBlogger_Keyword_Extractor::extract( $topic );
|
||||
|
||||
if ( $dedup->is_batch_duplicate( $topic, $topic_keywords, $accepted_keyword_sets ) ) {
|
||||
++$deduped_count;
|
||||
@@ -165,133 +166,6 @@ class PRAutoBlogger_Idea_Scorer {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract meaningful keywords from a text string (for fallback dedup).
|
||||
*
|
||||
* @param string $text Input text.
|
||||
* @return string[] Unique lowercase keywords.
|
||||
*/
|
||||
private function extract_meaningful_keywords( string $text ): array {
|
||||
static $stopwords = array(
|
||||
'a',
|
||||
'an',
|
||||
'the',
|
||||
'and',
|
||||
'or',
|
||||
'but',
|
||||
'is',
|
||||
'are',
|
||||
'was',
|
||||
'were',
|
||||
'in',
|
||||
'on',
|
||||
'at',
|
||||
'to',
|
||||
'for',
|
||||
'of',
|
||||
'with',
|
||||
'by',
|
||||
'from',
|
||||
'as',
|
||||
'it',
|
||||
'its',
|
||||
'this',
|
||||
'that',
|
||||
'your',
|
||||
'you',
|
||||
'how',
|
||||
'what',
|
||||
'when',
|
||||
'why',
|
||||
'where',
|
||||
'which',
|
||||
'who',
|
||||
'do',
|
||||
'does',
|
||||
'did',
|
||||
'not',
|
||||
'no',
|
||||
'can',
|
||||
'will',
|
||||
'should',
|
||||
'could',
|
||||
'would',
|
||||
'may',
|
||||
'might',
|
||||
'have',
|
||||
'has',
|
||||
'had',
|
||||
'be',
|
||||
'been',
|
||||
'being',
|
||||
'about',
|
||||
'into',
|
||||
'through',
|
||||
'during',
|
||||
'before',
|
||||
'after',
|
||||
'above',
|
||||
'below',
|
||||
'between',
|
||||
'same',
|
||||
'up',
|
||||
'down',
|
||||
'out',
|
||||
'off',
|
||||
'over',
|
||||
'under',
|
||||
'again',
|
||||
'then',
|
||||
'here',
|
||||
'there',
|
||||
'all',
|
||||
'each',
|
||||
'every',
|
||||
'both',
|
||||
'few',
|
||||
'more',
|
||||
'most',
|
||||
'other',
|
||||
'some',
|
||||
'such',
|
||||
'than',
|
||||
'too',
|
||||
'very',
|
||||
'just',
|
||||
'also',
|
||||
'only',
|
||||
'own',
|
||||
'so',
|
||||
'if',
|
||||
'while',
|
||||
'because',
|
||||
'until',
|
||||
'vs',
|
||||
'versus',
|
||||
'guide',
|
||||
'complete',
|
||||
'ultimate',
|
||||
'best',
|
||||
'top',
|
||||
'new',
|
||||
'first',
|
||||
'need',
|
||||
'know',
|
||||
'everything',
|
||||
);
|
||||
|
||||
$text = strtolower( $text );
|
||||
$words = preg_split( '/[^a-z0-9-]+/', $text, -1, PREG_SPLIT_NO_EMPTY );
|
||||
$words = array_filter(
|
||||
$words,
|
||||
static function ( string $w ) use ( $stopwords ): bool {
|
||||
return strlen( $w ) >= 2 && ! in_array( $w, $stopwords, true );
|
||||
}
|
||||
);
|
||||
|
||||
return array_values( array_unique( $words ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Map analysis type to a reader-friendly article type.
|
||||
*
|
||||
|
||||
@@ -38,6 +38,10 @@ class PRAutoBlogger_Image_Composer_Layout {
|
||||
* plugin; sizes are px. Values were tuned against rendered samples (see
|
||||
* PR #thread 2026-06-image-composer assets).
|
||||
*
|
||||
* Note: 'caption_margin_right' (og) and 'caption_side_padding' (square) were removed
|
||||
* in v0.19.4 — the Imagick renderer enforces right-edge geometry via caption_chars_per_line
|
||||
* and centered x positioning respectively, so those keys had no consumer.
|
||||
*
|
||||
* @return array<string, array<string, mixed>> Keyed by role: featured, og, square.
|
||||
*/
|
||||
public static function defaults(): array {
|
||||
@@ -60,7 +64,6 @@ class PRAutoBlogger_Image_Composer_Layout {
|
||||
'caption_size' => 32,
|
||||
'caption_color' => '#FFFFFF',
|
||||
'caption_gap' => 24,
|
||||
'caption_margin_right' => 40,
|
||||
'caption_max_lines' => 2,
|
||||
'caption_chars_per_line' => 52,
|
||||
'caption_line_height' => 1.3,
|
||||
@@ -79,7 +82,6 @@ class PRAutoBlogger_Image_Composer_Layout {
|
||||
'caption_color' => self::COLOR_INK,
|
||||
'caption_max_lines' => 3,
|
||||
'caption_chars_per_line' => 36,
|
||||
'caption_side_padding' => 64,
|
||||
'caption_line_height' => 1.3,
|
||||
),
|
||||
);
|
||||
|
||||
156
includes/core/class-keyword-extractor.php
Normal file
156
includes/core/class-keyword-extractor.php
Normal file
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Shared keyword-extraction utility for the scoring and dedup pipeline.
|
||||
*
|
||||
* Extracts meaningful lowercase tokens from free text by stripping stop-words.
|
||||
* Used by both PRAutoBlogger_Idea_Scorer and PRAutoBlogger_Semantic_Dedup as the
|
||||
* fallback path when embedding similarity is unavailable.
|
||||
*
|
||||
* What: Static utility; no state.
|
||||
* Who triggers: PRAutoBlogger_Idea_Scorer, PRAutoBlogger_Semantic_Dedup.
|
||||
* Dependencies: none.
|
||||
*
|
||||
* @see core/class-idea-scorer.php -- Primary consumer (scoring pipeline).
|
||||
* @see core/class-semantic-dedup.php -- Secondary consumer (embedding fallback).
|
||||
*/
|
||||
class PRAutoBlogger_Keyword_Extractor {
|
||||
|
||||
/**
|
||||
* Common English stop-words filtered out before keyword matching.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
private const STOPWORDS = array(
|
||||
'a',
|
||||
'an',
|
||||
'the',
|
||||
'and',
|
||||
'or',
|
||||
'but',
|
||||
'is',
|
||||
'are',
|
||||
'was',
|
||||
'were',
|
||||
'in',
|
||||
'on',
|
||||
'at',
|
||||
'to',
|
||||
'for',
|
||||
'of',
|
||||
'with',
|
||||
'by',
|
||||
'from',
|
||||
'as',
|
||||
'it',
|
||||
'its',
|
||||
'this',
|
||||
'that',
|
||||
'your',
|
||||
'you',
|
||||
'how',
|
||||
'what',
|
||||
'when',
|
||||
'why',
|
||||
'where',
|
||||
'which',
|
||||
'who',
|
||||
'do',
|
||||
'does',
|
||||
'did',
|
||||
'not',
|
||||
'no',
|
||||
'can',
|
||||
'will',
|
||||
'should',
|
||||
'could',
|
||||
'would',
|
||||
'may',
|
||||
'might',
|
||||
'have',
|
||||
'has',
|
||||
'had',
|
||||
'be',
|
||||
'been',
|
||||
'being',
|
||||
'about',
|
||||
'into',
|
||||
'through',
|
||||
'during',
|
||||
'before',
|
||||
'after',
|
||||
'above',
|
||||
'below',
|
||||
'between',
|
||||
'same',
|
||||
'up',
|
||||
'down',
|
||||
'out',
|
||||
'off',
|
||||
'over',
|
||||
'under',
|
||||
'again',
|
||||
'then',
|
||||
'here',
|
||||
'there',
|
||||
'all',
|
||||
'each',
|
||||
'every',
|
||||
'both',
|
||||
'few',
|
||||
'more',
|
||||
'most',
|
||||
'other',
|
||||
'some',
|
||||
'such',
|
||||
'than',
|
||||
'too',
|
||||
'very',
|
||||
'just',
|
||||
'also',
|
||||
'only',
|
||||
'own',
|
||||
'so',
|
||||
'if',
|
||||
'while',
|
||||
'because',
|
||||
'until',
|
||||
'vs',
|
||||
'versus',
|
||||
'guide',
|
||||
'complete',
|
||||
'ultimate',
|
||||
'best',
|
||||
'top',
|
||||
'new',
|
||||
'first',
|
||||
'need',
|
||||
'know',
|
||||
'everything',
|
||||
);
|
||||
|
||||
/**
|
||||
* Extract meaningful keywords from text.
|
||||
*
|
||||
* Lowercases input, splits on non-alphanumeric characters, filters
|
||||
* stop-words and single-character tokens, and de-duplicates.
|
||||
*
|
||||
* @param string $text Input text.
|
||||
* @return string[] Unique lowercase keyword tokens.
|
||||
*/
|
||||
public static function extract( string $text ): array {
|
||||
$text = strtolower( $text );
|
||||
$words = preg_split( '/[^a-z0-9-]+/', $text, -1, PREG_SPLIT_NO_EMPTY );
|
||||
$words = array_filter(
|
||||
$words,
|
||||
static function ( string $w ): bool {
|
||||
return strlen( $w ) >= 2 && ! in_array( $w, self::STOPWORDS, true );
|
||||
}
|
||||
);
|
||||
|
||||
return array_values( array_unique( $words ) );
|
||||
}
|
||||
}
|
||||
@@ -168,6 +168,19 @@ class PRAutoBlogger_Pipeline_Runner {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Public wrapper for orchestrate(): collect -> analyze -> score.
|
||||
* Used by Generation_Checkpoint_Runner (Tick 1) so orchestration and
|
||||
* article generation happen in separate cron ticks.
|
||||
*
|
||||
* @param PRAutoBlogger_Cost_Tracker $cost_tracker
|
||||
* @return PRAutoBlogger_Article_Idea[]
|
||||
*/
|
||||
public function orchestrate_only( PRAutoBlogger_Cost_Tracker $cost_tracker ): array {
|
||||
return $this->orchestrate( $cost_tracker );
|
||||
}
|
||||
|
||||
// ── Private helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -103,4 +103,41 @@ class PRAutoBlogger_Pipeline_Status {
|
||||
'pipeline'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an initial "running" status before the first tick fires.
|
||||
* Mirrors the shape on_ajax_generate_now() already writes so the poller
|
||||
* sees a consistent "running" state whether triggered from board or settings page.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function write_initial(): void {
|
||||
set_transient(
|
||||
self::KEY,
|
||||
array(
|
||||
'status' => 'running',
|
||||
'stage' => __( 'Starting generation…', 'prautoblogger' ),
|
||||
'started' => time(),
|
||||
'last_updated' => time(),
|
||||
),
|
||||
self::TTL
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a terminal "error" status.
|
||||
*
|
||||
* @param string $message Operator-facing error description.
|
||||
* @return void
|
||||
*/
|
||||
public static function write_error( string $message ): void {
|
||||
set_transient(
|
||||
self::KEY,
|
||||
array(
|
||||
'status' => 'error',
|
||||
'message' => $message,
|
||||
),
|
||||
self::TTL
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +91,9 @@ class PRAutoBlogger_Post_Assembler {
|
||||
* are generated. Its cost is logged with the run_id but no post_id. After all
|
||||
* articles finish, this method divides the research cost evenly and inserts
|
||||
* one 'llm_research' row per article so the cost breakdown popover includes
|
||||
* the amortized research overhead.
|
||||
* the amortized research overhead. The audit fields prompt_version and
|
||||
* agent_role are carried from the original research row (v0.19.4 fix;
|
||||
* prior builds left them NULL/empty — see phase-1 thread seq 49).
|
||||
*
|
||||
* Side effects: database SELECT, INSERT, DELETE on generation_log.
|
||||
*
|
||||
@@ -109,7 +111,7 @@ class PRAutoBlogger_Post_Assembler {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$research_row = $wpdb->get_row(
|
||||
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
"SELECT id, estimated_cost, provider, model, prompt_tokens, completion_tokens
|
||||
"SELECT id, estimated_cost, provider, model, prompt_tokens, completion_tokens, prompt_version, agent_role
|
||||
FROM {$table}
|
||||
WHERE run_id = %s AND stage = 'llm_research' AND post_id IS NULL
|
||||
LIMIT 1",
|
||||
@@ -158,6 +160,8 @@ class PRAutoBlogger_Post_Assembler {
|
||||
'estimated_cost' => $amortized_cost,
|
||||
'response_status' => 'success',
|
||||
'error_message' => '',
|
||||
'prompt_version' => $research_row->prompt_version,
|
||||
'agent_role' => $research_row->agent_role ?? '',
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
)
|
||||
);
|
||||
|
||||
@@ -107,11 +107,11 @@ class PRAutoBlogger_Publisher {
|
||||
if ( null !== $run_id && '' !== $run_id ) {
|
||||
$existing_id = $this->find_existing_post( $run_id, $idea_hash );
|
||||
if ( $existing_id > 0 ) {
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf( 'Post for run %s / idea %s already exists (ID=%d) — skipping duplicate insert.', $run_id, $idea_hash, $existing_id ),
|
||||
'publisher'
|
||||
);
|
||||
return $existing_id;
|
||||
// v0.20.0: a re-run's regenerated content must land on the
|
||||
// existing UNPUBLISHED post (otherwise re-runs silently
|
||||
// discard their output). Published posts stay frozen
|
||||
// (CPO guardrail 5) — refresh skips them untouched.
|
||||
return $this->refresh_unpublished_post( $existing_id, $content, $idea, $review );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +157,60 @@ class PRAutoBlogger_Publisher {
|
||||
return $post_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh an existing post from re-run output — unpublished only.
|
||||
*
|
||||
* Idempotent-resume calls land here too (same content in = same
|
||||
* content out, so behavior is unchanged for plain resumes). The post
|
||||
* keeps its CURRENT status: a re-run never publishes an unpublished
|
||||
* post — publication stays an explicit Review Queue / WP editor
|
||||
* action. Published (and scheduled/private) posts are returned
|
||||
* untouched: the run is frozen (CPO guardrail 5).
|
||||
*
|
||||
* Side effects: wp_update_post + post meta updates (unpublished only).
|
||||
*
|
||||
* @param int $post_id Existing post ID.
|
||||
* @param string $content Regenerated HTML content.
|
||||
* @param PRAutoBlogger_Article_Idea $idea The idea.
|
||||
* @param PRAutoBlogger_Editorial_Review $review The fresh review.
|
||||
* @return int The post ID.
|
||||
*/
|
||||
private function refresh_unpublished_post( int $post_id, string $content, PRAutoBlogger_Article_Idea $idea, PRAutoBlogger_Editorial_Review $review ): int {
|
||||
$post = get_post( $post_id );
|
||||
if ( PRAutoBlogger_Rerun_Eligibility::post_frozen( $post ) ) {
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf( 'Post %d already exists and is published — frozen, skipping content refresh.', $post_id ),
|
||||
'publisher'
|
||||
);
|
||||
return $post_id;
|
||||
}
|
||||
|
||||
// Same sanitize chain as create_post().
|
||||
$clean_content = PRAutoBlogger_Post_Assembler::sanitize_llm_content( $content );
|
||||
$linked_content = PRAutoBlogger_Peptide_Linker::inject_links( $clean_content );
|
||||
|
||||
wp_update_post(
|
||||
array(
|
||||
'ID' => $post_id,
|
||||
'post_title' => sanitize_text_field( $idea->get_suggested_title() ),
|
||||
'post_content' => wp_kses_post( $linked_content ),
|
||||
)
|
||||
);
|
||||
|
||||
update_post_meta( $post_id, '_prautoblogger_editor_verdict', $review->get_verdict() );
|
||||
update_post_meta( $post_id, '_prautoblogger_editor_notes', $review->get_notes() );
|
||||
update_post_meta( $post_id, '_prautoblogger_quality_score', $review->get_quality_score() );
|
||||
update_post_meta( $post_id, '_prautoblogger_seo_score', $review->get_seo_score() );
|
||||
update_post_meta( $post_id, '_prautoblogger_generated_at', gmdate( 'c' ) );
|
||||
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf( 'Post %d refreshed from re-run output (status preserved: %s).', $post_id, (string) ( $post->post_status ?? '' ) ),
|
||||
'publisher'
|
||||
);
|
||||
|
||||
return $post_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build post_meta array for generation metadata.
|
||||
*
|
||||
|
||||
73
includes/core/class-request-recorder.php
Normal file
73
includes/core/class-request-recorder.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Per-PHP-process stash for the most recent outgoing LLM request body.
|
||||
*
|
||||
* What: A process-scoped static slot recording the JSON request body the
|
||||
* OpenRouter provider is about to dispatch, so Cost_Tracker::
|
||||
* log_api_call() can persist it to `generation_log.request_json`
|
||||
* (v0.20.0 / B1) without threading the body through every call
|
||||
* site. Mirrors the Run_Context precedent (v0.18.0).
|
||||
*
|
||||
* SECURITY GUARANTEE: only the request BODY is ever recorded.
|
||||
* Authorization headers are built separately
|
||||
* (Request_Builder::build_headers()) and can never reach this
|
||||
* class; build_body() copies only whitelisted option keys.
|
||||
*
|
||||
* Consume-once semantics: consume() clears the slot, and record()
|
||||
* overwrites it at every dispatch — so a stale body from an
|
||||
* unlogged failed call can never attach to a later, unrelated
|
||||
* log row.
|
||||
* Who triggers it: OpenRouter_Provider::send_chat_completion() (record,
|
||||
* pre-dispatch so error rows carry the request too);
|
||||
* Cost_Tracker::log_api_call() (consume).
|
||||
* Dependencies: none.
|
||||
*
|
||||
* @see providers/class-open-router-provider.php — Records before dispatch.
|
||||
* @see core/class-cost-tracker.php — Consumes into the log row.
|
||||
* @see core/class-run-context.php — The pattern this mirrors.
|
||||
* @see ARCHITECTURE.md #24 — Edit + re-run substrate (B1).
|
||||
*/
|
||||
class PRAutoBlogger_Request_Recorder {
|
||||
|
||||
/** @var string|null JSON-encoded request body of the in-flight LLM call. */
|
||||
private static ?string $request_json = null;
|
||||
|
||||
/**
|
||||
* Record the outgoing request body for the next log row.
|
||||
*
|
||||
* Overwrites any previous stash (one in-flight chat call per PHP
|
||||
* process). Bodies that fail JSON encoding are skipped silently —
|
||||
* the log row's request_json stays NULL, never blocking the call.
|
||||
*
|
||||
* @param array<string, mixed> $body Request body from Request_Builder::build_body().
|
||||
* @return void
|
||||
*/
|
||||
public static function record( array $body ): void {
|
||||
$encoded = wp_json_encode( $body );
|
||||
self::$request_json = is_string( $encoded ) ? $encoded : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the stashed request body and clear the slot (consume-once).
|
||||
*
|
||||
* @return string|null JSON request body, or null when nothing is stashed.
|
||||
*/
|
||||
public static function consume(): ?string {
|
||||
$json = self::$request_json;
|
||||
self::$request_json = null;
|
||||
return $json;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the stash (tests / defensive teardown).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function clear(): void {
|
||||
self::$request_json = null;
|
||||
}
|
||||
}
|
||||
214
includes/core/class-rerun-eligibility.php
Normal file
214
includes/core/class-rerun-eligibility.php
Normal file
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Policy gates for edit + single-step re-run (v0.20.0, M3).
|
||||
*
|
||||
* What: Single source of truth for WHO may re-run WHAT:
|
||||
* - Published posts are frozen (CPO guardrail 5): publish/future/
|
||||
* private statuses lock the run; edits go through the WP editor.
|
||||
* - Runs that are actively executing (status pending/running or
|
||||
* the global generation lock held) cannot be re-run — "in
|
||||
* progress" in the CPO sense means in the WORKFLOW (unpublished),
|
||||
* not mid-execution.
|
||||
* - Editable stages (input fork + replay) are the item-scoped
|
||||
* writer chat stages of the CURRENT pipeline vocabulary:
|
||||
* outline/draft/polish (single-pass uses draft only). review's
|
||||
* input is fully derived from content (re-run it from here
|
||||
* instead); analysis/research are run-level idea-selection
|
||||
* stages; image stages are not chat-replayable (Phase 2b).
|
||||
* - The canonical per-item chain orders downstream computation
|
||||
* for stale-marking and rebuild sets.
|
||||
* Who triggers it: Dossier_Actions (UI affordances + request validation),
|
||||
* Rerun_Executor (re-validation under the lock before mutating).
|
||||
* Dependencies: get_post(), Run_State, Generation_Lock, Stage_Input_Store.
|
||||
*
|
||||
* @see admin/class-dossier-actions.php — AJAX validation.
|
||||
* @see core/class-rerun-executor.php — Cron-side re-validation.
|
||||
* @see ARCHITECTURE.md #24 — Edit + re-run design.
|
||||
*/
|
||||
class PRAutoBlogger_Rerun_Eligibility {
|
||||
|
||||
/** Post statuses that freeze a run (CPO guardrail 5). */
|
||||
public const FROZEN_STATUSES = array( 'publish', 'future', 'private' );
|
||||
|
||||
/** Stages whose input may be edited and replayed (chat writer stages). */
|
||||
public const EDITABLE_STAGES = array( 'outline', 'draft', 'polish' );
|
||||
|
||||
/** Canonical per-item stage chain (current pipeline vocabulary). */
|
||||
public const ITEM_STAGE_CHAIN = array( 'outline', 'draft', 'polish', 'review', 'publish' );
|
||||
|
||||
/**
|
||||
* Whether a stage's input may be edited + replayed.
|
||||
*
|
||||
* @param string $stage Stage name.
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_editable_stage( string $stage ): bool {
|
||||
return in_array( $stage, self::EDITABLE_STAGES, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether re-run-from-here may target this stage (any chain stage).
|
||||
*
|
||||
* @param string $stage Stage name.
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_chain_stage( string $stage ): bool {
|
||||
return in_array( $stage, self::ITEM_STAGE_CHAIN, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Stages strictly AFTER a stage in the canonical chain — the set that
|
||||
* goes stale when the stage is re-run. Stages absent from an item
|
||||
* (e.g. outline/polish on single-pass runs) simply match no rows.
|
||||
*
|
||||
* @param string $stage Stage name.
|
||||
* @return array<string> Downstream stage names ([] for non-chain stages).
|
||||
*/
|
||||
public static function downstream_of( string $stage ): array {
|
||||
$idx = array_search( $stage, self::ITEM_STAGE_CHAIN, true );
|
||||
if ( false === $idx ) {
|
||||
return array();
|
||||
}
|
||||
return array_slice( self::ITEM_STAGE_CHAIN, $idx + 1 );
|
||||
}
|
||||
|
||||
/**
|
||||
* The demotion set for re-run-from-here: the stage itself plus all
|
||||
* downstream chain stages.
|
||||
*
|
||||
* @param string $stage Stage name.
|
||||
* @return array<string>
|
||||
*/
|
||||
public static function rebuild_set( string $stage ): array {
|
||||
$idx = array_search( $stage, self::ITEM_STAGE_CHAIN, true );
|
||||
if ( false === $idx ) {
|
||||
return array();
|
||||
}
|
||||
return array_slice( self::ITEM_STAGE_CHAIN, $idx );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a post is frozen for pipeline re-runs (guardrail 5).
|
||||
*
|
||||
* @param WP_Post|null $post The post, or null when none exists yet.
|
||||
* @return bool
|
||||
*/
|
||||
public static function post_frozen( ?WP_Post $post ): bool {
|
||||
return ( $post instanceof WP_Post )
|
||||
&& in_array( (string) $post->post_status, self::FROZEN_STATUSES, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Full re-run eligibility check for a (run, post) pair.
|
||||
*
|
||||
* Returns ok=false with an operator-readable reason for: missing/
|
||||
* frozen post, missing run row, run actively executing, or the
|
||||
* global generation lock being held. Called from the AJAX layer for
|
||||
* UI affordances AND re-validated by the cron handler under the lock
|
||||
* (state can change between click and pickup).
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param int $post_id Post ID (0 = run has no post yet).
|
||||
* @param bool $require_idle_lock Whether a held generation lock blocks (true for actions).
|
||||
* @return array{ok: bool, reason: string} Verdict + reason ('' when ok).
|
||||
*/
|
||||
public static function check( string $run_id, int $post_id, bool $require_idle_lock = true ): array {
|
||||
if ( '' === $run_id ) {
|
||||
return self::no( __( 'This article has no recorded generation run.', 'prautoblogger' ) );
|
||||
}
|
||||
|
||||
$post = $post_id > 0 ? get_post( $post_id ) : null;
|
||||
if ( self::post_frozen( $post ) ) {
|
||||
return self::no( __( 'This article is published — its run is frozen. Edit the article in the WordPress editor instead.', 'prautoblogger' ) );
|
||||
}
|
||||
|
||||
$run = PRAutoBlogger_Run_State::get_run( $run_id );
|
||||
if ( null === $run ) {
|
||||
return self::no( __( 'No run ledger row exists for this article (pre-substrate run).', 'prautoblogger' ) );
|
||||
}
|
||||
if ( in_array( (string) $run['status'], array( 'pending', 'running' ), true ) ) {
|
||||
return self::no( __( 'This run is currently executing. Wait for it to finish (or for the reaper to mark it failed).', 'prautoblogger' ) );
|
||||
}
|
||||
|
||||
if ( $require_idle_lock && null !== PRAutoBlogger_Generation_Lock::get_acquired_at() ) {
|
||||
return self::no( __( 'Another generation is in progress (lock held). Try again when it completes.', 'prautoblogger' ) );
|
||||
}
|
||||
|
||||
return array(
|
||||
'ok' => true,
|
||||
'reason' => '',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay-specific check: base eligibility + editable stage + a saved
|
||||
* input fork whose body has not been retention-pruned.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param int $post_id Post ID.
|
||||
* @param string $stage Stage name.
|
||||
* @param string $agent_role Stage row agent role.
|
||||
* @param string $item_key Stage row item key.
|
||||
* @param bool $require_idle_lock False when the caller already owns the lock (cron handler).
|
||||
* @return array{ok: bool, reason: string}
|
||||
*/
|
||||
public static function check_replay( string $run_id, int $post_id, string $stage, string $agent_role, string $item_key, bool $require_idle_lock = true ): array {
|
||||
if ( ! self::is_editable_stage( $stage ) ) {
|
||||
return self::no( __( 'This stage cannot be replayed with an edited input.', 'prautoblogger' ) );
|
||||
}
|
||||
$base = self::check( $run_id, $post_id, $require_idle_lock );
|
||||
if ( ! $base['ok'] ) {
|
||||
return $base;
|
||||
}
|
||||
$fork = PRAutoBlogger_Stage_Input_Store::latest_fork( $run_id, $stage, $agent_role, $item_key );
|
||||
if ( null === $fork ) {
|
||||
return self::no( __( 'Save an edited input for this stage first — re-run executes your latest saved version.', 'prautoblogger' ) );
|
||||
}
|
||||
if ( empty( $fork['request_json'] ) ) {
|
||||
return self::no( __( 'The saved input for this stage was pruned by the retention cron and can no longer be replayed.', 'prautoblogger' ) );
|
||||
}
|
||||
return $base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild-specific check: base eligibility + chain stage + idea seed
|
||||
* available (runs started before v0.20.0 have no seed).
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param int $post_id Post ID.
|
||||
* @param string $stage Target stage.
|
||||
* @param string $item_key Item key.
|
||||
* @param bool $require_idle_lock False when the caller already owns the lock (cron handler).
|
||||
* @return array{ok: bool, reason: string}
|
||||
*/
|
||||
public static function check_rebuild( string $run_id, int $post_id, string $stage, string $item_key, bool $require_idle_lock = true ): array {
|
||||
if ( ! self::is_chain_stage( $stage ) ) {
|
||||
return self::no( __( 'Re-run from here is only available on article pipeline stages.', 'prautoblogger' ) );
|
||||
}
|
||||
$base = self::check( $run_id, $post_id, $require_idle_lock );
|
||||
if ( ! $base['ok'] ) {
|
||||
return $base;
|
||||
}
|
||||
if ( null === PRAutoBlogger_Stage_Input_Store::get_seed( $run_id, $item_key ) ) {
|
||||
return self::no( __( 'No idea seed is stored for this run (recorded from v0.20.0 onward) — the pipeline cannot rebuild it safely.', 'prautoblogger' ) );
|
||||
}
|
||||
return $base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorthand for a negative verdict.
|
||||
*
|
||||
* @param string $reason Operator-readable reason.
|
||||
* @return array{ok: bool, reason: string}
|
||||
*/
|
||||
private static function no( string $reason ): array {
|
||||
return array(
|
||||
'ok' => false,
|
||||
'reason' => $reason,
|
||||
);
|
||||
}
|
||||
}
|
||||
221
includes/core/class-rerun-executor.php
Normal file
221
includes/core/class-rerun-executor.php
Normal file
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Queues and executes operator re-runs as chained cron jobs (v0.20.0, M3).
|
||||
*
|
||||
* What: The chained-cron seam for the dossier's two re-run actions —
|
||||
* NEVER synchronous (CPO hard constraint): the AJAX layer only
|
||||
* schedules; ALL state mutations happen in the cron handler after
|
||||
* re-validating eligibility under the global generation lock.
|
||||
* - Replay job: single-stage re-run of the latest edited input
|
||||
* fork (Stage_Replay). Demotes the one stage row to running
|
||||
* (human_modified=1), stale-marks downstream, executes, restores
|
||||
* a terminal run status.
|
||||
* - Rebuild job ("re-run from here"): demotes the target + all
|
||||
* downstream chain stages to pending, then re-enters
|
||||
* Article_Worker with the idea reconstructed from the stored
|
||||
* seed — upstream done stages are reused (never re-charged),
|
||||
* demoted stages re-run with prompts rebuilt from CURRENT
|
||||
* upstream snapshots, and the publisher refreshes the
|
||||
* unpublished post.
|
||||
* Both jobs: monthly budget check, Run_State::reopen() (ceiling
|
||||
* re-snapshotted from the current setting — a deliberate new-spend
|
||||
* decision), M1 status transient for board/dossier pickup
|
||||
* visibility, lock released in finally. Failures restore/record a
|
||||
* terminal run status so the run-reaper's stuck-running sweep
|
||||
* stays meaningful.
|
||||
* Who triggers it: Dossier_Actions (queue_*), WP-Cron (on_* handlers via
|
||||
* class-prautoblogger.php registration).
|
||||
* Dependencies: Rerun_Job_Support (lock/budget/transient plumbing),
|
||||
* Run_State, Run_Stage_Rerun_State, Stage_Input_Store,
|
||||
* Stage_Replay, Article_Worker, Rerun_Eligibility.
|
||||
*
|
||||
* @see admin/class-dossier-actions.php — The AJAX layer (queues via Job_Support).
|
||||
* @see core/class-rerun-job-support.php — Shared queue/lock/status plumbing.
|
||||
* @see core/class-stage-replay.php — The governed replay call.
|
||||
* @see ARCHITECTURE.md #24 — Edit + re-run design.
|
||||
*/
|
||||
class PRAutoBlogger_Rerun_Executor {
|
||||
|
||||
/** Cron action for single-stage replay jobs. */
|
||||
public const REPLAY_ACTION = 'prautoblogger_rerun_stage_replay';
|
||||
|
||||
/** Cron action for re-run-from-here rebuild jobs. */
|
||||
public const REBUILD_ACTION = 'prautoblogger_rerun_from_stage';
|
||||
|
||||
/**
|
||||
* Cron handler: replay one stage from its latest edited input fork.
|
||||
*
|
||||
* Side effects: run_stages/runs mutations, one governed LLM call,
|
||||
* generation_log row, status transient, lock acquire/release.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param int $post_id Post the dossier acted on.
|
||||
* @param string $stage Stage name.
|
||||
* @param string $agent_role Stage row agent role.
|
||||
* @param string $item_key Stage row item key.
|
||||
* @return void
|
||||
*/
|
||||
public function on_replay_job( string $run_id, int $post_id, string $stage, string $agent_role, string $item_key ): void {
|
||||
PRAutoBlogger_Rerun_Job_Support::harden_process();
|
||||
if ( ! PRAutoBlogger_Rerun_Job_Support::acquire_lock_or_report() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$previous_status = null;
|
||||
try {
|
||||
// Re-validate under the lock — state may have changed between
|
||||
// click and pickup (e.g. the post was published).
|
||||
// require_idle_lock=false: THIS handler owns the lock.
|
||||
$check = PRAutoBlogger_Rerun_Eligibility::check_replay( $run_id, $post_id, $stage, $agent_role, $item_key, false );
|
||||
if ( ! $check['ok'] ) {
|
||||
PRAutoBlogger_Rerun_Job_Support::finish_error( $check['reason'] );
|
||||
return;
|
||||
}
|
||||
if ( PRAutoBlogger_Rerun_Job_Support::monthly_budget_exceeded() ) {
|
||||
PRAutoBlogger_Rerun_Job_Support::finish_error( __( 'Monthly API budget exceeded. Re-run not started.', 'prautoblogger' ) );
|
||||
return;
|
||||
}
|
||||
|
||||
$fork = PRAutoBlogger_Stage_Input_Store::latest_fork( $run_id, $stage, $agent_role, $item_key );
|
||||
$body = null !== $fork ? PRAutoBlogger_Stage_Replay::decode_body( (string) $fork['request_json'] ) : null;
|
||||
if ( null === $body ) {
|
||||
PRAutoBlogger_Rerun_Job_Support::finish_error( __( 'The saved input could not be decoded as a replayable request.', 'prautoblogger' ) );
|
||||
return;
|
||||
}
|
||||
|
||||
$previous_status = PRAutoBlogger_Run_State::get_status( $run_id );
|
||||
if ( ! PRAutoBlogger_Run_State::reopen( $run_id ) ) {
|
||||
PRAutoBlogger_Rerun_Job_Support::finish_error( __( 'The run could not be reopened for new spend.', 'prautoblogger' ) );
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! PRAutoBlogger_Run_Stage_Rerun_State::restart( $run_id, $stage, $agent_role, $item_key, true ) ) {
|
||||
PRAutoBlogger_Rerun_Job_Support::restore_terminal( $run_id, $previous_status );
|
||||
PRAutoBlogger_Rerun_Job_Support::finish_error( __( 'The stage row could not be demoted for replay (it may be executing).', 'prautoblogger' ) );
|
||||
return;
|
||||
}
|
||||
// Guardrail 3: explicit, visible downstream invalidation.
|
||||
PRAutoBlogger_Run_Stage_Rerun_State::mark_stale( $run_id, $item_key, PRAutoBlogger_Rerun_Eligibility::downstream_of( $stage ) );
|
||||
|
||||
PRAutoBlogger_Rerun_Job_Support::broadcast(
|
||||
sprintf(
|
||||
/* translators: %s: stage display label. */
|
||||
__( 'Re-running %s with the edited input…', 'prautoblogger' ),
|
||||
PRAutoBlogger_Stage_Display_Map::label( $stage )
|
||||
)
|
||||
);
|
||||
|
||||
$result = PRAutoBlogger_Stage_Replay::run( $run_id, $stage, $agent_role, $item_key, $body );
|
||||
|
||||
PRAutoBlogger_Run_State::mark_status( $run_id, 'done' );
|
||||
PRAutoBlogger_Rerun_Job_Support::finish_complete( $result['cost'] );
|
||||
} catch ( PRAutoBlogger_Cost_Ceiling_Exception $e ) {
|
||||
// Governor already halted the run + recorded the overage.
|
||||
PRAutoBlogger_Run_Stage_State::fail( $run_id, $stage, $agent_role, $item_key );
|
||||
PRAutoBlogger_Rerun_Job_Support::finish_error( $e->getMessage() );
|
||||
} catch ( \Throwable $e ) {
|
||||
PRAutoBlogger_Run_Stage_State::fail( $run_id, $stage, $agent_role, $item_key );
|
||||
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
sprintf( 'Stage replay %s for run %s/%s: %s', get_class( $e ), $run_id, $stage, $e->getMessage() ),
|
||||
'rerun'
|
||||
);
|
||||
PRAutoBlogger_Rerun_Job_Support::finish_error( $e->getMessage() );
|
||||
} finally {
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron handler: re-run from a stage — demote it + downstream, then
|
||||
* re-enter the worker so demoted stages rebuild from current
|
||||
* upstream snapshots.
|
||||
*
|
||||
* Side effects: run_stages/runs mutations, worker LLM calls
|
||||
* (governed), post refresh, status transient, lock acquire/release.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param int $post_id Post the dossier acted on.
|
||||
* @param string $stage Target stage (start of the rebuild set).
|
||||
* @param string $item_key Item key.
|
||||
* @return void
|
||||
*/
|
||||
public function on_rebuild_job( string $run_id, int $post_id, string $stage, string $item_key ): void {
|
||||
PRAutoBlogger_Rerun_Job_Support::harden_process();
|
||||
if ( ! PRAutoBlogger_Rerun_Job_Support::acquire_lock_or_report() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// require_idle_lock=false: THIS handler owns the lock.
|
||||
$check = PRAutoBlogger_Rerun_Eligibility::check_rebuild( $run_id, $post_id, $stage, $item_key, false );
|
||||
if ( ! $check['ok'] ) {
|
||||
PRAutoBlogger_Rerun_Job_Support::finish_error( $check['reason'] );
|
||||
return;
|
||||
}
|
||||
if ( PRAutoBlogger_Rerun_Job_Support::monthly_budget_exceeded() ) {
|
||||
PRAutoBlogger_Rerun_Job_Support::finish_error( __( 'Monthly API budget exceeded. Re-run not started.', 'prautoblogger' ) );
|
||||
return;
|
||||
}
|
||||
|
||||
$idea_json = PRAutoBlogger_Stage_Input_Store::get_seed( $run_id, $item_key );
|
||||
$idea_data = null !== $idea_json ? json_decode( $idea_json, true ) : null;
|
||||
if ( ! is_array( $idea_data ) ) {
|
||||
PRAutoBlogger_Rerun_Job_Support::finish_error( __( 'The stored idea seed could not be decoded.', 'prautoblogger' ) );
|
||||
return;
|
||||
}
|
||||
$idea = new PRAutoBlogger_Article_Idea( $idea_data );
|
||||
|
||||
$job_start = current_time( 'mysql' );
|
||||
$demoted = PRAutoBlogger_Rerun_Eligibility::rebuild_set( $stage );
|
||||
|
||||
if ( ! PRAutoBlogger_Run_State::reopen( $run_id ) ) {
|
||||
PRAutoBlogger_Rerun_Job_Support::finish_error( __( 'The run could not be reopened for new spend.', 'prautoblogger' ) );
|
||||
return;
|
||||
}
|
||||
PRAutoBlogger_Run_Stage_Rerun_State::demote_to_pending( $run_id, $item_key, $demoted );
|
||||
|
||||
PRAutoBlogger_Rerun_Job_Support::broadcast(
|
||||
sprintf(
|
||||
/* translators: %s: stage display label. */
|
||||
__( 'Re-running the pipeline from %s…', 'prautoblogger' ),
|
||||
PRAutoBlogger_Stage_Display_Map::label( $stage )
|
||||
)
|
||||
);
|
||||
|
||||
$tracker = new PRAutoBlogger_Cost_Tracker();
|
||||
$tracker->set_run_id( $run_id );
|
||||
$result = ( new PRAutoBlogger_Article_Worker( $tracker ) )->generate( $idea );
|
||||
|
||||
// The worker traps its own failures (fail_open_for_item);
|
||||
// publish-done is the success signal.
|
||||
$succeeded = PRAutoBlogger_Run_Stage_State::is_done( $run_id, 'publish', '', $item_key );
|
||||
|
||||
// Decisions recorded while rebuilding a human-modified item
|
||||
// derive from edited content — flag them (guardrail 2).
|
||||
if ( PRAutoBlogger_Run_Stage_Rerun_State::item_has_human_modified( $run_id, $item_key ) ) {
|
||||
PRAutoBlogger_Audit_Writer::flag_decisions_human_modified( $run_id, $demoted, $job_start );
|
||||
}
|
||||
|
||||
PRAutoBlogger_Run_State::mark_status( $run_id, $succeeded ? 'done' : 'failed' );
|
||||
if ( $succeeded ) {
|
||||
PRAutoBlogger_Rerun_Job_Support::finish_complete( (float) $result['cost'] );
|
||||
} else {
|
||||
PRAutoBlogger_Rerun_Job_Support::finish_error( __( 'The re-run did not complete — check the Activity Log and the dossier stage states.', 'prautoblogger' ) );
|
||||
}
|
||||
} catch ( \Throwable $e ) {
|
||||
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
sprintf( 'Rebuild from %s %s for run %s: %s', $stage, get_class( $e ), $run_id, $e->getMessage() ),
|
||||
'rerun'
|
||||
);
|
||||
PRAutoBlogger_Rerun_Job_Support::finish_error( $e->getMessage() );
|
||||
} finally {
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
}
|
||||
}
|
||||
}
|
||||
158
includes/core/class-rerun-job-support.php
Normal file
158
includes/core/class-rerun-job-support.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Shared plumbing for re-run cron jobs (v0.20.0, M3).
|
||||
*
|
||||
* What: The queue/lock/budget/status-transient helpers both
|
||||
* Rerun_Executor handlers share — extracted so the executor stays
|
||||
* under the 300-line cap. The status transient writes use the M1
|
||||
* Generation_Status_Poller shape so the kanban board (and the
|
||||
* dossier's poll) show queued → pickup → result without any new
|
||||
* polling infrastructure; the poller's existing R2/R3 orphan
|
||||
* recovery covers a dead re-run process unchanged.
|
||||
* Who triggers it: PRAutoBlogger_Rerun_Executor (handlers) and
|
||||
* PRAutoBlogger_Dossier_Actions (queue()).
|
||||
* Dependencies: Generation_Lock, Generation_Status_Poller (constants),
|
||||
* Cost_Tracker, Run_State, Logger, WP-Cron.
|
||||
*
|
||||
* @see core/class-rerun-executor.php — The job handlers.
|
||||
* @see ARCHITECTURE.md #24 — Edit + re-run design.
|
||||
*/
|
||||
class PRAutoBlogger_Rerun_Job_Support {
|
||||
|
||||
/**
|
||||
* Schedule a re-run job and fire the cron spawner (non-blocking).
|
||||
*
|
||||
* The uniq argument keeps wp_schedule_single_event()'s identical-
|
||||
* args dedup from swallowing a legitimate second click after a
|
||||
* failed attempt within the 10-minute window.
|
||||
*
|
||||
* Side effects: one single cron event, status transient write,
|
||||
* non-blocking loopback request.
|
||||
*
|
||||
* @param string $action Cron action name.
|
||||
* @param array $args Handler args.
|
||||
* @param string $queued_message Operator-facing "queued" copy.
|
||||
* @return void
|
||||
*/
|
||||
public static function queue( string $action, array $args, string $queued_message ): void {
|
||||
$args[] = (string) microtime( true ); // Uniqueness token.
|
||||
wp_schedule_single_event( time(), $action, $args );
|
||||
|
||||
self::broadcast( $queued_message );
|
||||
|
||||
spawn_cron();
|
||||
wp_remote_post(
|
||||
site_url( 'wp-cron.php?doing_wp_cron=' . sprintf( '%.22F', microtime( true ) ) ),
|
||||
array(
|
||||
'timeout' => 0.01,
|
||||
'blocking' => false,
|
||||
'sslverify' => false,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Raise process limits for a background LLM job (cron context). */
|
||||
public static function harden_process(): void {
|
||||
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
@ignore_user_abort( true );
|
||||
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
@set_time_limit( 300 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire the global generation mutex or report the conflict.
|
||||
*
|
||||
* @return bool Whether the lock was acquired.
|
||||
*/
|
||||
public static function acquire_lock_or_report(): bool {
|
||||
if ( PRAutoBlogger_Generation_Lock::acquire() ) {
|
||||
return true;
|
||||
}
|
||||
self::finish_error( __( 'Could not acquire the generation lock — another run is in progress. Re-run again when it completes.', 'prautoblogger' ) );
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the monthly budget hard-stop blocks new spend.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function monthly_budget_exceeded(): bool {
|
||||
return ( new PRAutoBlogger_Cost_Tracker() )->is_budget_exceeded();
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a terminal run status after an aborted-but-reopened job.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string|null $status The pre-reopen status (done/failed/halted).
|
||||
* @return void
|
||||
*/
|
||||
public static function restore_terminal( string $run_id, ?string $status ): void {
|
||||
if ( null !== $status && in_array( $status, array( 'done', 'failed', 'halted' ), true ) ) {
|
||||
PRAutoBlogger_Run_State::mark_status( $run_id, $status );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the live status transient (board + dossier polling).
|
||||
*
|
||||
* @param string $message Stage message.
|
||||
* @return void
|
||||
*/
|
||||
public static function broadcast( string $message ): void {
|
||||
set_transient(
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
|
||||
array(
|
||||
'status' => 'running',
|
||||
'stage' => $message,
|
||||
'started' => time(),
|
||||
'last_updated' => time(),
|
||||
),
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Final transient: success (board-compatible shape).
|
||||
*
|
||||
* @param float $cost Job spend in USD.
|
||||
* @return void
|
||||
*/
|
||||
public static function finish_complete( float $cost ): void {
|
||||
set_transient(
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
|
||||
array(
|
||||
'status' => 'complete',
|
||||
'generated' => 0,
|
||||
'published' => 0,
|
||||
'rejected' => 0,
|
||||
'cost' => $cost,
|
||||
'message' => __( 'Re-run complete.', 'prautoblogger' ),
|
||||
),
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Final transient: error (board-compatible shape) + log.
|
||||
*
|
||||
* @param string $message Operator-facing error.
|
||||
* @return void
|
||||
*/
|
||||
public static function finish_error( string $message ): void {
|
||||
PRAutoBlogger_Logger::instance()->warning( 'Re-run job: ' . $message, 'rerun' );
|
||||
set_transient(
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
|
||||
array(
|
||||
'status' => 'error',
|
||||
'message' => $message,
|
||||
),
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,9 @@ declare(strict_types=1);
|
||||
* ledger/state activity for 2× the expected run wall-clock are
|
||||
* marked `failed` and their non-done stages reaped to `failed`;
|
||||
* such runs render as "incomplete" via incomplete_runs().
|
||||
* 3. Retention — `request_json` on generation_log and stage output
|
||||
* 3. Retention — `request_json` on generation_log, stage output
|
||||
* payloads (`meta_json` on run_stages) are NULLed after R days;
|
||||
* payloads, and stage_inputs human-fork bodies (v0.20.0);
|
||||
* R comes from the `prautoblogger_request_json_retention_days`
|
||||
* SETTING (default constant 14; 0 = keep forever) — never a
|
||||
* literal at the point of use.
|
||||
@@ -265,6 +266,11 @@ class PRAutoBlogger_Run_Reaper {
|
||||
$pruned += is_numeric( $result ) ? (int) $result : 0;
|
||||
}
|
||||
|
||||
// v0.20.0: human edit-fork bodies ride the same retention (seed
|
||||
// rows are kept — structural, ~1KB). A pruned fork can no longer
|
||||
// be replayed; the dossier explains that state honestly.
|
||||
$pruned += PRAutoBlogger_Stage_Input_Store::prune_human_bodies( $cutoff );
|
||||
|
||||
return $pruned;
|
||||
}
|
||||
}
|
||||
|
||||
172
includes/core/class-run-stage-rerun-state.php
Normal file
172
includes/core/class-run-stage-rerun-state.php
Normal file
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Operator-action state mutations for edit + re-run (v0.20.0, M3).
|
||||
*
|
||||
* What: The ONLY paths that may demote a 'done' run_stages row — and all
|
||||
* of them are deliberate operator actions dispatched from the
|
||||
* dossier (never reachable from pipeline resume code):
|
||||
* - restart(): done/failed/halted/pending → running for a single-
|
||||
* stage replay; bumps attempt, clears stale, sets human_modified
|
||||
* when the replay uses an edited input fork (CPO guardrail 2).
|
||||
* - mark_stale(): flags downstream 'done' stages as stale after an
|
||||
* upstream edit enters execution. Stale is a COLUMN, not a
|
||||
* status: the stage stays 'done', so resume-without-recharge
|
||||
* logic can never silently re-run it (CPO guardrail 3 holds by
|
||||
* construction). Only Run_Stage_Writes::done() clears it.
|
||||
* - demote_to_pending(): re-run-from-here demotion; the worker
|
||||
* re-entry then rebuilds those stages from current upstream
|
||||
* snapshots. Audit columns (attempt, human_modified) survive;
|
||||
* old meta_json survives until overwritten by the fresh done().
|
||||
* Every method no-ops when the run_stages table is missing.
|
||||
* Who triggers it: PRAutoBlogger_Rerun_Executor (cron handlers, after
|
||||
* re-validating eligibility under the generation lock).
|
||||
* Dependencies: Run_Stage_State (probe/table), $wpdb.
|
||||
*
|
||||
* @see core/class-rerun-executor.php — Sole caller.
|
||||
* @see core/class-run-stage-writes.php — Pipeline-path writes (done clears stale).
|
||||
* @see core/class-rerun-eligibility.php — Policy gates before any mutation.
|
||||
* @see ARCHITECTURE.md #24 — Edit + re-run design.
|
||||
*/
|
||||
class PRAutoBlogger_Run_Stage_Rerun_State {
|
||||
|
||||
/**
|
||||
* Demote one stage row to 'running' for an operator replay.
|
||||
*
|
||||
* Allowed from done/failed/halted/pending — never from 'running'
|
||||
* (a concurrently executing stage cannot be restarted). Bumps the
|
||||
* attempt counter, clears `stale` (the row is being refreshed) and
|
||||
* sets `human_modified` when the replay executes an edited fork.
|
||||
* `human_modified` is never cleared by any code path — it is sticky
|
||||
* audit state for the life of the run.
|
||||
*
|
||||
* Side effects: one UPDATE on run_stages.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $stage Stage name.
|
||||
* @param string $agent_role Agent role (resolved by the caller from the actual row).
|
||||
* @param string $item_key Item key.
|
||||
* @param bool $human_modified Whether an edited input fork drives this replay.
|
||||
* @return bool Whether a row was demoted.
|
||||
*/
|
||||
public static function restart( string $run_id, string $stage, string $agent_role, string $item_key, bool $human_modified ): bool {
|
||||
if ( '' === $run_id || ! PRAutoBlogger_Run_Stage_State::is_available() ) {
|
||||
return false;
|
||||
}
|
||||
global $wpdb;
|
||||
$table = PRAutoBlogger_Run_Stage_State::table_name();
|
||||
$now = current_time( 'mysql' );
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$updated = $wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"UPDATE {$table}
|
||||
SET status = 'running', attempt = attempt + 1, stale = 0,
|
||||
human_modified = IF(%d = 1, 1, human_modified),
|
||||
updated_at = %s, finished_at = NULL
|
||||
WHERE run_id = %s AND stage = %s AND agent_role = %s AND item_key = %s
|
||||
AND status IN ('done','failed','halted','pending')",
|
||||
$human_modified ? 1 : 0,
|
||||
$now,
|
||||
$run_id,
|
||||
$stage,
|
||||
$agent_role,
|
||||
$item_key
|
||||
)
|
||||
);
|
||||
return 1 === (int) $updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flag a set of 'done' stages as stale (explicit downstream
|
||||
* invalidation after an upstream edit/re-run — CPO guardrail 3).
|
||||
*
|
||||
* Nothing is demoted and nothing auto-re-runs: the rows stay 'done'
|
||||
* and visible; the operator triggers each subsequent stage (or
|
||||
* re-run-from-here) deliberately.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $item_key Item key.
|
||||
* @param array<string> $stages Stage names to flag.
|
||||
* @return int Rows flagged.
|
||||
*/
|
||||
public static function mark_stale( string $run_id, string $item_key, array $stages ): int {
|
||||
if ( '' === $run_id || empty( $stages ) || ! PRAutoBlogger_Run_Stage_State::is_available() ) {
|
||||
return 0;
|
||||
}
|
||||
global $wpdb;
|
||||
$table = PRAutoBlogger_Run_Stage_State::table_name();
|
||||
$placeholders = implode( ',', array_fill( 0, count( $stages ), '%s' ) );
|
||||
$params = array_merge( array( current_time( 'mysql' ), $run_id, $item_key ), array_values( $stages ) );
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared -- $placeholders is a fixed %s list.
|
||||
$updated = $wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"UPDATE {$table} SET stale = 1, updated_at = %s
|
||||
WHERE run_id = %s AND item_key = %s AND status = 'done' AND stage IN ({$placeholders})",
|
||||
$params
|
||||
)
|
||||
);
|
||||
return is_numeric( $updated ) ? (int) $updated : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Demote a set of stages to 'pending' for re-run-from-here.
|
||||
*
|
||||
* The pipeline's resume machinery treats pending stages as not-done
|
||||
* and re-executes them with prompts REBUILT from current upstream
|
||||
* snapshots — that is how an upstream edit propagates downstream.
|
||||
* Rows keep attempt/human_modified (audit) and their old meta_json
|
||||
* until the fresh completion overwrites it; `stale` stays 1 until
|
||||
* Run_Stage_Writes::done() clears it on fresh output. Never touches
|
||||
* a 'running' row.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $item_key Item key.
|
||||
* @param array<string> $stages Stage names to demote.
|
||||
* @return int Rows demoted.
|
||||
*/
|
||||
public static function demote_to_pending( string $run_id, string $item_key, array $stages ): int {
|
||||
if ( '' === $run_id || empty( $stages ) || ! PRAutoBlogger_Run_Stage_State::is_available() ) {
|
||||
return 0;
|
||||
}
|
||||
global $wpdb;
|
||||
$table = PRAutoBlogger_Run_Stage_State::table_name();
|
||||
$placeholders = implode( ',', array_fill( 0, count( $stages ), '%s' ) );
|
||||
$params = array_merge( array( current_time( 'mysql' ), $run_id, $item_key ), array_values( $stages ) );
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared -- $placeholders is a fixed %s list.
|
||||
$updated = $wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"UPDATE {$table} SET status = 'pending', stale = 1, updated_at = %s, finished_at = NULL
|
||||
WHERE run_id = %s AND item_key = %s AND status != 'running' AND stage IN ({$placeholders})",
|
||||
$params
|
||||
)
|
||||
);
|
||||
return is_numeric( $updated ) ? (int) $updated : 0;
|
||||
}
|
||||
/**
|
||||
* Whether any stage row of this item carries the human_modified flag
|
||||
* (drives derived-decision flagging during rebuilds — guardrail 2).
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $item_key Item key.
|
||||
* @return bool
|
||||
*/
|
||||
public static function item_has_human_modified( string $run_id, string $item_key ): bool {
|
||||
if ( '' === $run_id || ! PRAutoBlogger_Run_Stage_State::is_available() ) {
|
||||
return false;
|
||||
}
|
||||
global $wpdb;
|
||||
$table = PRAutoBlogger_Run_Stage_State::table_name();
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$found = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT 1 FROM {$table} WHERE run_id = %s AND item_key = %s AND human_modified = 1 LIMIT 1",
|
||||
$run_id,
|
||||
$item_key
|
||||
)
|
||||
);
|
||||
return null !== $found;
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,17 @@ declare(strict_types=1);
|
||||
* roles (Phase-2 fan-out) are honoured as-is. get() falls back to role=''
|
||||
* on miss for mid-run-upgrade compat with v0.18.1 rows.
|
||||
*
|
||||
* v0.20.0: write bodies extracted to PRAutoBlogger_Run_Stage_Writes (the
|
||||
* M2 split pattern — this class was at the 300-line cap); the public API
|
||||
* here is unchanged and delegates. Operator-action mutations (replay
|
||||
* restart, stale marking, re-run demotion) live in
|
||||
* PRAutoBlogger_Run_Stage_Rerun_State only.
|
||||
*
|
||||
* @see core/class-run-stage-writes.php — Extracted write bodies.
|
||||
* @see core/class-run-stage-rerun-state.php — M3 operator-action writes.
|
||||
* @see core/class-run-state.php — Run-level lifecycle + ledger row.
|
||||
* @see core/class-run-reaper.php — Sweeps stages stuck in 'running'.
|
||||
* @see ARCHITECTURE.md #22 — Pipeline v2 Phase 1 substrate.
|
||||
* @see ARCHITECTURE.md #22 / #24 — Substrate + edit/re-run design.
|
||||
*/
|
||||
class PRAutoBlogger_Run_Stage_State {
|
||||
|
||||
@@ -73,6 +81,7 @@ class PRAutoBlogger_Run_Stage_State {
|
||||
|
||||
/**
|
||||
* Mark a stage as running. Sticky: done stages are never demoted.
|
||||
* Delegates to Run_Stage_Writes (v0.20.0 split — behavior unchanged).
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $stage Stage name (see Stage_Display_Map).
|
||||
@@ -80,35 +89,13 @@ class PRAutoBlogger_Run_Stage_State {
|
||||
* @param string $item_key Article scope ('' for run-level stages).
|
||||
*/
|
||||
public static function start( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): void {
|
||||
if ( '' === $run_id || ! self::is_available() ) {
|
||||
return;
|
||||
}
|
||||
$agent_role = self::resolve_role( $stage, $agent_role );
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
$now = current_time( 'mysql' );
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"INSERT INTO {$table}
|
||||
(run_id, stage, agent_role, item_key, status, attempt, started_at, updated_at)
|
||||
VALUES (%s, %s, %s, %s, 'running', 1, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
attempt = IF(status = 'done', attempt, attempt + 1),
|
||||
status = IF(status = 'done', 'done', 'running'),
|
||||
updated_at = VALUES(updated_at)",
|
||||
$run_id,
|
||||
$stage,
|
||||
$agent_role,
|
||||
$item_key,
|
||||
$now,
|
||||
$now
|
||||
)
|
||||
);
|
||||
PRAutoBlogger_Run_Stage_Writes::start( $run_id, $stage, $agent_role, $item_key );
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a stage done and persist its output for resume-without-recharge.
|
||||
* Delegates to Run_Stage_Writes (v0.20.0 split; fresh completion also
|
||||
* clears the `stale` flag there).
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $stage Stage name.
|
||||
@@ -118,41 +105,12 @@ class PRAutoBlogger_Run_Stage_State {
|
||||
* @param float $cost_usd Cost attributed to this stage.
|
||||
*/
|
||||
public static function done( string $run_id, string $stage, string $agent_role = '', string $item_key = '', ?string $output = null, float $cost_usd = 0.0 ): void {
|
||||
if ( '' === $run_id || ! self::is_available() ) {
|
||||
return;
|
||||
}
|
||||
$agent_role = self::resolve_role( $stage, $agent_role );
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
$now = current_time( 'mysql' );
|
||||
$meta = null !== $output ? wp_json_encode( array( 'output' => $output ) ) : null;
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"INSERT INTO {$table}
|
||||
(run_id, stage, agent_role, item_key, status, attempt, cost_usd, meta_json, started_at, updated_at, finished_at)
|
||||
VALUES (%s, %s, %s, %s, 'done', 1, %f, %s, %s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
status = 'done',
|
||||
cost_usd = VALUES(cost_usd),
|
||||
meta_json = VALUES(meta_json),
|
||||
updated_at = VALUES(updated_at),
|
||||
finished_at = VALUES(finished_at)",
|
||||
$run_id,
|
||||
$stage,
|
||||
$agent_role,
|
||||
$item_key,
|
||||
$cost_usd,
|
||||
$meta,
|
||||
$now,
|
||||
$now,
|
||||
$now
|
||||
)
|
||||
);
|
||||
PRAutoBlogger_Run_Stage_Writes::done( $run_id, $stage, $agent_role, $item_key, $output, $cost_usd );
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a stage failed (does not demote a done stage).
|
||||
* Mark a stage failed (does not demote a done stage). Delegates to
|
||||
* Run_Stage_Writes (v0.20.0 split — behavior unchanged).
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $stage Stage name.
|
||||
@@ -160,26 +118,7 @@ class PRAutoBlogger_Run_Stage_State {
|
||||
* @param string $item_key Article scope.
|
||||
*/
|
||||
public static function fail( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): void {
|
||||
if ( '' === $run_id || ! self::is_available() ) {
|
||||
return;
|
||||
}
|
||||
$agent_role = self::resolve_role( $stage, $agent_role );
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
$now = current_time( 'mysql' );
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"UPDATE {$table} SET status = 'failed', updated_at = %s, finished_at = %s
|
||||
WHERE run_id = %s AND stage = %s AND agent_role = %s AND item_key = %s AND status != 'done'",
|
||||
$now,
|
||||
$now,
|
||||
$run_id,
|
||||
$stage,
|
||||
$agent_role,
|
||||
$item_key
|
||||
)
|
||||
);
|
||||
PRAutoBlogger_Run_Stage_Writes::fail( $run_id, $stage, $agent_role, $item_key );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -196,7 +135,7 @@ class PRAutoBlogger_Run_Stage_State {
|
||||
if ( '' === $run_id || ! self::is_available() ) {
|
||||
return null;
|
||||
}
|
||||
$agent_role = self::resolve_role( $stage, $agent_role );
|
||||
$agent_role = PRAutoBlogger_Run_Stage_Writes::resolve_role( $stage, $agent_role );
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
@@ -265,49 +204,68 @@ class PRAutoBlogger_Run_Stage_State {
|
||||
|
||||
/**
|
||||
* Fail every still-open stage of one item. Done stages are untouched.
|
||||
* Delegates to Run_Stage_Writes (v0.20.0 split — behavior unchanged).
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $item_key Stage item key ('' = run-level stages).
|
||||
*/
|
||||
public static function fail_open_for_item( string $run_id, string $item_key ): void {
|
||||
PRAutoBlogger_Run_Stage_Writes::fail_open_for_item( $run_id, $item_key );
|
||||
}
|
||||
|
||||
/**
|
||||
* All stage rows for one item of a run (plus run-level rows when
|
||||
* $include_run_level), ordered by id. Read API for the rerun engine
|
||||
* and the dossier's item-scoped assembly (v0.20.0).
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $item_key Item key ('idea:<hash>').
|
||||
* @param bool $include_run_level Include item_key='' rows too.
|
||||
* @return array<int, array<string, mixed>> Stage rows (ARRAY_A).
|
||||
*/
|
||||
public static function stages_for_item( string $run_id, string $item_key, bool $include_run_level = true ): array {
|
||||
if ( '' === $run_id || ! self::is_available() ) {
|
||||
return;
|
||||
return array();
|
||||
}
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
$now = current_time( 'mysql' );
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->query(
|
||||
$sql = $include_run_level
|
||||
? "SELECT * FROM {$table} WHERE run_id = %s AND item_key IN (%s, '') ORDER BY id ASC"
|
||||
: "SELECT * FROM {$table} WHERE run_id = %s AND item_key = %s ORDER BY id ASC";
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- assembled from two fixed literals above.
|
||||
$rows = $wpdb->get_results( $wpdb->prepare( $sql, $run_id, $item_key ), ARRAY_A );
|
||||
return is_array( $rows ) ? $rows : array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of the given runs carry at least one stale stage (batched --
|
||||
* feeds the Review Queue warning chips without N+1; CPO product AC:
|
||||
* a stale flag is present at publish). Self-healing: missing table
|
||||
* or column returns [].
|
||||
*
|
||||
* @param array<string> $run_ids Run UUIDs.
|
||||
* @return array<string> Subset of $run_ids having stale stages.
|
||||
*/
|
||||
public static function runs_with_stale_stages( array $run_ids ): array {
|
||||
$run_ids = array_values( array_filter( array_map( 'strval', $run_ids ) ) );
|
||||
if ( empty( $run_ids ) || ! self::is_available() ) {
|
||||
return array();
|
||||
}
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
$placeholders = implode( ',', array_fill( 0, count( $run_ids ), '%s' ) );
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- $placeholders is a fixed %s list.
|
||||
$rows = $wpdb->get_col(
|
||||
$wpdb->prepare(
|
||||
"UPDATE {$table} SET status = 'failed', updated_at = %s, finished_at = %s
|
||||
WHERE run_id = %s AND item_key = %s AND status IN ('pending','running')",
|
||||
$now,
|
||||
$now,
|
||||
$run_id,
|
||||
$item_key
|
||||
"SELECT DISTINCT run_id FROM {$table} WHERE stale = 1 AND run_id IN ({$placeholders})",
|
||||
$run_ids
|
||||
)
|
||||
);
|
||||
return is_array( $rows ) ? array_map( 'strval', $rows ) : array();
|
||||
}
|
||||
|
||||
/** Reset per-request caches (tests). */
|
||||
public static function flush_cache(): void {
|
||||
self::$table_ok = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective agent role for a stage.
|
||||
*
|
||||
* '' → derive from Stage_Display_Map (unknown stages map to '').
|
||||
* Non-empty → returned as-is (Phase-2 explicit fan-out role).
|
||||
*
|
||||
* @param string $stage Stage name.
|
||||
* @param string $agent_role Caller-supplied role ('' = auto-resolve).
|
||||
* @return string Effective agent role.
|
||||
*/
|
||||
private static function resolve_role( string $stage, string $agent_role ): string {
|
||||
if ( '' !== $agent_role ) {
|
||||
return $agent_role;
|
||||
}
|
||||
return (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( $stage );
|
||||
}
|
||||
}
|
||||
|
||||
222
includes/core/class-run-stage-writes.php
Normal file
222
includes/core/class-run-stage-writes.php
Normal file
@@ -0,0 +1,222 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Pipeline-path write operations over `wp_prautoblogger_run_stages`.
|
||||
*
|
||||
* Extracted from PRAutoBlogger_Run_Stage_State in v0.20.0 so that class
|
||||
* stays under the 300-line cap (the M2 class-split pattern): start/done/
|
||||
* fail/fail_open_for_item bodies moved here verbatim; Run_Stage_State
|
||||
* keeps thin delegating proxies, so every existing call site is
|
||||
* unchanged. One M3 behavior delta, isolated in done(): a freshly
|
||||
* completed stage clears its `stale` flag (fresh output is never stale),
|
||||
* with a self-healing legacy retry for half-migrated schemas that lack
|
||||
* the v0.20.0 columns (same retry pattern as Cost_Tracker v0.18.0).
|
||||
*
|
||||
* Operator-action mutations (restart/mark_stale/demote) live in
|
||||
* PRAutoBlogger_Run_Stage_Rerun_State, not here.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Run_Stage_State proxies (pipeline call sites).
|
||||
* Dependencies: Run_Stage_State (table probe), Stage_Display_Map (role).
|
||||
*
|
||||
* @see core/class-run-stage-state.php — Public API + read methods.
|
||||
* @see core/class-run-stage-rerun-state.php — M3 operator-action writes.
|
||||
* @see ARCHITECTURE.md #22 / #24 — Substrate + edit/re-run design.
|
||||
*/
|
||||
class PRAutoBlogger_Run_Stage_Writes {
|
||||
|
||||
/**
|
||||
* Mark a stage as running. Sticky: done stages are never demoted.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $stage Stage name (see Stage_Display_Map).
|
||||
* @param string $agent_role Fan-out dimension ('' = resolve from stage map).
|
||||
* @param string $item_key Article scope ('' for run-level stages).
|
||||
*/
|
||||
public static function start( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): void {
|
||||
if ( '' === $run_id || ! PRAutoBlogger_Run_Stage_State::is_available() ) {
|
||||
return;
|
||||
}
|
||||
$agent_role = self::resolve_role( $stage, $agent_role );
|
||||
global $wpdb;
|
||||
$table = PRAutoBlogger_Run_Stage_State::table_name();
|
||||
$now = current_time( 'mysql' );
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"INSERT INTO {$table}
|
||||
(run_id, stage, agent_role, item_key, status, attempt, started_at, updated_at)
|
||||
VALUES (%s, %s, %s, %s, 'running', 1, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
attempt = IF(status = 'done', attempt, attempt + 1),
|
||||
status = IF(status = 'done', 'done', 'running'),
|
||||
updated_at = VALUES(updated_at)",
|
||||
$run_id,
|
||||
$stage,
|
||||
$agent_role,
|
||||
$item_key,
|
||||
$now,
|
||||
$now
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a stage done and persist its output for resume-without-recharge.
|
||||
*
|
||||
* v0.20.0: a fresh completion clears the `stale` flag (and never
|
||||
* touches `human_modified` — that flag is sticky run audit). When the
|
||||
* site's schema predates the v0.20.0 columns (cron fired before the
|
||||
* admin_init migration pass), the write self-heals by retrying the
|
||||
* pre-v0.20.0 statement instead of losing the checkpoint.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $stage Stage name.
|
||||
* @param string $agent_role Fan-out dimension ('' = resolve from stage map).
|
||||
* @param string $item_key Article scope.
|
||||
* @param string|null $output Stage output snapshot (pruned after R days).
|
||||
* @param float $cost_usd Cost attributed to this stage.
|
||||
*/
|
||||
public static function done( string $run_id, string $stage, string $agent_role = '', string $item_key = '', ?string $output = null, float $cost_usd = 0.0 ): void {
|
||||
if ( '' === $run_id || ! PRAutoBlogger_Run_Stage_State::is_available() ) {
|
||||
return;
|
||||
}
|
||||
$agent_role = self::resolve_role( $stage, $agent_role );
|
||||
global $wpdb;
|
||||
$table = PRAutoBlogger_Run_Stage_State::table_name();
|
||||
$now = current_time( 'mysql' );
|
||||
$meta = null !== $output ? wp_json_encode( array( 'output' => $output ) ) : null;
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$result = $wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"INSERT INTO {$table}
|
||||
(run_id, stage, agent_role, item_key, status, attempt, cost_usd, meta_json, stale, started_at, updated_at, finished_at)
|
||||
VALUES (%s, %s, %s, %s, 'done', 1, %f, %s, 0, %s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
status = 'done',
|
||||
cost_usd = VALUES(cost_usd),
|
||||
meta_json = VALUES(meta_json),
|
||||
stale = 0,
|
||||
updated_at = VALUES(updated_at),
|
||||
finished_at = VALUES(finished_at)",
|
||||
$run_id,
|
||||
$stage,
|
||||
$agent_role,
|
||||
$item_key,
|
||||
$cost_usd,
|
||||
$meta,
|
||||
$now,
|
||||
$now,
|
||||
$now
|
||||
)
|
||||
);
|
||||
|
||||
// Self-healing on a half-migrated schema (no `stale` column yet):
|
||||
// retry with the pre-v0.20.0 statement rather than losing the
|
||||
// resume checkpoint. Same pattern as Cost_Tracker (v0.18.0).
|
||||
if ( false === $result ) {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"INSERT INTO {$table}
|
||||
(run_id, stage, agent_role, item_key, status, attempt, cost_usd, meta_json, started_at, updated_at, finished_at)
|
||||
VALUES (%s, %s, %s, %s, 'done', 1, %f, %s, %s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
status = 'done',
|
||||
cost_usd = VALUES(cost_usd),
|
||||
meta_json = VALUES(meta_json),
|
||||
updated_at = VALUES(updated_at),
|
||||
finished_at = VALUES(finished_at)",
|
||||
$run_id,
|
||||
$stage,
|
||||
$agent_role,
|
||||
$item_key,
|
||||
$cost_usd,
|
||||
$meta,
|
||||
$now,
|
||||
$now,
|
||||
$now
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a stage failed (does not demote a done stage).
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $stage Stage name.
|
||||
* @param string $agent_role Fan-out dimension ('' = resolve from stage map).
|
||||
* @param string $item_key Article scope.
|
||||
*/
|
||||
public static function fail( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): void {
|
||||
if ( '' === $run_id || ! PRAutoBlogger_Run_Stage_State::is_available() ) {
|
||||
return;
|
||||
}
|
||||
$agent_role = self::resolve_role( $stage, $agent_role );
|
||||
global $wpdb;
|
||||
$table = PRAutoBlogger_Run_Stage_State::table_name();
|
||||
$now = current_time( 'mysql' );
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"UPDATE {$table} SET status = 'failed', updated_at = %s, finished_at = %s
|
||||
WHERE run_id = %s AND stage = %s AND agent_role = %s AND item_key = %s AND status != 'done'",
|
||||
$now,
|
||||
$now,
|
||||
$run_id,
|
||||
$stage,
|
||||
$agent_role,
|
||||
$item_key
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail every still-open stage of one item. Done stages are untouched.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $item_key Stage item key ('' = run-level stages).
|
||||
*/
|
||||
public static function fail_open_for_item( string $run_id, string $item_key ): void {
|
||||
if ( '' === $run_id || ! PRAutoBlogger_Run_Stage_State::is_available() ) {
|
||||
return;
|
||||
}
|
||||
global $wpdb;
|
||||
$table = PRAutoBlogger_Run_Stage_State::table_name();
|
||||
$now = current_time( 'mysql' );
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"UPDATE {$table} SET status = 'failed', updated_at = %s, finished_at = %s
|
||||
WHERE run_id = %s AND item_key = %s AND status IN ('pending','running')",
|
||||
$now,
|
||||
$now,
|
||||
$run_id,
|
||||
$item_key
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective agent role for a stage.
|
||||
*
|
||||
* '' → derive from Stage_Display_Map (unknown stages map to '').
|
||||
* Non-empty → returned as-is (Phase-2 explicit fan-out role).
|
||||
*
|
||||
* Public in v0.20.0 (was private on Run_Stage_State) so the read API
|
||||
* and the rerun classes share one resolution source.
|
||||
*
|
||||
* @param string $stage Stage name.
|
||||
* @param string $agent_role Caller-supplied role ('' = auto-resolve).
|
||||
* @return string Effective agent role.
|
||||
*/
|
||||
public static function resolve_role( string $stage, string $agent_role ): string {
|
||||
if ( '' !== $agent_role ) {
|
||||
return $agent_role;
|
||||
}
|
||||
return (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( $stage );
|
||||
}
|
||||
}
|
||||
@@ -252,6 +252,41 @@ class PRAutoBlogger_Run_State {
|
||||
return is_array( $rows ) ? $rows : array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reopen a terminal run for operator-deliberate new spend (M3
|
||||
* re-runs). done/failed/halted -> running; finished_at cleared; the
|
||||
* cost ceiling is RE-SNAPSHOTTED from the current setting — the
|
||||
* deliberate re-run action adopts the operator's current per-run
|
||||
* policy (can lower as well as raise), exactly as a new run would.
|
||||
* Accumulated reserved/settled spend is kept: the governor treats
|
||||
* re-runs as new spend on the SAME run (CPO guardrail 4).
|
||||
*
|
||||
* The conditional UPDATE is the atomic gate: an actively executing
|
||||
* run (pending/running) can never be reopened.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @return bool Whether the run transitioned to running.
|
||||
*/
|
||||
public static function reopen( string $run_id ): bool {
|
||||
if ( '' === $run_id || ! self::is_available() ) {
|
||||
return false;
|
||||
}
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$updated = $wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"UPDATE {$table}
|
||||
SET status = 'running', ceiling_usd = %f, updated_at = %s, finished_at = NULL
|
||||
WHERE run_id = %s AND status IN ('done','failed','halted')",
|
||||
self::ceiling_setting(),
|
||||
current_time( 'mysql' ),
|
||||
$run_id
|
||||
)
|
||||
);
|
||||
return 1 === (int) $updated;
|
||||
}
|
||||
|
||||
/** Reset per-request caches (tests). */
|
||||
public static function flush_cache(): void {
|
||||
self::$table_ok = null;
|
||||
|
||||
@@ -204,7 +204,7 @@ class PRAutoBlogger_Semantic_Dedup {
|
||||
return false;
|
||||
}
|
||||
foreach ( $this->recent_titles as $title ) {
|
||||
$title_keywords = $this->extract_meaningful_keywords( $title );
|
||||
$title_keywords = PRAutoBlogger_Keyword_Extractor::extract( $title );
|
||||
$overlap = count( array_intersect( $topic_keywords, $title_keywords ) );
|
||||
if ( $overlap / count( $topic_keywords ) >= 0.6 ) {
|
||||
return true;
|
||||
@@ -241,131 +241,4 @@ class PRAutoBlogger_Semantic_Dedup {
|
||||
}
|
||||
return $titles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract meaningful keywords from text (fallback dedup).
|
||||
*
|
||||
* @param string $text Input text.
|
||||
* @return string[] Unique lowercase keywords.
|
||||
*/
|
||||
private function extract_meaningful_keywords( string $text ): array {
|
||||
static $stopwords = array(
|
||||
'a',
|
||||
'an',
|
||||
'the',
|
||||
'and',
|
||||
'or',
|
||||
'but',
|
||||
'is',
|
||||
'are',
|
||||
'was',
|
||||
'were',
|
||||
'in',
|
||||
'on',
|
||||
'at',
|
||||
'to',
|
||||
'for',
|
||||
'of',
|
||||
'with',
|
||||
'by',
|
||||
'from',
|
||||
'as',
|
||||
'it',
|
||||
'its',
|
||||
'this',
|
||||
'that',
|
||||
'your',
|
||||
'you',
|
||||
'how',
|
||||
'what',
|
||||
'when',
|
||||
'why',
|
||||
'where',
|
||||
'which',
|
||||
'who',
|
||||
'do',
|
||||
'does',
|
||||
'did',
|
||||
'not',
|
||||
'no',
|
||||
'can',
|
||||
'will',
|
||||
'should',
|
||||
'could',
|
||||
'would',
|
||||
'may',
|
||||
'might',
|
||||
'have',
|
||||
'has',
|
||||
'had',
|
||||
'be',
|
||||
'been',
|
||||
'being',
|
||||
'about',
|
||||
'into',
|
||||
'through',
|
||||
'during',
|
||||
'before',
|
||||
'after',
|
||||
'above',
|
||||
'below',
|
||||
'between',
|
||||
'same',
|
||||
'up',
|
||||
'down',
|
||||
'out',
|
||||
'off',
|
||||
'over',
|
||||
'under',
|
||||
'again',
|
||||
'then',
|
||||
'here',
|
||||
'there',
|
||||
'all',
|
||||
'each',
|
||||
'every',
|
||||
'both',
|
||||
'few',
|
||||
'more',
|
||||
'most',
|
||||
'other',
|
||||
'some',
|
||||
'such',
|
||||
'than',
|
||||
'too',
|
||||
'very',
|
||||
'just',
|
||||
'also',
|
||||
'only',
|
||||
'own',
|
||||
'so',
|
||||
'if',
|
||||
'while',
|
||||
'because',
|
||||
'until',
|
||||
'vs',
|
||||
'versus',
|
||||
'guide',
|
||||
'complete',
|
||||
'ultimate',
|
||||
'best',
|
||||
'top',
|
||||
'new',
|
||||
'first',
|
||||
'need',
|
||||
'know',
|
||||
'everything',
|
||||
);
|
||||
|
||||
$text = strtolower( $text );
|
||||
$words = preg_split( '/[^a-z0-9-]+/', $text, -1, PREG_SPLIT_NO_EMPTY );
|
||||
$words = array_filter(
|
||||
$words,
|
||||
static function ( string $w ) use ( $stopwords ): bool {
|
||||
return strlen( $w ) >= 2 && ! in_array( $w, $stopwords, true );
|
||||
}
|
||||
);
|
||||
|
||||
return array_values( array_unique( $words ) );
|
||||
}
|
||||
}
|
||||
|
||||
269
includes/core/class-stage-input-store.php
Normal file
269
includes/core/class-stage-input-store.php
Normal file
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* INSERT-only store over `wp_prautoblogger_stage_inputs` (v0.20.0, M3).
|
||||
*
|
||||
* What: Immutable input-version rows for the edit + re-run feature.
|
||||
* Two row kinds, discriminated by `source`:
|
||||
* - 'human': an operator's edited request body for one stage. Each
|
||||
* save creates the NEXT version for the (run, stage, role, item)
|
||||
* scope — there is deliberately no UPDATE path in this class, so
|
||||
* originals (and earlier forks) can never be overwritten (CPO
|
||||
* guardrail 1; the executed original lives untouched in
|
||||
* generation_log.request_json).
|
||||
* - 'seed': the serialized Article_Idea persisted at worker start
|
||||
* (stage='', version=1). Re-run-from-here reconstructs the EXACT
|
||||
* idea from it — rebuilding the idea from post fields would risk
|
||||
* an item_key hash mismatch (post_title is sanitized at insert)
|
||||
* and a duplicate post.
|
||||
* Self-healing: every method no-ops (null/false/empty) when the
|
||||
* table is missing (half-migrated schema).
|
||||
* Retention: human fork bodies are NULLed by the run-reaper after
|
||||
* R days (same setting as all heavy payloads); seed rows are ~1KB
|
||||
* structural state and are kept.
|
||||
* Who triggers it: Dossier_Actions (save fork), Rerun_Executor (read fork
|
||||
* / seed), Article_Worker (save seed), Run_Reaper (prune).
|
||||
* Dependencies: WordPress $wpdb.
|
||||
*
|
||||
* @see admin/class-dossier-actions.php — Fork save endpoint.
|
||||
* @see core/class-rerun-executor.php — Fork/seed consumption.
|
||||
* @see core/class-run-reaper.php — Retention prune.
|
||||
* @see ARCHITECTURE.md #24 — Edit + re-run design.
|
||||
*/
|
||||
class PRAutoBlogger_Stage_Input_Store {
|
||||
|
||||
/** Source discriminator: operator edit fork. */
|
||||
public const SOURCE_HUMAN = 'human';
|
||||
|
||||
/** Source discriminator: idea seed persisted at worker start. */
|
||||
public const SOURCE_SEED = 'seed';
|
||||
|
||||
/** @var bool|null Per-request "stage_inputs table exists" probe result. */
|
||||
private static ?bool $table_ok = null;
|
||||
|
||||
/** @return string Fully-qualified stage_inputs table name. */
|
||||
public static function table_name(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . 'prautoblogger_stage_inputs';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the stage_inputs table exists. Cached per request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_available(): bool {
|
||||
if ( null !== self::$table_ok ) {
|
||||
return self::$table_ok;
|
||||
}
|
||||
global $wpdb;
|
||||
if ( null === $wpdb ) {
|
||||
return false; // Not cached: $wpdb may appear later in boot.
|
||||
}
|
||||
$table = self::table_name();
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
|
||||
self::$table_ok = ( $found === $table );
|
||||
return self::$table_ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a human edit fork as the next version for its scope.
|
||||
*
|
||||
* INSERT-only by design. The version number is MAX(version)+1 for the
|
||||
* scope; a concurrent-save collision on the UNIQUE key is retried
|
||||
* once with a refreshed version.
|
||||
*
|
||||
* Side effects: one (rarely two) INSERTs into stage_inputs.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $stage Stage name.
|
||||
* @param string $agent_role Stage row's agent role.
|
||||
* @param string $item_key Stage row's item key.
|
||||
* @param string $request_json Edited request body (full JSON, no headers).
|
||||
* @param string $author WP user login of the editor.
|
||||
* @return int|null New version number, or null on failure/missing table.
|
||||
*/
|
||||
public static function save_fork( string $run_id, string $stage, string $agent_role, string $item_key, string $request_json, string $author ): ?int {
|
||||
if ( '' === $run_id || '' === $stage || ! self::is_available() ) {
|
||||
return null;
|
||||
}
|
||||
for ( $attempt = 0; $attempt < 2; $attempt++ ) {
|
||||
$version = self::next_version( $run_id, $stage, $agent_role, $item_key );
|
||||
$inserted = self::insert_row( $run_id, $stage, $agent_role, $item_key, $version, self::SOURCE_HUMAN, $request_json, $author );
|
||||
if ( $inserted ) {
|
||||
return $version;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest human fork row for a scope, or null when none exist.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $stage Stage name.
|
||||
* @param string $agent_role Agent role.
|
||||
* @param string $item_key Item key.
|
||||
* @return array<string, mixed>|null Row incl. version/request_json/author/created_at.
|
||||
*/
|
||||
public static function latest_fork( string $run_id, string $stage, string $agent_role, string $item_key ): ?array {
|
||||
if ( '' === $run_id || ! self::is_available() ) {
|
||||
return null;
|
||||
}
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$row = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$table}
|
||||
WHERE run_id = %s AND stage = %s AND agent_role = %s AND item_key = %s AND source = %s
|
||||
ORDER BY version DESC LIMIT 1",
|
||||
$run_id,
|
||||
$stage,
|
||||
$agent_role,
|
||||
$item_key,
|
||||
self::SOURCE_HUMAN
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
return is_array( $row ) ? $row : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the idea seed for one run item (INSERT-once; replays no-op).
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $item_key Item key from Run_Stage_State::item_key_for_idea().
|
||||
* @param string $idea_json Serialized Article_Idea::to_array() payload.
|
||||
* @return void
|
||||
*/
|
||||
public static function save_seed( string $run_id, string $item_key, string $idea_json ): void {
|
||||
if ( '' === $run_id || '' === $item_key || ! self::is_available() ) {
|
||||
return;
|
||||
}
|
||||
if ( null !== self::get_seed( $run_id, $item_key ) ) {
|
||||
return; // Seed already persisted (idempotent resume).
|
||||
}
|
||||
self::insert_row( $run_id, '', '', $item_key, 1, self::SOURCE_SEED, $idea_json, 'pipeline' );
|
||||
}
|
||||
|
||||
/**
|
||||
* The idea seed JSON for one run item, or null when absent.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $item_key Item key.
|
||||
* @return string|null
|
||||
*/
|
||||
public static function get_seed( string $run_id, string $item_key ): ?string {
|
||||
if ( '' === $run_id || ! self::is_available() ) {
|
||||
return null;
|
||||
}
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$json = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT request_json FROM {$table}
|
||||
WHERE run_id = %s AND stage = '' AND item_key = %s AND source = %s LIMIT 1",
|
||||
$run_id,
|
||||
$item_key,
|
||||
self::SOURCE_SEED
|
||||
)
|
||||
);
|
||||
return ( is_string( $json ) && '' !== $json ) ? $json : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retention: NULL human fork bodies older than the cutoff. Seed rows
|
||||
* are kept (structural, ~1KB). Version/author/flag metadata survives
|
||||
* as the permanent audit trail.
|
||||
*
|
||||
* @param string $cutoff MySQL datetime; rows created before it are pruned.
|
||||
* @return int Rows pruned.
|
||||
*/
|
||||
public static function prune_human_bodies( string $cutoff ): int {
|
||||
if ( ! self::is_available() ) {
|
||||
return 0;
|
||||
}
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$result = $wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"UPDATE {$table} SET request_json = NULL
|
||||
WHERE source = %s AND request_json IS NOT NULL AND created_at < %s",
|
||||
self::SOURCE_HUMAN,
|
||||
$cutoff
|
||||
)
|
||||
);
|
||||
return is_numeric( $result ) ? (int) $result : 0;
|
||||
}
|
||||
|
||||
/** Reset per-request caches (tests). */
|
||||
public static function flush_cache(): void {
|
||||
self::$table_ok = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Next version number for a scope (1 when none exist).
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $stage Stage name.
|
||||
* @param string $agent_role Agent role.
|
||||
* @param string $item_key Item key.
|
||||
* @return int
|
||||
*/
|
||||
private static function next_version( string $run_id, string $stage, string $agent_role, string $item_key ): int {
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$max = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT MAX(version) FROM {$table}
|
||||
WHERE run_id = %s AND stage = %s AND agent_role = %s AND item_key = %s",
|
||||
$run_id,
|
||||
$stage,
|
||||
$agent_role,
|
||||
$item_key
|
||||
)
|
||||
);
|
||||
return ( null !== $max ? (int) $max : 0 ) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw row INSERT (the only write path — INSERT-only by construction).
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $stage Stage name ('' for seeds).
|
||||
* @param string $agent_role Agent role.
|
||||
* @param string $item_key Item key.
|
||||
* @param int $version Version number.
|
||||
* @param string $source 'human' or 'seed'.
|
||||
* @param string $request_json Payload JSON.
|
||||
* @param string $author Author label.
|
||||
* @return bool Whether the row was inserted.
|
||||
*/
|
||||
private static function insert_row( string $run_id, string $stage, string $agent_role, string $item_key, int $version, string $source, string $request_json, string $author ): bool {
|
||||
global $wpdb;
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$inserted = $wpdb->insert(
|
||||
self::table_name(),
|
||||
array(
|
||||
'run_id' => $run_id,
|
||||
'stage' => $stage,
|
||||
'agent_role' => $agent_role,
|
||||
'item_key' => $item_key,
|
||||
'version' => $version,
|
||||
'source' => $source,
|
||||
'request_json' => $request_json,
|
||||
'author' => $author,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
)
|
||||
);
|
||||
return false !== $inserted;
|
||||
}
|
||||
}
|
||||
189
includes/core/class-stage-replay.php
Normal file
189
includes/core/class-stage-replay.php
Normal file
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Executes one stage replay from a saved input fork (v0.20.0, M3).
|
||||
*
|
||||
* What: Decodes an immutable stage_inputs fork body back into the
|
||||
* (messages, model, options) triple and sends it through the
|
||||
* normal provider seam — so the cost governor's reserve-before-
|
||||
* call (on the SAME run ledger row), the retry/backoff loop, the
|
||||
* empty-completion guard, and the B1 request recorder all apply
|
||||
* to replays exactly as to pipeline calls (CPO guardrail 4). The
|
||||
* fresh output lands in the stage's run_stages snapshot (the same
|
||||
* place downstream rebuilds read from) and a generation_log row
|
||||
* is written with the replayed request body.
|
||||
*
|
||||
* options_from_body() is the exact inverse of Request_Builder::
|
||||
* build_body() + apply_reasoning_budget(): the v0.18.1 reasoning
|
||||
* headroom is subtracted back out (the builder re-adds it), and
|
||||
* reasoning is pinned explicitly — enabled:false when the original
|
||||
* body had none — so a later change to the global reasoning
|
||||
* setting can never alter the replay's shape.
|
||||
* Who triggers it: PRAutoBlogger_Rerun_Executor::on_replay_job() only
|
||||
* (after eligibility re-validation, lock, reopen, restart and
|
||||
* stale-marking).
|
||||
* Dependencies: OpenRouter_Provider, Cost_Tracker, Run_Stage_State,
|
||||
* Audit_Writer, Stage_Display_Map.
|
||||
*
|
||||
* @see core/class-rerun-executor.php — Orchestration around this.
|
||||
* @see providers/class-open-router-request-builder.php — The forward transform.
|
||||
* @see ARCHITECTURE.md #24 — Edit + re-run design.
|
||||
*/
|
||||
class PRAutoBlogger_Stage_Replay {
|
||||
|
||||
/**
|
||||
* Reconstruct provider options from a stored request body.
|
||||
*
|
||||
* Inverse of build_body()/apply_reasoning_budget(): copies
|
||||
* temperature/response_format; recovers the caller's max_tokens by
|
||||
* subtracting the reasoning cap the builder added as headroom; pins
|
||||
* reasoning explicitly (the stored body is the contract — global
|
||||
* setting drift must not leak in). Adds the caller-metadata keys
|
||||
* (stage, prompt_key) used by the guard's logging — never sent
|
||||
* upstream.
|
||||
*
|
||||
* @param array<string, mixed> $body Decoded request body.
|
||||
* @param string $stage Stage being replayed.
|
||||
* @return array<string, mixed> Options for send_chat_completion().
|
||||
*/
|
||||
public static function options_from_body( array $body, string $stage ): array {
|
||||
$options = array();
|
||||
if ( isset( $body['temperature'] ) ) {
|
||||
$options['temperature'] = $body['temperature'];
|
||||
}
|
||||
if ( isset( $body['response_format'] ) ) {
|
||||
$options['response_format'] = $body['response_format'];
|
||||
}
|
||||
|
||||
$reasoning_active = isset( $body['reasoning'] ) && false !== ( $body['reasoning']['enabled'] ?? true );
|
||||
if ( $reasoning_active ) {
|
||||
$options['reasoning'] = $body['reasoning'];
|
||||
if ( isset( $body['max_tokens'] ) ) {
|
||||
$cap = isset( $body['reasoning']['max_tokens'] ) ? (int) $body['reasoning']['max_tokens'] : 0;
|
||||
// The builder raised max_tokens by the cap (completion
|
||||
// headroom); hand back the caller-level value so the
|
||||
// builder's re-application is not compounded.
|
||||
$options['max_tokens'] = max( 1, (int) $body['max_tokens'] - $cap );
|
||||
}
|
||||
} else {
|
||||
// Pin reasoning OFF (same per-call override shape the
|
||||
// empty-completion retry uses) so a globally-enabled
|
||||
// reasoning setting cannot reshape the replay.
|
||||
$options['reasoning'] = array( 'enabled' => false );
|
||||
if ( isset( $body['max_tokens'] ) ) {
|
||||
$options['max_tokens'] = (int) $body['max_tokens'];
|
||||
}
|
||||
}
|
||||
|
||||
$options['stage'] = $stage;
|
||||
$options['prompt_key'] = PRAutoBlogger_Stage_Display_Map::default_prompt_key( $stage );
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode + validate a fork body. Returns null when the JSON is not a
|
||||
* replayable chat body (model string + non-empty messages list of
|
||||
* role/content pairs).
|
||||
*
|
||||
* @param string $request_json Stored fork body.
|
||||
* @return array<string, mixed>|null Decoded body, or null when invalid.
|
||||
*/
|
||||
public static function decode_body( string $request_json ): ?array {
|
||||
$body = json_decode( $request_json, true );
|
||||
if ( ! is_array( $body ) || empty( $body['model'] ) || ! is_string( $body['model'] ) ) {
|
||||
return null;
|
||||
}
|
||||
if ( empty( $body['messages'] ) || ! is_array( $body['messages'] ) ) {
|
||||
return null;
|
||||
}
|
||||
foreach ( $body['messages'] as $message ) {
|
||||
if ( ! is_array( $message ) || ! isset( $message['role'], $message['content'] )
|
||||
|| ! is_string( $message['role'] ) || ! is_string( $message['content'] ) ) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return $body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the replay call and persist its results.
|
||||
*
|
||||
* The caller (Rerun_Executor) has already: re-validated eligibility,
|
||||
* taken the generation lock, reopened the run, demoted the stage row
|
||||
* to running and stale-marked downstream. This method performs the
|
||||
* governed LLM call and the success-path writes.
|
||||
*
|
||||
* Side effects: one OpenRouter call (governor-reserved), run_stages
|
||||
* done-write, one generation_log row (with the replayed request via
|
||||
* the B1 recorder), run_decisions human_modified flags.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $stage Stage name.
|
||||
* @param string $agent_role Stage row agent role.
|
||||
* @param string $item_key Stage row item key.
|
||||
* @param array<string, mixed> $body Decoded fork body (from decode_body()).
|
||||
* @return array{content: string, cost: float, model: string} Replay result.
|
||||
*
|
||||
* @throws \RuntimeException On provider failure or empty output.
|
||||
* @throws PRAutoBlogger_Cost_Ceiling_Exception When the governor blocks the call.
|
||||
*/
|
||||
public static function run( string $run_id, string $stage, string $agent_role, string $item_key, array $body ): array {
|
||||
$tracker = new PRAutoBlogger_Cost_Tracker();
|
||||
// Joins this process to the run: Run_Context (governor guard),
|
||||
// ledger row, prompt pins — replay spend reserves against the
|
||||
// SAME per-run ceiling as the original calls (guardrail 4).
|
||||
$tracker->set_run_id( $run_id );
|
||||
|
||||
$llm = new PRAutoBlogger_OpenRouter_Provider();
|
||||
$options = self::options_from_body( $body, $stage );
|
||||
$response = $llm->send_chat_completion( $body['messages'], (string) $body['model'], $options );
|
||||
|
||||
// Same belt as the writer path: an empty replay output must fail
|
||||
// the stage, never overwrite a good snapshot with nothing.
|
||||
if ( '' === trim( (string) $response['content'] ) ) {
|
||||
throw new \RuntimeException(
|
||||
sprintf(
|
||||
/* translators: 1: stage name, 2: model identifier. */
|
||||
__( 'Replay of stage "%1$s" produced empty content (model %2$s) — failing the replay instead of storing an empty output.', 'prautoblogger' ),
|
||||
$stage,
|
||||
(string) $response['model']
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$cost = $llm->estimate_cost(
|
||||
(string) $response['model'],
|
||||
(int) $response['prompt_tokens'],
|
||||
(int) $response['completion_tokens']
|
||||
);
|
||||
|
||||
PRAutoBlogger_Run_Stage_State::done( $run_id, $stage, $agent_role, $item_key, (string) $response['content'], $cost );
|
||||
|
||||
$tracker->log_api_call(
|
||||
null,
|
||||
$stage,
|
||||
$llm->get_provider_name(),
|
||||
(string) $response['model'],
|
||||
(int) $response['prompt_tokens'],
|
||||
(int) $response['completion_tokens'],
|
||||
'success',
|
||||
'',
|
||||
'' !== $agent_role ? $agent_role : null,
|
||||
null
|
||||
);
|
||||
|
||||
// Guardrail 2: decision rows for the replayed stage carry the
|
||||
// human_modified flag (no-op when the stage has none).
|
||||
PRAutoBlogger_Audit_Writer::flag_decisions_human_modified( $run_id, array( $stage ) );
|
||||
|
||||
return array(
|
||||
'content' => (string) $response['content'],
|
||||
'cost' => $cost,
|
||||
'model' => (string) $response['model'],
|
||||
);
|
||||
}
|
||||
}
|
||||
88
includes/providers/class-image-aspect-ratio.php
Normal file
88
includes/providers/class-image-aspect-ratio.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Aspect-ratio snap utility for image generation providers.
|
||||
*
|
||||
* Converts pixel dimensions to the nearest supported W:H string for APIs
|
||||
* that require an aspect-ratio parameter rather than raw pixel sizes.
|
||||
*
|
||||
* What: Static utility; no state.
|
||||
* Who triggers: PRAutoBlogger_OpenRouter_Image_Provider, PRAutoBlogger_OpenRouter_Image_Batch,
|
||||
* PRAutoBlogger_Runware_Image_Batch.
|
||||
* Dependencies: none.
|
||||
*
|
||||
* @see providers/class-open-router-image-provider.php -- generate_image() path.
|
||||
* @see providers/class-open-router-image-batch.php -- parallel batch path.
|
||||
* @see providers/class-runware-image-batch.php -- Runware parallel batch.
|
||||
*/
|
||||
class PRAutoBlogger_Image_Aspect_Ratio {
|
||||
|
||||
/**
|
||||
* Standard aspect ratios supported by OpenRouter / Runware image models.
|
||||
*
|
||||
* @var array<int, array{w: int, h: int, ratio: float}>
|
||||
*/
|
||||
private const STANDARD_ASPECTS = array(
|
||||
array(
|
||||
'w' => 1,
|
||||
'h' => 1,
|
||||
'ratio' => 1.0,
|
||||
),
|
||||
array(
|
||||
'w' => 3,
|
||||
'h' => 2,
|
||||
'ratio' => 1.5,
|
||||
),
|
||||
array(
|
||||
'w' => 2,
|
||||
'h' => 3,
|
||||
'ratio' => 0.6667,
|
||||
),
|
||||
array(
|
||||
'w' => 4,
|
||||
'h' => 3,
|
||||
'ratio' => 1.3333,
|
||||
),
|
||||
array(
|
||||
'w' => 3,
|
||||
'h' => 4,
|
||||
'ratio' => 0.75,
|
||||
),
|
||||
array(
|
||||
'w' => 16,
|
||||
'h' => 9,
|
||||
'ratio' => 1.7778,
|
||||
),
|
||||
array(
|
||||
'w' => 9,
|
||||
'h' => 16,
|
||||
'ratio' => 0.5625,
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Snap pixel dimensions to the nearest supported aspect ratio string.
|
||||
*
|
||||
* @param int $width Width in pixels.
|
||||
* @param int $height Height in pixels (must be > 0 to avoid division by zero).
|
||||
* @return string Ratio string e.g. "16:9". Returns "1:1" when height is 0.
|
||||
*/
|
||||
public static function snap( int $width, int $height ): string {
|
||||
$target = $height > 0 ? (float) $width / (float) $height : 1.0;
|
||||
$best = self::STANDARD_ASPECTS[0];
|
||||
$best_d = abs( $target - $best['ratio'] );
|
||||
|
||||
foreach ( self::STANDARD_ASPECTS as $candidate ) {
|
||||
$d = abs( $target - $candidate['ratio'] );
|
||||
if ( $d < $best_d ) {
|
||||
$best = $candidate;
|
||||
$best_d = $d;
|
||||
}
|
||||
}
|
||||
|
||||
return $best['w'] . ':' . $best['h'];
|
||||
}
|
||||
}
|
||||
@@ -262,56 +262,7 @@ class PRAutoBlogger_OpenRouter_Image_Batch {
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function build_request_body( string $prompt, int $width, int $height, string $model ): array {
|
||||
$target = $height > 0 ? (float) $width / (float) $height : 1.0;
|
||||
|
||||
// Snap to nearest standard aspect ratio.
|
||||
$aspects = array(
|
||||
array(
|
||||
'w' => 1,
|
||||
'h' => 1,
|
||||
'r' => 1.0,
|
||||
),
|
||||
array(
|
||||
'w' => 3,
|
||||
'h' => 2,
|
||||
'r' => 1.5,
|
||||
),
|
||||
array(
|
||||
'w' => 2,
|
||||
'h' => 3,
|
||||
'r' => 0.6667,
|
||||
),
|
||||
array(
|
||||
'w' => 4,
|
||||
'h' => 3,
|
||||
'r' => 1.3333,
|
||||
),
|
||||
array(
|
||||
'w' => 3,
|
||||
'h' => 4,
|
||||
'r' => 0.75,
|
||||
),
|
||||
array(
|
||||
'w' => 16,
|
||||
'h' => 9,
|
||||
'r' => 1.7778,
|
||||
),
|
||||
array(
|
||||
'w' => 9,
|
||||
'h' => 16,
|
||||
'r' => 0.5625,
|
||||
),
|
||||
);
|
||||
$best = $aspects[0];
|
||||
$best_d = abs( $target - $best['r'] );
|
||||
foreach ( $aspects as $c ) {
|
||||
$d = abs( $target - $c['r'] );
|
||||
if ( $d < $best_d ) {
|
||||
$best = $c;
|
||||
$best_d = $d;
|
||||
}
|
||||
}
|
||||
$aspect = $best['w'] . ':' . $best['h'];
|
||||
$aspect = PRAutoBlogger_Image_Aspect_Ratio::snap( $width, $height );
|
||||
|
||||
return array(
|
||||
'model' => $model,
|
||||
|
||||
@@ -233,51 +233,10 @@ class PRAutoBlogger_OpenRouter_Image_Provider implements PRAutoBlogger_Image_Pro
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Supported aspect ratios for OpenRouter image models.
|
||||
*
|
||||
* @var array<int, array{w: int, h: int, ratio: float}>
|
||||
*/
|
||||
private const STANDARD_ASPECTS = array(
|
||||
array(
|
||||
'w' => 1,
|
||||
'h' => 1,
|
||||
'ratio' => 1.0,
|
||||
),
|
||||
array(
|
||||
'w' => 3,
|
||||
'h' => 2,
|
||||
'ratio' => 1.5,
|
||||
),
|
||||
array(
|
||||
'w' => 2,
|
||||
'h' => 3,
|
||||
'ratio' => 0.6667,
|
||||
),
|
||||
array(
|
||||
'w' => 4,
|
||||
'h' => 3,
|
||||
'ratio' => 1.3333,
|
||||
),
|
||||
array(
|
||||
'w' => 3,
|
||||
'h' => 4,
|
||||
'ratio' => 0.75,
|
||||
),
|
||||
array(
|
||||
'w' => 16,
|
||||
'h' => 9,
|
||||
'ratio' => 1.7778,
|
||||
),
|
||||
array(
|
||||
'w' => 9,
|
||||
'h' => 16,
|
||||
'ratio' => 0.5625,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
private function build_request_body( string $prompt, int $width, int $height, string $model ): array {
|
||||
$aspect = $this->snap_aspect_ratio( $width, $height );
|
||||
$aspect = PRAutoBlogger_Image_Aspect_Ratio::snap( $width, $height );
|
||||
|
||||
return array(
|
||||
'model' => $model,
|
||||
@@ -296,29 +255,6 @@ class PRAutoBlogger_OpenRouter_Image_Provider implements PRAutoBlogger_Image_Pro
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap pixel dimensions to the nearest supported aspect ratio string.
|
||||
*
|
||||
* @param int $width Width in pixels.
|
||||
* @param int $height Height in pixels.
|
||||
* @return string Aspect ratio string like "16:9".
|
||||
*/
|
||||
private function snap_aspect_ratio( int $width, int $height ): string {
|
||||
$target = $height > 0 ? (float) $width / (float) $height : 1.0;
|
||||
$best = self::STANDARD_ASPECTS[0];
|
||||
$best_d = abs( $target - $best['ratio'] );
|
||||
|
||||
foreach ( self::STANDARD_ASPECTS as $candidate ) {
|
||||
$d = abs( $target - $candidate['ratio'] );
|
||||
if ( $d < $best_d ) {
|
||||
$best = $candidate;
|
||||
$best_d = $d;
|
||||
}
|
||||
}
|
||||
|
||||
return $best['w'] . ':' . $best['h'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce the interface's dimension contract.
|
||||
*
|
||||
|
||||
@@ -78,6 +78,13 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
|
||||
// reasoning token cap + completion headroom — lives in the builder.
|
||||
$body = $builder->build_body( $messages, $model, $options );
|
||||
|
||||
// v0.20.0 (B1): stash the outgoing request body so the next
|
||||
// log_api_call() persists it to generation_log.request_json.
|
||||
// Only the BODY is recorded -- Authorization lives exclusively in
|
||||
// build_headers()' return and can never reach the recorder.
|
||||
// Recorded BEFORE dispatch so error/timeout rows carry it too.
|
||||
PRAutoBlogger_Request_Recorder::record( $body );
|
||||
|
||||
// Whether the outgoing request has reasoning enabled — drives the
|
||||
// empty-completion retry decision after parse.
|
||||
$reasoning_active = isset( $body['reasoning'] ) && false !== ( $body['reasoning']['enabled'] ?? true );
|
||||
|
||||
@@ -138,7 +138,8 @@ class PRAutoBlogger_Runware_Catalog_Fetcher {
|
||||
* @param int $offset Pagination offset (0-based).
|
||||
* @param int $limit Page size.
|
||||
* @return array{results: array<int, array<string, mixed>>, totalResults: int}
|
||||
* @throws \RuntimeException On HTTP error or malformed response.
|
||||
* @throws \RuntimeException On HTTP error or malformed response (non-200 exceptions
|
||||
* include up to 200 chars of the response body for diagnosis).
|
||||
*/
|
||||
private function fetch_page( string $api_key, int $offset, int $limit ): array {
|
||||
$payload = array(
|
||||
@@ -171,8 +172,10 @@ class PRAutoBlogger_Runware_Catalog_Fetcher {
|
||||
|
||||
$status = (int) wp_remote_retrieve_response_code( $response );
|
||||
if ( 200 !== $status ) {
|
||||
$raw_body = (string) wp_remote_retrieve_body( $response );
|
||||
$body_excerpt = '' !== $raw_body ? ' — body: ' . substr( $raw_body, 0, 200 ) : '';
|
||||
throw new \RuntimeException(
|
||||
sprintf( 'Runware modelSearch returned HTTP %d', $status )
|
||||
sprintf( 'Runware modelSearch returned HTTP %d%s', $status, $body_excerpt )
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,19 +2,7 @@
|
||||
// phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
declare(strict_types=1);
|
||||
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_init
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_setopt
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_close
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_errno
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_error
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_getinfo
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_init
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_add_handle
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_exec
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_select
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_getcontent
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_remove_handle
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_close
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions -- curl_multi required for parallel image generation; no WP HTTP equivalent.
|
||||
|
||||
/**
|
||||
* Parallel image generation via curl_multi for Runware.
|
||||
|
||||
@@ -178,72 +178,15 @@ class PRAutoBlogger_Runware_Image_Provider implements PRAutoBlogger_Image_Provid
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* Runware has no dedicated "validate key" endpoint, so we send a minimal
|
||||
* authentication task and look for `taskType=authentication` in the
|
||||
* response data with no accompanying errors.
|
||||
* Delegates to PRAutoBlogger_Runware_Validator (extracted for 300-line compliance).
|
||||
*
|
||||
* @see providers/class-runware-image-validator.php
|
||||
*/
|
||||
public function validate_credentials_detailed(): array {
|
||||
try {
|
||||
$api_key = $this->support->get_api_key();
|
||||
} catch ( \RuntimeException $e ) {
|
||||
return array(
|
||||
'status' => 'error',
|
||||
'message' => $e->getMessage(),
|
||||
);
|
||||
}
|
||||
|
||||
$body = array(
|
||||
array(
|
||||
'taskType' => 'authentication',
|
||||
'apiKey' => $api_key,
|
||||
),
|
||||
);
|
||||
|
||||
$response = wp_remote_post(
|
||||
PRAutoBlogger_Runware_Image_Support::API_URL,
|
||||
array(
|
||||
'timeout' => 15,
|
||||
'headers' => array( 'Content-Type' => 'application/json' ),
|
||||
'body' => wp_json_encode( $body ),
|
||||
)
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return array(
|
||||
'status' => 'error',
|
||||
'message' => __( 'Could not reach Runware API.', 'prautoblogger' ),
|
||||
'debug' => $response->get_error_message(),
|
||||
);
|
||||
}
|
||||
|
||||
$status = (int) wp_remote_retrieve_response_code( $response );
|
||||
$raw = (string) wp_remote_retrieve_body( $response );
|
||||
|
||||
if ( 200 !== $status ) {
|
||||
return array(
|
||||
'status' => 'error',
|
||||
'message' => sprintf( __( 'Runware returned HTTP %d.', 'prautoblogger' ), $status ),
|
||||
'debug' => substr( $raw, 0, 200 ),
|
||||
);
|
||||
}
|
||||
|
||||
$decoded = json_decode( $raw, true );
|
||||
if ( is_array( $decoded ) && isset( $decoded['errors'] ) && ! empty( $decoded['errors'] ) ) {
|
||||
$first = $decoded['errors'][0] ?? array();
|
||||
$msg = is_array( $first ) ? (string) ( $first['message'] ?? '' ) : (string) $first;
|
||||
return array(
|
||||
'status' => 'error',
|
||||
'message' => __( 'Runware rejected the API key.', 'prautoblogger' ),
|
||||
'debug' => substr( $msg, 0, 200 ),
|
||||
);
|
||||
}
|
||||
|
||||
return array(
|
||||
'status' => 'ok',
|
||||
'message' => __( 'Runware API key valid.', 'prautoblogger' ),
|
||||
);
|
||||
return PRAutoBlogger_Runware_Validator::validate( $this->support );
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Build the JSON task-array body for a single imageInference request.
|
||||
*
|
||||
|
||||
89
includes/providers/class-runware-image-validator.php
Normal file
89
includes/providers/class-runware-image-validator.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Credential validator for the Runware image provider.
|
||||
*
|
||||
* Extracted from PRAutoBlogger_Runware_Image_Provider for 300-line compliance.
|
||||
* Sends a minimal authentication task to Runware's API to confirm the key is valid.
|
||||
*
|
||||
* What: Static utility; no state.
|
||||
* Who triggers: PRAutoBlogger_Runware_Image_Provider::validate_credentials_detailed().
|
||||
* Dependencies: PRAutoBlogger_Runware_Image_Support (API_URL constant, get_api_key()).
|
||||
*
|
||||
* @see providers/class-runware-image-provider.php -- Sole caller.
|
||||
* @see providers/class-runware-image-support.php -- API key retrieval + URL.
|
||||
*/
|
||||
class PRAutoBlogger_Runware_Validator {
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* Runware has no dedicated "validate key" endpoint, so we send a minimal
|
||||
* authentication task and look for `taskType=authentication` in the
|
||||
* response data with no accompanying errors.
|
||||
*/
|
||||
public static function validate( PRAutoBlogger_Runware_Image_Support $support ): array {
|
||||
try {
|
||||
$api_key = $support->get_api_key();
|
||||
} catch ( \RuntimeException $e ) {
|
||||
return array(
|
||||
'status' => 'error',
|
||||
'message' => $e->getMessage(),
|
||||
);
|
||||
}
|
||||
|
||||
$body = array(
|
||||
array(
|
||||
'taskType' => 'authentication',
|
||||
'apiKey' => $api_key,
|
||||
),
|
||||
);
|
||||
|
||||
$response = wp_remote_post(
|
||||
PRAutoBlogger_Runware_Image_Support::API_URL,
|
||||
array(
|
||||
'timeout' => 15,
|
||||
'headers' => array( 'Content-Type' => 'application/json' ),
|
||||
'body' => wp_json_encode( $body ),
|
||||
)
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return array(
|
||||
'status' => 'error',
|
||||
'message' => __( 'Could not reach Runware API.', 'prautoblogger' ),
|
||||
'debug' => $response->get_error_message(),
|
||||
);
|
||||
}
|
||||
|
||||
$status = (int) wp_remote_retrieve_response_code( $response );
|
||||
$raw = (string) wp_remote_retrieve_body( $response );
|
||||
|
||||
if ( 200 !== $status ) {
|
||||
return array(
|
||||
'status' => 'error',
|
||||
'message' => sprintf( __( 'Runware returned HTTP %d.', 'prautoblogger' ), $status ),
|
||||
'debug' => substr( $raw, 0, 200 ),
|
||||
);
|
||||
}
|
||||
|
||||
$decoded = json_decode( $raw, true );
|
||||
if ( is_array( $decoded ) && isset( $decoded['errors'] ) && ! empty( $decoded['errors'] ) ) {
|
||||
$first = $decoded['errors'][0] ?? array();
|
||||
$msg = is_array( $first ) ? (string) ( $first['message'] ?? '' ) : (string) $first;
|
||||
return array(
|
||||
'status' => 'error',
|
||||
'message' => __( 'Runware rejected the API key.', 'prautoblogger' ),
|
||||
'debug' => substr( $msg, 0, 200 ),
|
||||
);
|
||||
}
|
||||
|
||||
return array(
|
||||
'status' => 'ok',
|
||||
'message' => __( 'Runware API key valid.', 'prautoblogger' ),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.18.3
|
||||
* Version: 0.22.0
|
||||
* Requires at least: 6.0
|
||||
* Requires PHP: 7.4
|
||||
* Author: PeptideRepo
|
||||
@@ -35,31 +35,26 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
| Defined here so every file in the plugin can reference paths, versions,
|
||||
| and limits without magic strings.
|
||||
*/
|
||||
define( 'PRAUTOBLOGGER_VERSION', '0.18.3' );
|
||||
define( 'PRAUTOBLOGGER_DB_VERSION', '1.2.0' );
|
||||
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
|
||||
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||
define( 'PRAUTOBLOGGER_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
|
||||
define( 'PRAUTOBLOGGER_PLUGIN_BASENAME', plugin_basename( __FILE__ ) );
|
||||
// API and retry limits — named constants instead of magic numbers.
|
||||
// SAFETY INVARIANT: API_TIMEOUT_SECONDS × MAX_RETRIES must stay < STATUS_TTL (600s,
|
||||
// defined in PRAutoBlogger_Generation_Status_Poller). The current product
|
||||
// (180s × 3 = 540s) leaves a 60s margin. If either constant is raised, verify the
|
||||
// inequality still holds — otherwise a fully-retrying LLM call can exhaust the
|
||||
// status transient TTL within a single stage and trigger a false orphan-run abort.
|
||||
define( 'PRAUTOBLOGGER_MAX_RETRIES', 3 );
|
||||
// Pipeline v2 Phase 1 (v0.18.0) defaults. Both are SETTINGS-backed at every
|
||||
// read site (prautoblogger_per_run_cost_ceiling_usd /
|
||||
// prautoblogger_request_json_retention_days); these constants exist only as
|
||||
// the named get_option() fallbacks — never inline the literals.
|
||||
define( 'PRAUTOBLOGGER_VERSION', '0.22.0' );
|
||||
define( 'PRAUTOBLOGGER_DB_VERSION', '1.4.0' );
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD', 0.50 );
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS', 14 );
|
||||
// Default max cards shown per board column and per ideas-browser page.
|
||||
// Settings-backed (prautoblogger_board_column_limit, prautoblogger_ideas_per_page).
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT', 20 );
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_IDEAS_PER_PAGE', 30 );
|
||||
// v0.18.1: ceiling on "thinking" tokens per reasoning-enabled LLM call. The
|
||||
// request's completion budget (max_tokens) is RAISED by this amount so
|
||||
// reasoning can never consume the visible-content budget and return an empty
|
||||
// message (the 2026-06-11 empty-draft incident). SETTINGS-backed
|
||||
// (prautoblogger_reasoning_max_tokens); 0 disables the cap (pure effort mode).
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_REASONING_MAX_TOKENS', 2048 );
|
||||
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
|
||||
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||
define( 'PRAUTOBLOGGER_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
|
||||
define( 'PRAUTOBLOGGER_PLUGIN_BASENAME', plugin_basename( __FILE__ ) );
|
||||
// API and retry limits — named constants instead of magic numbers.
|
||||
define( 'PRAUTOBLOGGER_MAX_RETRIES', 3 );
|
||||
define( 'PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS', 2 );
|
||||
define( 'PRAUTOBLOGGER_API_TIMEOUT_SECONDS', 180 );
|
||||
define( 'PRAUTOBLOGGER_CACHE_TTL_SECONDS', 3600 );
|
||||
@@ -124,6 +119,7 @@ PRAutoBlogger_Autoloader::register();
|
||||
// The main orchestrator class is loaded explicitly because its CamelCase name
|
||||
// (PRAutoBlogger) doesn't map cleanly to a filename via the autoloader's
|
||||
// kebab-case convention (it would look for class-pr-auto-blogger.php).
|
||||
require_once PRAUTOBLOGGER_PLUGIN_DIR . 'includes/class-db-migrations.php'; // Loaded explicitly: DB_Migrations -> class-d-b-migrations.php via autoloader
|
||||
require_once PRAUTOBLOGGER_PLUGIN_DIR . 'includes/class-prautoblogger.php';
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
252
scripts/check-constants.php
Normal file
252
scripts/check-constants.php
Normal file
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
/**
|
||||
* CI guard: every PRAUTOBLOGGER_* constant referenced in the shipped plugin PHP
|
||||
* must be either (a) defined in prautoblogger.php, or (b) protected by a
|
||||
* defined() guard at the reference site.
|
||||
*
|
||||
* Purpose: prevent the class of regression that caused the v0.19.0 admin-500
|
||||
* incident — a constant deleted from prautoblogger.php while tests/bootstrap.php
|
||||
* kept a fallback copy, masking the gap until prod boot.
|
||||
*
|
||||
* Usage (from repo root):
|
||||
* php scripts/check-constants.php [--verbose] [--self-test]
|
||||
*
|
||||
* Exit 0 = all clear. Exit 1 = unguarded undefined constants found. Exit 2 = --self-test failed.
|
||||
*
|
||||
* Include/exclude summary (relative to repo root):
|
||||
* Defined-set source prautoblogger.php only
|
||||
* Scanned for refs prautoblogger.php, includes/, templates/, uninstall.php
|
||||
* Excluded from refs tests/ vendor/ .github/ .git/ node_modules/
|
||||
* Excluded from defs everything except prautoblogger.php
|
||||
*
|
||||
* Guard window: a bare constant use is considered guarded if a defined() call
|
||||
* for the same constant appears within GUARD_WINDOW lines above it in the same
|
||||
* file (covers if/elseif blocks and multi-line && chains).
|
||||
*
|
||||
* @package PRAutoBlogger
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// How many lines above the bare use to look for a defined() guard.
|
||||
const CONSTANTS_GUARD_WINDOW = 4; // 4 covers if/elseif blocks and multi-line && chains (wider risks false-positives).
|
||||
|
||||
// Source of truth for define()d constants -- test-bootstrap fallbacks excluded.
|
||||
const CONSTANTS_BOOTSTRAP_FILE = 'prautoblogger.php';
|
||||
|
||||
// Directories skipped when walking the shipped tree for references.
|
||||
const CONSTANTS_EXCLUDE_DIRS = array( 'tests', 'vendor', '.github', '.git', 'node_modules' );
|
||||
|
||||
// Entry points for the reference scan (files and dirs relative to repo root).
|
||||
const CONSTANTS_SCAN_ROOTS = array(
|
||||
'prautoblogger.php',
|
||||
'uninstall.php',
|
||||
'includes',
|
||||
'templates',
|
||||
);
|
||||
|
||||
$verbose = in_array( '--verbose', $argv ?? array(), true );
|
||||
$self_test = in_array( '--self-test', $argv ?? array(), true );
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1. Build the defined set from prautoblogger.php only.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
if ( ! file_exists( CONSTANTS_BOOTSTRAP_FILE ) ) {
|
||||
fwrite( STDERR, "ERROR: bootstrap file not found -- run from repo root.\n" );
|
||||
fwrite( STDERR, ' Expected: ' . CONSTANTS_BOOTSTRAP_FILE . "\n" );
|
||||
exit( 1 );
|
||||
}
|
||||
|
||||
$bootstrap_src = (string) file_get_contents( CONSTANTS_BOOTSTRAP_FILE );
|
||||
|
||||
preg_match_all(
|
||||
"/define\s*\(\s*'(PRAUTOBLOGGER_[A-Z0-9_]+)'/",
|
||||
$bootstrap_src,
|
||||
$def_matches
|
||||
);
|
||||
|
||||
/** @var array<string, int> $defined_set constant name => 1 */
|
||||
$defined_set = array_flip( $def_matches[1] ?? array() );
|
||||
|
||||
if ( $verbose ) {
|
||||
echo 'Defined in ' . CONSTANTS_BOOTSTRAP_FILE . ' (' . count( $defined_set ) . "):\n";
|
||||
foreach ( array_keys( $defined_set ) as $constant_name ) {
|
||||
echo " {$constant_name}\n";
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 2. Collect shipped PHP files to scan.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Recursively collect *.php paths under a directory, skipping excluded dirs.
|
||||
*
|
||||
* @param string $dir Directory to walk.
|
||||
* @param string[] $files Accumulator (modified by reference).
|
||||
* @return void
|
||||
*/
|
||||
function constants_collect_php_files( string $dir, array &$files ): void {
|
||||
$dir_iter = new DirectoryIterator( $dir );
|
||||
foreach ( $dir_iter as $entry ) {
|
||||
if ( $entry->isDot() ) {
|
||||
continue;
|
||||
}
|
||||
$basename = $entry->getBasename();
|
||||
if ( $entry->isDir() ) {
|
||||
if ( ! in_array( $basename, CONSTANTS_EXCLUDE_DIRS, true ) ) {
|
||||
constants_collect_php_files( $entry->getPathname(), $files );
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ( $entry->isFile() && $entry->getExtension() === 'php' ) {
|
||||
$files[] = $entry->getPathname();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @var string[] $php_files */
|
||||
$php_files = array();
|
||||
|
||||
foreach ( CONSTANTS_SCAN_ROOTS as $root ) {
|
||||
if ( ! file_exists( $root ) ) {
|
||||
// Template dir may not exist in very early checkouts -- skip silently.
|
||||
continue;
|
||||
}
|
||||
if ( is_file( $root ) ) {
|
||||
$php_files[] = $root;
|
||||
} else {
|
||||
constants_collect_php_files( $root, $php_files );
|
||||
}
|
||||
}
|
||||
|
||||
sort( $php_files );
|
||||
|
||||
if ( $verbose ) {
|
||||
echo 'Scanning ' . count( $php_files ) . " PHP files\n\n";
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3. Scan each file for bare PRAUTOBLOGGER_* references.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** @var array<int, array{string, int, string, string}> $errors */
|
||||
$errors = array();
|
||||
|
||||
foreach ( $php_files as $php_path ) {
|
||||
$raw = file_get_contents( $php_path );
|
||||
if ( false === $raw ) {
|
||||
fwrite( STDERR, "WARNING: cannot read {$php_path} -- skipping\n" );
|
||||
continue;
|
||||
}
|
||||
|
||||
$lines = explode( "\n", $raw );
|
||||
|
||||
foreach ( $lines as $idx => $raw_line ) {
|
||||
// Fast pre-check: skip lines with no PRAUTOBLOGGER_ token.
|
||||
if ( strpos( $raw_line, 'PRAUTOBLOGGER_' ) === false ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip pure comment lines (docblock * lines, // comments, # lines).
|
||||
$trimmed = ltrim( $raw_line );
|
||||
if (
|
||||
str_starts_with( $trimmed, '*' ) ||
|
||||
str_starts_with( $trimmed, '//' ) ||
|
||||
str_starts_with( $trimmed, '#' )
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract all constant names from this line.
|
||||
if ( ! preg_match_all( '/PRAUTOBLOGGER_[A-Z0-9_]+/', $raw_line, $match ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ( $match[0] as $name ) {
|
||||
// Skip if this line is itself a define() call for this name.
|
||||
if ( preg_match( "/define\s*\(\s*'" . preg_quote( $name, '/' ) . "'/", $raw_line ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if the constant is production-defined in prautoblogger.php.
|
||||
if ( isset( $defined_set[ $name ] ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for a defined() guard in the preceding GUARD_WINDOW lines
|
||||
// (inclusive of the current line to catch same-line chained &&).
|
||||
$window_start = max( 0, $idx - CONSTANTS_GUARD_WINDOW );
|
||||
$guarded = false;
|
||||
for ( $w = $window_start; $w <= $idx; $w++ ) {
|
||||
if ( preg_match( "/defined\s*\(\s*'" . preg_quote( $name, '/' ) . "'/", $lines[ $w ] ) ) {
|
||||
$guarded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! $guarded ) {
|
||||
$errors[] = array( $php_path, $idx + 1, $name, $raw_line );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4. Report results.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
if ( array() !== $errors ) {
|
||||
fwrite( STDERR, "\nFAIL -- Unguarded undefined PRAUTOBLOGGER_* constants:\n\n" );
|
||||
foreach ( $errors as [ $php_path, $lineno, $name, $line_text ] ) {
|
||||
fwrite( STDERR, " {$php_path}:{$lineno}: {$name}\n" );
|
||||
fwrite( STDERR, ' ' . trim( $line_text ) . "\n" );
|
||||
}
|
||||
fwrite( STDERR, "\nTo fix: add define( '" . $errors[0][2] . "', ... ) to prautoblogger.php,\n" );
|
||||
fwrite( STDERR, "or guard the reference with if ( defined( '" . $errors[0][2] . "' ) ) { ... }\n\n" );
|
||||
exit( 1 );
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 5. Self-test mode: synthesize a missing constant and assert non-zero exit.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
if ( $self_test ) {
|
||||
// Temporarily inject a fake reference that must NOT be in prautoblogger.php.
|
||||
// The scan above will flag PRAUTOBLOGGER_SELFTEST_SENTINEL as unguarded.
|
||||
// We verify this script would have exited non-zero in that scenario.
|
||||
$sentinel = 'PRAUTOBLOGGER_SELFTEST_SENTINEL';
|
||||
$bootstrap_src = (string) file_get_contents( CONSTANTS_BOOTSTRAP_FILE );
|
||||
if ( false !== strpos( $bootstrap_src, $sentinel ) ) {
|
||||
fwrite( STDERR, "SELF-TEST FAIL: sentinel constant already defined in prautoblogger.php -- pick a unique name.\n" );
|
||||
exit( 2 );
|
||||
}
|
||||
|
||||
// If $errors is empty at this point, the real codebase is clean (good).
|
||||
// Now simulate an unguarded reference by re-running the scan on a temp file.
|
||||
$tmp_file = tempnam( sys_get_temp_dir(), 'prab_selftest_' ) . '.php';
|
||||
file_put_contents( $tmp_file, "<?php\necho {$sentinel};\n" );
|
||||
$tmp_errors = array();
|
||||
$tmp_lines = explode( "\n", (string) file_get_contents( $tmp_file ) );
|
||||
foreach ( $tmp_lines as $idx => $raw_line ) {
|
||||
if ( preg_match_all( '/PRAUTOBLOGGER_[A-Z0-9_]+/', $raw_line, $m ) ) {
|
||||
foreach ( $m[0] as $n ) {
|
||||
if ( ! isset( $defined_set[ $n ] ) ) {
|
||||
$tmp_errors[] = $n;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@unlink( $tmp_file );
|
||||
if ( ! in_array( $sentinel, $tmp_errors, true ) ) {
|
||||
fwrite( STDERR, "SELF-TEST FAIL: scanner did not detect unguarded sentinel constant.\n" );
|
||||
exit( 2 );
|
||||
}
|
||||
echo "SELF-TEST OK -- scanner correctly detected the synthetic missing constant.\n";
|
||||
exit( 0 );
|
||||
}
|
||||
|
||||
echo "OK -- All PRAUTOBLOGGER_* constants are production-defined or defined()-guarded.\n";
|
||||
exit( 0 );
|
||||
169
templates/admin/board-page.php
Normal file
169
templates/admin/board-page.php
Normal file
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
/**
|
||||
* Kanban board template — primary landing screen for PRAutoBlogger.
|
||||
*
|
||||
* Renders the four-column board: Generating | In Review | Published | Failed.
|
||||
* Cards are populated on first load by a synchronous PHP data call, then kept
|
||||
* live by the board.js poller (AJAX, settings-backed interval).
|
||||
*
|
||||
* @see admin/class-board-page.php — Renders this template.
|
||||
* @see admin/class-board-data-provider.php — Supplies card data.
|
||||
* @see assets/js/board.js — Polling + DOM updates.
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Eager load on first render so the board is not blank on arrival.
|
||||
$provider = new PRAutoBlogger_Board_Data_Provider();
|
||||
$snapshot = $provider->get_board_snapshot();
|
||||
|
||||
/**
|
||||
* Render a single board card as HTML.
|
||||
*
|
||||
* @param array<string, mixed> $card Card data from Board_Data_Provider.
|
||||
* @param string $column_key Column identifier (generating|in_review|published|failed).
|
||||
* @return void
|
||||
*/
|
||||
function prab_render_board_card( array $card, string $column_key ): void {
|
||||
$title = ! empty( $card['title'] ) ? esc_html( $card['title'] ) : esc_html__( 'Untitled', 'prautoblogger' );
|
||||
$cost = isset( $card['cost_total'] ) ? '$' . esc_html( number_format( (float) $card['cost_total'], 4 ) ) : '';
|
||||
$click_url = '';
|
||||
$link_text = '';
|
||||
|
||||
if ( 'review' === ( $card['click_action'] ?? '' ) ) {
|
||||
$click_url = esc_url( $card['review_url'] ?? '' );
|
||||
$link_text = esc_html__( 'Review Queue', 'prautoblogger' );
|
||||
} elseif ( 'edit' === ( $card['click_action'] ?? '' ) ) {
|
||||
$click_url = esc_url( $card['edit_url'] ?? '' );
|
||||
$link_text = esc_html__( 'Edit Post', 'prautoblogger' );
|
||||
} elseif ( 'logs' === ( $card['click_action'] ?? '' ) ) {
|
||||
$click_url = esc_url( $card['log_url'] ?? '' );
|
||||
$link_text = esc_html__( 'View Log', 'prautoblogger' );
|
||||
}
|
||||
|
||||
$is_generating = ( 'generating' === $column_key );
|
||||
$card_class = 'prab-board-card';
|
||||
if ( $is_generating ) {
|
||||
$card_class .= ' prab-board-card--generating';
|
||||
}
|
||||
?>
|
||||
<div class="<?php echo esc_attr( $card_class ); ?>"
|
||||
data-run-id="<?php echo esc_attr( (string) ( $card['run_id'] ?? '' ) ); ?>"
|
||||
data-post-id="<?php echo esc_attr( (string) ( $card['post_id'] ?? 0 ) ); ?>">
|
||||
|
||||
<?php if ( $is_generating ) : ?>
|
||||
<div class="prab-card-spinner" aria-label="<?php esc_attr_e( 'Generating in progress', 'prautoblogger' ); ?>"></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="prab-card-title"><?php echo $title; // Already escaped above. ?></div>
|
||||
|
||||
<?php if ( ! empty( $cost ) ) : ?>
|
||||
<div class="prab-card-meta">
|
||||
<span class="prab-card-cost"><?php echo $cost; // Already escaped above. ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ( $is_generating && ! empty( $card['stage_current'] ) ) : ?>
|
||||
<div class="prab-card-stage">
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: pipeline stage name */
|
||||
esc_html__( 'Stage: %s', 'prautoblogger' ),
|
||||
esc_html( ucfirst( (string) $card['stage_current'] ) )
|
||||
);
|
||||
?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ( ! empty( $card['error_message'] ) ) : ?>
|
||||
<div class="prab-card-error">
|
||||
<?php echo esc_html( wp_trim_words( (string) $card['error_message'], 12, '…' ) ); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ( ! empty( $click_url ) ) : ?>
|
||||
<div class="prab-card-actions">
|
||||
<a href="<?php echo $click_url; // Already escaped above. ?>" class="prab-card-link">
|
||||
<?php echo $link_text; // Already escaped above. ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
?>
|
||||
<div class="wrap prab-board-wrap">
|
||||
<h1 class="prab-board-heading">
|
||||
<?php esc_html_e( 'PRAutoBlogger', 'prautoblogger' ); ?>
|
||||
<span class="prab-board-heading-sub"><?php esc_html_e( 'Article Board', 'prautoblogger' ); ?></span>
|
||||
<?php if ( current_user_can( 'manage_options' ) ) : ?>
|
||||
<button type="button"
|
||||
id="prab-board-generate-now"
|
||||
class="prab-board-generate-btn button button-primary"
|
||||
aria-label="<?php esc_attr_e( 'Generate a new article now', 'prautoblogger' ); ?>">
|
||||
<?php esc_html_e( 'New Article', 'prautoblogger' ); ?>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</h1>
|
||||
<div id="prab-board-gen-status" class="prab-board-gen-status" aria-live="polite" style="display:none;"></div>
|
||||
|
||||
<div id="prab-board" class="prab-board" aria-live="polite" aria-label="<?php esc_attr_e( 'Article generation board', 'prautoblogger' ); ?>">
|
||||
|
||||
<?php
|
||||
$columns = array(
|
||||
'generating' => array(
|
||||
'label' => __( 'Generating', 'prautoblogger' ),
|
||||
'pill' => 'prab-pill--generating',
|
||||
'cards' => $snapshot['generating'],
|
||||
),
|
||||
'in_review' => array(
|
||||
'label' => __( 'In Review', 'prautoblogger' ),
|
||||
'pill' => 'prab-pill--review',
|
||||
'cards' => $snapshot['in_review'],
|
||||
),
|
||||
'published' => array(
|
||||
'label' => __( 'Published', 'prautoblogger' ),
|
||||
'pill' => 'prab-pill--published',
|
||||
'cards' => $snapshot['published'],
|
||||
),
|
||||
'failed' => array(
|
||||
'label' => __( 'Failed', 'prautoblogger' ),
|
||||
'pill' => 'prab-pill--failed',
|
||||
'cards' => $snapshot['failed'],
|
||||
),
|
||||
);
|
||||
foreach ( $columns as $col_key => $col ) :
|
||||
$count = count( $col['cards'] );
|
||||
?>
|
||||
<div class="prab-board-column" data-column="<?php echo esc_attr( $col_key ); ?>">
|
||||
<div class="prab-col-header">
|
||||
<span class="prab-col-title"><?php echo esc_html( $col['label'] ); ?></span>
|
||||
<span class="prab-col-count prab-pill <?php echo esc_attr( $col['pill'] ); ?>"><?php echo esc_html( (string) $count ); ?></span>
|
||||
</div>
|
||||
<div class="prab-col-cards" data-column-cards="<?php echo esc_attr( $col_key ); ?>">
|
||||
<?php if ( empty( $col['cards'] ) ) : ?>
|
||||
<p class="prab-col-empty"><?php esc_html_e( 'Nothing here yet.', 'prautoblogger' ); ?></p>
|
||||
<?php else : ?>
|
||||
<?php foreach ( $col['cards'] as $card ) : ?>
|
||||
<?php prab_render_board_card( $card, $col_key ); ?>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
</div><!-- #prab-board -->
|
||||
|
||||
<p class="prab-board-footer">
|
||||
<?php
|
||||
$interval_secs = max( 3, (int) get_option( 'prautoblogger_board_poll_interval', 5 ) );
|
||||
printf(
|
||||
/* translators: %d: poll interval in seconds */
|
||||
esc_html__( 'Board refreshes every %d seconds while an article is generating. Configure in Settings → Schedule & Budget.', 'prautoblogger' ),
|
||||
$interval_secs
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
</div><!-- .prab-board-wrap -->
|
||||
109
templates/admin/dossier-edit-panel.php
Normal file
109
templates/admin/dossier-edit-panel.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
/**
|
||||
* Partial: per-stage edit + replay panel (M3, v0.20.0).
|
||||
*
|
||||
* Rendered only for editable stages (writer chat stages with a
|
||||
* persisted input). Included by dossier-stage-section.php; inherits its
|
||||
* scope: $stage_name, $s_role, $section_id, $rerun (panel data),
|
||||
* $dossier (spend/eligibility/post_id).
|
||||
*
|
||||
* UX contract (CPO guardrails): the copy states explicitly that saving
|
||||
* forks a NEW version (original preserved), that re-running marks
|
||||
* downstream stages stale, that execution is QUEUED (never implied
|
||||
* synchronous), and what the run has already spent against its ceiling.
|
||||
*
|
||||
* Security: message contents via esc_textarea(); all attributes
|
||||
* esc_attr(); spend numbers via number_format + esc_html.
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$panel_prefill = is_array( $rerun['prefill'] ?? null ) ? $rerun['prefill'] : array(
|
||||
'model' => '',
|
||||
'messages' => array(),
|
||||
);
|
||||
$panel_fork = is_array( $rerun['fork'] ?? null ) ? $rerun['fork'] : null;
|
||||
$panel_spend = is_array( $dossier['spend'] ?? null ) ? $dossier['spend'] : array(
|
||||
'settled' => 0.0,
|
||||
'reserved' => 0.0,
|
||||
'ceiling' => 0.0,
|
||||
'warn' => false,
|
||||
);
|
||||
$held_usd = (float) $panel_spend['settled'] + (float) $panel_spend['reserved'];
|
||||
?>
|
||||
<div class="prab-edit-panel" id="<?php echo $section_id; // Already escaped. ?>-edit" hidden
|
||||
data-stage="<?php echo esc_attr( $stage_name ); ?>"
|
||||
data-agent-role="<?php echo esc_attr( $s_role ); ?>">
|
||||
|
||||
<p class="prab-edit-copy">
|
||||
<?php esc_html_e( 'You are editing a copy of this stage\'s input. Saving creates a new version — the original is preserved unchanged. Re-running this stage executes your latest saved version as a queued background job and marks all downstream stages stale; nothing downstream re-runs until you trigger it.', 'prautoblogger' ); ?>
|
||||
</p>
|
||||
|
||||
<p class="prab-edit-spend<?php echo ! empty( $panel_spend['warn'] ) ? ' prab-edit-spend--warn' : ''; ?>">
|
||||
<?php if ( (float) $panel_spend['ceiling'] > 0 ) : ?>
|
||||
<?php
|
||||
printf(
|
||||
/* translators: 1: spent USD, 2: ceiling USD. */
|
||||
esc_html__( 'Run spend so far: $%1$s of the $%2$s per-run ceiling. Re-runs are new spend on this run.', 'prautoblogger' ),
|
||||
esc_html( number_format( $held_usd, 4 ) ),
|
||||
esc_html( number_format( (float) $panel_spend['ceiling'], 2 ) )
|
||||
);
|
||||
if ( ! empty( $panel_spend['warn'] ) ) {
|
||||
echo ' ';
|
||||
esc_html_e( 'Warning: this run is approaching its ceiling — the re-run may halt. Raise the per-run ceiling setting first if needed.', 'prautoblogger' );
|
||||
}
|
||||
?>
|
||||
<?php else : ?>
|
||||
<?php esc_html_e( 'No per-run cost ceiling is configured for this run (monthly budget still applies).', 'prautoblogger' ); ?>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
|
||||
<p class="prab-edit-model">
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: model identifier. */
|
||||
esc_html__( 'Model (fixed for replay): %s', 'prautoblogger' ),
|
||||
esc_html( (string) $panel_prefill['model'] )
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
|
||||
<?php foreach ( $panel_prefill['messages'] as $i => $panel_message ) : ?>
|
||||
<div class="prab-edit-message">
|
||||
<label class="prab-edit-role" for="<?php echo $section_id; // Already escaped. ?>-msg-<?php echo (int) $i; ?>">
|
||||
<?php echo esc_html( ucfirst( (string) ( $panel_message['role'] ?? '' ) ) ); ?>
|
||||
</label>
|
||||
<textarea class="prab-edit-textarea large-text"
|
||||
id="<?php echo $section_id; // Already escaped. ?>-msg-<?php echo (int) $i; ?>"
|
||||
rows="<?php echo 'system' === ( $panel_message['role'] ?? '' ) ? 4 : 10; ?>"
|
||||
data-role="<?php echo esc_attr( (string) ( $panel_message['role'] ?? '' ) ); ?>"><?php echo esc_textarea( (string) ( $panel_message['content'] ?? '' ) ); ?></textarea>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<div class="prab-edit-actions">
|
||||
<button type="button" class="button button-primary prab-edit-save">
|
||||
<?php esc_html_e( 'Save as new version', 'prautoblogger' ); ?>
|
||||
</button>
|
||||
<button type="button" class="button prab-edit-rerun" <?php disabled( null === $panel_fork ); ?>>
|
||||
<?php esc_html_e( 'Re-run this stage (queued)', 'prautoblogger' ); ?>
|
||||
</button>
|
||||
<span class="prab-edit-forkinfo" data-forkinfo>
|
||||
<?php
|
||||
if ( null !== $panel_fork ) {
|
||||
printf(
|
||||
/* translators: 1: version number, 2: author login, 3: datetime. */
|
||||
esc_html__( 'Latest saved version: v%1$d by %2$s at %3$s', 'prautoblogger' ),
|
||||
(int) $panel_fork['version'],
|
||||
esc_html( (string) $panel_fork['author'] ),
|
||||
esc_html( (string) $panel_fork['created_at'] )
|
||||
);
|
||||
} else {
|
||||
esc_html_e( 'No edited version saved yet.', 'prautoblogger' );
|
||||
}
|
||||
?>
|
||||
</span>
|
||||
<span class="prab-edit-feedback" aria-live="polite" data-feedback></span>
|
||||
</div>
|
||||
</div>
|
||||
118
templates/admin/dossier-log-stage-section.php
Normal file
118
templates/admin/dossier-log-stage-section.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
/**
|
||||
* Partial: log-only stage section (M3/F3, v0.20.0).
|
||||
*
|
||||
* Renders stages that exist ONLY in generation_log — image_a/image_b,
|
||||
* image_prompt_rewrite, llm_research, opik_eval_judge, ... — which the
|
||||
* M2 dossier silently dropped because the image pipeline (and the
|
||||
* research providers) never write run_stages rows (QA M2 F3). These
|
||||
* sections show the call record (model, tokens, cost, status, raw
|
||||
* trace) from their REAL data source; image stages additionally render
|
||||
* the post's actual pipeline attachments ($dossier['images']).
|
||||
*
|
||||
* Variables injected by dossier-page.php:
|
||||
* $stage_name (string) -- stage key
|
||||
* $log_rows (array) -- generation_log rows for this stage
|
||||
* $dossier (array) -- page view model (images)
|
||||
*
|
||||
* Security: identical escaping discipline to dossier-stage-section.php.
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$lo_label = PRAutoBlogger_Stage_Display_Map::label( $stage_name );
|
||||
$lo_is_image = in_array( $stage_name, array( 'image_a', 'image_b' ), true );
|
||||
$lo_images = $lo_is_image && ! empty( $dossier['images'] ) ? (array) $dossier['images'] : array();
|
||||
$lo_cost = 0.0;
|
||||
$lo_status = '';
|
||||
$lo_model = '—';
|
||||
foreach ( $log_rows as $lo_row ) {
|
||||
$lo_cost += (float) ( $lo_row['estimated_cost'] ?? 0 );
|
||||
$lo_status = (string) ( $lo_row['response_status'] ?? '' );
|
||||
if ( ! empty( $lo_row['model'] ) ) {
|
||||
$lo_model = (string) $lo_row['model'];
|
||||
}
|
||||
}
|
||||
$lo_section_id = 'prab-logstage-' . esc_attr( $stage_name );
|
||||
?>
|
||||
<div class="prab-dossier-section prab-dossier-section--stage prab-dossier-section--log-only"
|
||||
data-stage="<?php echo esc_attr( $stage_name ); ?>"
|
||||
id="<?php echo $lo_section_id; // Already escaped. ?>">
|
||||
|
||||
<div class="prab-stage-header">
|
||||
<h2 class="prab-section-title"><?php echo esc_html( $lo_label ); ?></h2>
|
||||
<div class="prab-stage-header-meta">
|
||||
<?php if ( '' !== $lo_status ) : ?>
|
||||
<span class="prab-trace-status prab-trace-status--<?php echo esc_attr( $lo_status ); ?>">
|
||||
<?php echo esc_html( $lo_status ); ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
<?php if ( $lo_cost > 0 ) : ?>
|
||||
<span class="prab-stage-cost">$<?php echo esc_html( number_format( $lo_cost, 5 ) ); ?></span>
|
||||
<?php endif; ?>
|
||||
<span class="prab-stage-model"><?php echo esc_html( $lo_model ); ?></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ( ! empty( $lo_images ) ) : ?>
|
||||
<div class="prab-image-grid">
|
||||
<?php foreach ( $lo_images as $lo_image ) : ?>
|
||||
<figure class="prab-image-card<?php echo ! empty( $lo_image['is_featured'] ) ? ' prab-image-card--chosen' : ''; ?>">
|
||||
<img src="<?php echo esc_url( (string) $lo_image['url'] ); ?>" alt="<?php echo esc_attr( (string) $lo_image['role'] ); ?>" loading="lazy" />
|
||||
<figcaption>
|
||||
<?php echo esc_html( ucfirst( (string) $lo_image['role'] ) ); ?>
|
||||
<?php if ( ! empty( $lo_image['is_featured'] ) ) : ?>
|
||||
<span class="prab-chip prab-chip--chosen"><?php esc_html_e( 'Chosen (featured)', 'prautoblogger' ); ?></span>
|
||||
<?php endif; ?>
|
||||
</figcaption>
|
||||
</figure>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php elseif ( $lo_is_image ) : ?>
|
||||
<p class="prab-output-absent"><?php esc_html_e( 'No pipeline attachments recorded for this article (images attach on publish).', 'prautoblogger' ); ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="prab-stage-trace">
|
||||
<button type="button" class="prab-trace-toggle button-link"
|
||||
aria-expanded="false"
|
||||
aria-controls="<?php echo $lo_section_id; // Already escaped. ?>-trace">
|
||||
<?php esc_html_e( 'Show raw trace', 'prautoblogger' ); ?>
|
||||
</button>
|
||||
<div id="<?php echo $lo_section_id; // Already escaped. ?>-trace" class="prab-trace-detail" hidden>
|
||||
<?php foreach ( $log_rows as $log_row ) : ?>
|
||||
<div class="prab-trace-entry">
|
||||
<div class="prab-trace-meta">
|
||||
<span><?php echo esc_html( (string) ( $log_row['model'] ?? '—' ) ); ?></span>
|
||||
<span>
|
||||
<?php
|
||||
printf(
|
||||
/* translators: 1: prompt tokens, 2: completion tokens. */
|
||||
esc_html__( '%1$d prompt + %2$d completion tokens', 'prautoblogger' ),
|
||||
(int) ( $log_row['prompt_tokens'] ?? 0 ),
|
||||
(int) ( $log_row['completion_tokens'] ?? 0 )
|
||||
);
|
||||
?>
|
||||
</span>
|
||||
<span>$<?php echo esc_html( number_format( (float) ( $log_row['estimated_cost'] ?? 0 ), 6 ) ); ?></span>
|
||||
<span class="prab-trace-status prab-trace-status--<?php echo esc_attr( (string) ( $log_row['response_status'] ?? '' ) ); ?>">
|
||||
<?php echo esc_html( (string) ( $log_row['response_status'] ?? '' ) ); ?>
|
||||
</span>
|
||||
</div>
|
||||
<?php if ( ! empty( $log_row['request_json'] ) ) : ?>
|
||||
<details class="prab-trace-request">
|
||||
<summary><?php esc_html_e( 'Request payload', 'prautoblogger' ); ?></summary>
|
||||
<?php // SECURITY: esc_html() treats model output as untrusted HTML. ?>
|
||||
<pre class="prab-trace-pre"><?php echo esc_html( (string) $log_row['request_json'] ); ?></pre>
|
||||
</details>
|
||||
<?php endif; ?>
|
||||
<?php if ( ! empty( $log_row['error_message'] ) ) : ?>
|
||||
<p class="prab-trace-error"><?php echo esc_html( (string) $log_row['error_message'] ); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- .prab-dossier-section--log-only -->
|
||||
243
templates/admin/dossier-page.php
Normal file
243
templates/admin/dossier-page.php
Normal file
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
/**
|
||||
* Article Dossier template (M2 — read-only).
|
||||
*
|
||||
* Design contract: Proposal C "Editorial Record" -- warm editorial,
|
||||
* typographic hierarchy, verdict pills, receipt-style cost sidebar,
|
||||
* per-stage I/O with expandable raw-trace toggle. Vanilla JS/CSS only.
|
||||
*
|
||||
* Variables injected by PRAutoBlogger_Dossier_Page::render_page():
|
||||
* $post_id (int) -- WordPress post ID
|
||||
* $dossier (array) -- View model from PRAutoBlogger_Dossier_Data_Assembler
|
||||
* $assembler (obj) -- (not directly used here)
|
||||
*
|
||||
* @see admin/class-dossier-page.php -- Renders this template.
|
||||
* @see admin/class-dossier-data-assembler.php -- Builds $dossier.
|
||||
* @see assets/css/dossier.css -- Styling.
|
||||
* @see assets/js/dossier.js -- Raw-trace toggle.
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$has_run = ! empty( $dossier['has_run'] );
|
||||
$run = $dossier['run'] ?? null;
|
||||
$stages = $dossier['stages'] ?? array();
|
||||
$decisions = $dossier['decisions'] ?? array();
|
||||
$log_index = $dossier['gen_log_index'] ?? array();
|
||||
$verdict = $dossier['verdict'] ?? '';
|
||||
$tier = $dossier['tier'] ?? '';
|
||||
$run_id = $dossier['run_id'] ?? '';
|
||||
|
||||
$log_query = new PRAutoBlogger_Dossier_Gen_Log_Query();
|
||||
$stage_totals = $log_query->aggregate_per_stage( $log_index );
|
||||
|
||||
$total_cost = 0.0;
|
||||
foreach ( $stage_totals as $st ) {
|
||||
$total_cost += $st['cost'];
|
||||
}
|
||||
|
||||
$verdict_labels = array(
|
||||
'approved' => __( 'Approved', 'prautoblogger' ),
|
||||
'revised' => __( 'Revised', 'prautoblogger' ),
|
||||
'rejected' => __( 'Rejected', 'prautoblogger' ),
|
||||
'halted' => __( 'Halted', 'prautoblogger' ),
|
||||
'pending' => __( 'Pending', 'prautoblogger' ),
|
||||
);
|
||||
|
||||
$back_url = admin_url( 'admin.php?page=prautoblogger-board' );
|
||||
?>
|
||||
<div class="wrap prab-dossier-wrap">
|
||||
|
||||
<?php // ── Back link ──────────────────────────────────────────────────────── ?>
|
||||
<p class="prab-dossier-back">
|
||||
<a href="<?php echo esc_url( $back_url ); ?>">
|
||||
← <?php esc_html_e( 'Back to Board', 'prautoblogger' ); ?>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<?php if ( ! $has_run ) : ?>
|
||||
<?php // ── Graceful empty state (legacy post or invalid id) ───────────────── ?>
|
||||
<div id="prab-dossier" class="prab-dossier prab-dossier--empty">
|
||||
<div class="prab-dossier-empty-notice">
|
||||
<h2><?php esc_html_e( 'No generation record', 'prautoblogger' ); ?></h2>
|
||||
<?php if ( $post_id > 0 && ! empty( $dossier['post_title'] ) ) : ?>
|
||||
<p>
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: article title */
|
||||
esc_html__( 'The article "%s" was published before the generation substrate was introduced (v0.18.0). No dossier data is available.', 'prautoblogger' ),
|
||||
esc_html( $dossier['post_title'] )
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
<?php elseif ( $post_id <= 0 ) : ?>
|
||||
<p><?php esc_html_e( 'No article was specified. Use a board card or post link to reach a dossier.', 'prautoblogger' ); ?></p>
|
||||
<?php else : ?>
|
||||
<p><?php esc_html_e( 'No run record was found for this article.', 'prautoblogger' ); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php return; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php // ── Full dossier ────────────────────────────────────────────────────── ?>
|
||||
<div id="prab-dossier" class="prab-dossier">
|
||||
|
||||
<?php // ── Article header ──────────────────────────────────────────────── ?>
|
||||
<div class="prab-dossier-header">
|
||||
<div class="prab-dossier-header-main">
|
||||
<h1 class="prab-dossier-title"><?php echo esc_html( $dossier['post_title'] ); ?></h1>
|
||||
<div class="prab-dossier-header-meta">
|
||||
<?php if ( '' !== $verdict ) : ?>
|
||||
<span class="prab-verdict-pill prab-verdict-pill--<?php echo esc_attr( $verdict ); ?>">
|
||||
<?php echo esc_html( $verdict_labels[ $verdict ] ?? ucfirst( $verdict ) ); ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
<?php if ( '' !== $tier ) : ?>
|
||||
<span class="prab-dossier-tier"><?php echo esc_html( ucfirst( $tier ) ); ?></span>
|
||||
<?php endif; ?>
|
||||
<span class="prab-dossier-status prab-dossier-status--<?php echo esc_attr( $dossier['post_status'] ?? '' ); ?>">
|
||||
<?php echo esc_html( ucfirst( $dossier['post_status'] ?? '' ) ); ?>
|
||||
</span>
|
||||
<?php // M3: run-summary audit badges -- visible, never tooltip-buried (CPO guardrail 2). ?>
|
||||
<span class="prab-chip prab-chip--human prab-chip--header" data-header-chip="human" <?php echo ! empty( $dossier['human_modified_any'] ) ? '' : 'hidden'; ?>>
|
||||
<?php esc_html_e( 'Human-modified', 'prautoblogger' ); ?>
|
||||
</span>
|
||||
<span class="prab-chip prab-chip--stale prab-chip--header" data-header-chip="stale" <?php echo ! empty( $dossier['stale_any'] ) ? '' : 'hidden'; ?>>
|
||||
<?php esc_html_e( 'Has stale stages', 'prautoblogger' ); ?>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<?php if ( $post_id > 0 ) : ?>
|
||||
<div class="prab-dossier-header-actions">
|
||||
<a href="<?php echo esc_url( get_edit_post_link( $post_id, 'raw' ) ?? '' ); ?>" class="button">
|
||||
<?php esc_html_e( 'Edit Article', 'prautoblogger' ); ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="prab-dossier-body">
|
||||
|
||||
<?php // ── Main column: stage sections ─────────────────────────────── ?>
|
||||
<div class="prab-dossier-main">
|
||||
|
||||
<?php
|
||||
$stage_order = array( 'analysis', 'llm_research', 'research', 'curate', 'outline', 'draft', 'polish', 'review', 'editorial', 'seo', 'publish', 'image_a', 'image_b' );
|
||||
$rendered_stages = array();
|
||||
|
||||
// Render stages in canonical order first, then any extra rows.
|
||||
foreach ( $stage_order as $stage_name ) {
|
||||
foreach ( $stages as $role_key => $stage_row ) {
|
||||
if ( ( $stage_row['stage'] ?? '' ) !== $stage_name ) {
|
||||
continue;
|
||||
}
|
||||
$rendered_stages[] = $role_key;
|
||||
$log_rows = $log_index[ $stage_name ] ?? array();
|
||||
require PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/dossier-stage-section.php';
|
||||
}
|
||||
}
|
||||
// Remaining unordered stages.
|
||||
foreach ( $stages as $role_key => $stage_row ) {
|
||||
if ( in_array( $role_key, $rendered_stages, true ) ) {
|
||||
continue;
|
||||
}
|
||||
$stage_name = (string) ( $stage_row['stage'] ?? 'unknown' );
|
||||
$log_rows = $log_index[ $stage_name ] ?? array();
|
||||
require PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/dossier-stage-section.php';
|
||||
}
|
||||
|
||||
// M3/F3: stages that exist only in generation_log (images,
|
||||
// llm_research, ...) render from their real data source.
|
||||
foreach ( ( $dossier['log_only_stages'] ?? array() ) as $stage_name ) {
|
||||
$log_rows = $log_index[ $stage_name ] ?? array();
|
||||
if ( empty( $log_rows ) ) {
|
||||
continue;
|
||||
}
|
||||
require PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/dossier-log-stage-section.php';
|
||||
}
|
||||
|
||||
if ( empty( $stages ) ) :
|
||||
?>
|
||||
<div class="prab-dossier-no-stages">
|
||||
<p><?php esc_html_e( 'Stage data is not yet available for this run.', 'prautoblogger' ); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php // ── Editorial decisions section ─────────────────────────────── ?>
|
||||
<?php if ( ! empty( $decisions ) ) : ?>
|
||||
<div class="prab-dossier-section prab-dossier-section--decisions">
|
||||
<h2 class="prab-section-title"><?php esc_html_e( 'Editorial Decisions', 'prautoblogger' ); ?></h2>
|
||||
<?php foreach ( $decisions as $d_stage => $d ) : ?>
|
||||
<div class="prab-decision-row">
|
||||
<span class="prab-decision-stage"><?php echo esc_html( PRAutoBlogger_Stage_Display_Map::label( $d_stage ) ); ?></span>
|
||||
<span class="prab-verdict-pill prab-verdict-pill--<?php echo esc_attr( (string) ( $d['verdict'] ?? '' ) ); ?>">
|
||||
<?php echo esc_html( ucfirst( (string) ( $d['verdict'] ?? '' ) ) ); ?>
|
||||
</span>
|
||||
<?php if ( ! empty( $d['rationale'] ) ) : ?>
|
||||
<p class="prab-decision-rationale"><?php echo esc_html( (string) $d['rationale'] ); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</div><!-- .prab-dossier-main -->
|
||||
|
||||
<?php // ── Sidebar: metadata + cost receipt ───────────────────────── ?>
|
||||
<div class="prab-dossier-sidebar">
|
||||
|
||||
<div class="prab-sidebar-card">
|
||||
<h3 class="prab-sidebar-heading"><?php esc_html_e( 'Run Info', 'prautoblogger' ); ?></h3>
|
||||
<?php if ( null !== $run ) : ?>
|
||||
<dl class="prab-sidebar-dl">
|
||||
<dt><?php esc_html_e( 'Status', 'prautoblogger' ); ?></dt>
|
||||
<dd><span class="prab-run-status prab-run-status--<?php echo esc_attr( (string) ( $run['status'] ?? '' ) ); ?>"><?php echo esc_html( ucfirst( (string) ( $run['status'] ?? '' ) ) ); ?></span></dd>
|
||||
<dt><?php esc_html_e( 'Started', 'prautoblogger' ); ?></dt>
|
||||
<dd><?php echo esc_html( (string) ( $run['started_at'] ?? '—' ) ); ?></dd>
|
||||
<?php if ( ! empty( $run['finished_at'] ) ) : ?>
|
||||
<dt><?php esc_html_e( 'Finished', 'prautoblogger' ); ?></dt>
|
||||
<dd><?php echo esc_html( (string) $run['finished_at'] ); ?></dd>
|
||||
<?php endif; ?>
|
||||
<dt><?php esc_html_e( 'Run ID', 'prautoblogger' ); ?></dt>
|
||||
<dd class="prab-run-id"><?php echo esc_html( $run_id ); ?></dd>
|
||||
</dl>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php require PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/dossier-sidebar-cards.php'; ?>
|
||||
|
||||
<div class="prab-sidebar-card prab-cost-receipt">
|
||||
<h3 class="prab-sidebar-heading"><?php esc_html_e( 'Cost Receipt', 'prautoblogger' ); ?></h3>
|
||||
<table class="prab-receipt-table">
|
||||
<tbody>
|
||||
<?php foreach ( $stage_totals as $st_name => $st ) : ?>
|
||||
<?php
|
||||
if ( $st['cost'] <= 0.0 && 0 === $st['calls'] ) :
|
||||
continue;
|
||||
endif;
|
||||
?>
|
||||
<tr>
|
||||
<td class="prab-receipt-stage"><?php echo esc_html( PRAutoBlogger_Stage_Display_Map::label( $st_name ) ); ?></td>
|
||||
<td class="prab-receipt-cost">$<?php echo esc_html( number_format( $st['cost'], 5 ) ); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="prab-receipt-total">
|
||||
<td><?php esc_html_e( 'Total', 'prautoblogger' ); ?></td>
|
||||
<td>$<?php echo esc_html( number_format( $total_cost, 5 ) ); ?></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div><!-- .prab-dossier-sidebar -->
|
||||
|
||||
</div><!-- .prab-dossier-body -->
|
||||
|
||||
</div><!-- #prab-dossier -->
|
||||
|
||||
</div><!-- .prab-dossier-wrap -->
|
||||
86
templates/admin/dossier-sidebar-cards.php
Normal file
86
templates/admin/dossier-sidebar-cards.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
/**
|
||||
* Partial: M3 sidebar cards — run spend (guardrail 4 visibility) and
|
||||
* the per-stage model/prompt-version consolidation (QA M2 F2).
|
||||
*
|
||||
* Included by dossier-page.php; inherits its scope: $dossier, $run,
|
||||
* $log_index, $stages.
|
||||
*
|
||||
* Security: all db-sourced values esc_html(); numbers via number_format.
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$sc_spend = is_array( $dossier['spend'] ?? null ) ? $dossier['spend'] : array(
|
||||
'settled' => 0.0,
|
||||
'reserved' => 0.0,
|
||||
'ceiling' => 0.0,
|
||||
'warn' => false,
|
||||
);
|
||||
$sc_held = (float) $sc_spend['settled'] + (float) $sc_spend['reserved'];
|
||||
?>
|
||||
<div class="prab-sidebar-card prab-run-spend<?php echo ! empty( $sc_spend['warn'] ) ? ' prab-run-spend--warn' : ''; ?>">
|
||||
<h3 class="prab-sidebar-heading"><?php esc_html_e( 'Run Spend', 'prautoblogger' ); ?></h3>
|
||||
<dl class="prab-sidebar-dl">
|
||||
<dt><?php esc_html_e( 'Settled', 'prautoblogger' ); ?></dt>
|
||||
<dd>$<?php echo esc_html( number_format( (float) $sc_spend['settled'], 4 ) ); ?></dd>
|
||||
<?php if ( (float) $sc_spend['reserved'] > 0 ) : ?>
|
||||
<dt><?php esc_html_e( 'Reserved', 'prautoblogger' ); ?></dt>
|
||||
<dd>$<?php echo esc_html( number_format( (float) $sc_spend['reserved'], 4 ) ); ?></dd>
|
||||
<?php endif; ?>
|
||||
<dt><?php esc_html_e( 'Ceiling', 'prautoblogger' ); ?></dt>
|
||||
<dd>
|
||||
<?php if ( (float) $sc_spend['ceiling'] > 0 ) : ?>
|
||||
$<?php echo esc_html( number_format( (float) $sc_spend['ceiling'], 2 ) ); ?>
|
||||
<?php else : ?>
|
||||
<?php esc_html_e( 'none configured', 'prautoblogger' ); ?>
|
||||
<?php endif; ?>
|
||||
</dd>
|
||||
</dl>
|
||||
<?php if ( ! empty( $sc_spend['warn'] ) ) : ?>
|
||||
<p class="prab-spend-warning">
|
||||
<?php
|
||||
printf(
|
||||
/* translators: 1: spent USD, 2: ceiling USD. */
|
||||
esc_html__( 'This run has committed $%1$s of its $%2$s ceiling — further re-runs may halt.', 'prautoblogger' ),
|
||||
esc_html( number_format( $sc_held, 4 ) ),
|
||||
esc_html( number_format( (float) $sc_spend['ceiling'], 2 ) )
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="prab-sidebar-card prab-models-card">
|
||||
<h3 class="prab-sidebar-heading"><?php esc_html_e( 'Models & Prompts', 'prautoblogger' ); ?></h3>
|
||||
<table class="prab-models-table">
|
||||
<tbody>
|
||||
<?php foreach ( $log_index as $sc_stage => $sc_rows ) : ?>
|
||||
<?php
|
||||
$sc_first = $sc_rows[0] ?? array();
|
||||
$sc_model = ! empty( $sc_first['model'] ) ? (string) $sc_first['model'] : '—';
|
||||
$sc_pv = ! empty( $sc_first['prompt_version'] ) ? (string) $sc_first['prompt_version'] : '—';
|
||||
$sc_role = ! empty( $sc_first['agent_role'] ) ? (string) $sc_first['agent_role'] : '—';
|
||||
?>
|
||||
<tr>
|
||||
<th scope="row"><?php echo esc_html( PRAutoBlogger_Stage_Display_Map::label( (string) $sc_stage ) ); ?></th>
|
||||
<td>
|
||||
<span class="prab-models-model"><?php echo esc_html( $sc_model ); ?></span>
|
||||
<span class="prab-models-meta">
|
||||
<?php
|
||||
printf(
|
||||
/* translators: 1: prompt version, 2: agent role. */
|
||||
esc_html__( 'pv %1$s · %2$s', 'prautoblogger' ),
|
||||
esc_html( $sc_pv ),
|
||||
esc_html( $sc_role )
|
||||
);
|
||||
?>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
173
templates/admin/dossier-stage-section.php
Normal file
173
templates/admin/dossier-stage-section.php
Normal file
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
/**
|
||||
* Partial: single stage section for the Article Dossier.
|
||||
*
|
||||
* Variables injected by dossier-page.php:
|
||||
* $stage_row (array) -- run_stages row incl. display_output + ['rerun'] panel data (M3)
|
||||
* $stage_name (string) -- stage key
|
||||
* $log_rows (array) -- generation_log rows for this stage
|
||||
* $dossier (array) -- page view model (spend/eligibility/post_id)
|
||||
*
|
||||
* Security: all model output escaped via esc_html() or wp_kses_post().
|
||||
* request_json escaped via esc_html() -- treat as untrusted HTML.
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$s_label = PRAutoBlogger_Stage_Display_Map::label( $stage_name );
|
||||
$s_status = (string) ( $stage_row['status'] ?? '' );
|
||||
$s_output = $stage_row['display_output'] ?? null;
|
||||
$s_cost = (float) ( $stage_row['cost_usd'] ?? 0 );
|
||||
$s_role = (string) ( $stage_row['agent_role'] ?? '' );
|
||||
$s_started = (string) ( $stage_row['started_at'] ?? '' );
|
||||
$s_finished = (string) ( $stage_row['finished_at'] ?? '' );
|
||||
$output_pruned = ( ! empty( $stage_row['status'] ) && 'done' === $stage_row['status'] && null === $s_output );
|
||||
|
||||
// Determine prompt version from gen_log (first row for this stage).
|
||||
$first_log = ! empty( $log_rows ) ? $log_rows[0] : null;
|
||||
$s_model = ( null !== $first_log && ! empty( $first_log['model'] ) ) ? $first_log['model'] : '—';
|
||||
$s_pv = ( null !== $first_log && ! empty( $first_log['prompt_version'] ) ) ? $first_log['prompt_version'] : '—';
|
||||
$has_raw_trace = ! empty( $log_rows );
|
||||
$section_id = 'prab-stage-' . esc_attr( $stage_name ) . '-' . esc_attr( $s_role );
|
||||
$rerun = is_array( $stage_row['rerun'] ?? null ) ? $stage_row['rerun'] : array();
|
||||
$is_stale = ! empty( $rerun['stale'] );
|
||||
$is_human = ! empty( $rerun['human_modified'] );
|
||||
$attempts = (int) ( $rerun['attempt'] ?? 1 );
|
||||
?>
|
||||
<div class="prab-dossier-section prab-dossier-section--stage prab-stage-status--<?php echo esc_attr( $s_status ); ?><?php echo $is_stale ? ' prab-stage--stale' : ''; ?>"
|
||||
data-stage="<?php echo esc_attr( $stage_name ); ?>"
|
||||
data-agent-role="<?php echo esc_attr( $s_role ); ?>"
|
||||
id="<?php echo $section_id; // Already escaped. ?>">
|
||||
|
||||
<div class="prab-stage-header">
|
||||
<h2 class="prab-section-title">
|
||||
<?php echo esc_html( $s_label ); ?>
|
||||
<?php if ( '' !== $s_role ) : ?>
|
||||
<span class="prab-stage-role">(<?php echo esc_html( $s_role ); ?>)</span>
|
||||
<?php endif; ?>
|
||||
</h2>
|
||||
<div class="prab-stage-header-meta">
|
||||
<span class="prab-stage-status-pill prab-stage-status-pill--<?php echo esc_attr( $s_status ); ?>">
|
||||
<?php echo esc_html( ucfirst( $s_status ) ); ?>
|
||||
</span>
|
||||
<?php // M3 audit chips -- visible, never tooltip-buried (CPO guardrails 2+3). ?>
|
||||
<span class="prab-chip prab-chip--stale" data-chip="stale" <?php echo $is_stale ? '' : 'hidden'; ?>>
|
||||
<?php esc_html_e( 'Stale — upstream changed', 'prautoblogger' ); ?>
|
||||
</span>
|
||||
<span class="prab-chip prab-chip--human" data-chip="human" <?php echo $is_human ? '' : 'hidden'; ?>>
|
||||
<?php esc_html_e( 'Human-edited input', 'prautoblogger' ); ?>
|
||||
</span>
|
||||
<?php if ( $attempts > 1 ) : ?>
|
||||
<span class="prab-chip prab-chip--attempt">
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %d: attempt count. */
|
||||
esc_html__( 'Attempt %d', 'prautoblogger' ),
|
||||
(int) $attempts
|
||||
);
|
||||
?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
<?php if ( $s_cost > 0 ) : ?>
|
||||
<span class="prab-stage-cost">$<?php echo esc_html( number_format( $s_cost, 5 ) ); ?></span>
|
||||
<?php endif; ?>
|
||||
<span class="prab-stage-model"><?php echo esc_html( $s_model ); ?></span>
|
||||
<?php if ( '—' !== $s_pv ) : ?>
|
||||
<span class="prab-stage-pv">pv <?php echo esc_html( $s_pv ); ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php // ── Output ──────────────────────────────────────────────────────── ?>
|
||||
<div class="prab-stage-output">
|
||||
<?php if ( $output_pruned ) : ?>
|
||||
<p class="prab-output-pruned">
|
||||
<?php
|
||||
$days = (int) get_option( 'prautoblogger_request_json_retention_days', 14 );
|
||||
printf(
|
||||
/* translators: %d: retention days */
|
||||
esc_html__( 'Output retained for %d days — currently unavailable (pruned by retention cron).', 'prautoblogger' ),
|
||||
$days
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
<?php elseif ( null !== $s_output ) : ?>
|
||||
<div class="prab-stage-output-rendered">
|
||||
<?php echo wp_kses_post( $s_output ); ?>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<p class="prab-output-absent"><?php esc_html_e( 'No output recorded for this stage.', 'prautoblogger' ); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php // ── Raw trace toggle ─────────────────────────────────────────────── ?>
|
||||
<?php if ( $has_raw_trace ) : ?>
|
||||
<div class="prab-stage-trace">
|
||||
<button type="button" class="prab-trace-toggle button-link"
|
||||
aria-expanded="false"
|
||||
aria-controls="<?php echo $section_id; ?>-trace">
|
||||
<?php esc_html_e( 'Show raw trace', 'prautoblogger' ); ?>
|
||||
</button>
|
||||
<div id="<?php echo $section_id; ?>-trace" class="prab-trace-detail" hidden>
|
||||
<?php foreach ( $log_rows as $log_row ) : ?>
|
||||
<div class="prab-trace-entry">
|
||||
<div class="prab-trace-meta">
|
||||
<span><?php echo esc_html( (string) ( $log_row['model'] ?? '—' ) ); ?></span>
|
||||
<span><?php printf( esc_html__( '%1$d prompt + %2$d completion tokens', 'prautoblogger' ), (int) ( $log_row['prompt_tokens'] ?? 0 ), (int) ( $log_row['completion_tokens'] ?? 0 ) ); ?></span>
|
||||
<span>$<?php echo esc_html( number_format( (float) ( $log_row['estimated_cost'] ?? 0 ), 6 ) ); ?></span>
|
||||
<span class="prab-trace-status prab-trace-status--<?php echo esc_attr( (string) ( $log_row['response_status'] ?? '' ) ); ?>">
|
||||
<?php echo esc_html( (string) ( $log_row['response_status'] ?? '' ) ); ?>
|
||||
</span>
|
||||
</div>
|
||||
<?php if ( ! empty( $log_row['request_json'] ) ) : ?>
|
||||
<details class="prab-trace-request">
|
||||
<summary><?php esc_html_e( 'Request payload', 'prautoblogger' ); ?></summary>
|
||||
<?php // SECURITY: esc_html() treats model output as untrusted HTML. ?>
|
||||
<pre class="prab-trace-pre"><?php echo esc_html( (string) $log_row['request_json'] ); ?></pre>
|
||||
</details>
|
||||
<?php endif; ?>
|
||||
<?php if ( ! empty( $log_row['error_message'] ) ) : ?>
|
||||
<p class="prab-trace-error"><?php echo esc_html( (string) $log_row['error_message'] ); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ( '' !== $s_started ) : ?>
|
||||
<div class="prab-stage-timestamps">
|
||||
<span class="prab-ts"><?php echo esc_html( $s_started ); ?></span>
|
||||
<?php if ( '' !== $s_finished ) : ?>
|
||||
<span class="prab-ts-sep">→</span>
|
||||
<span class="prab-ts"><?php echo esc_html( $s_finished ); ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php // ── M3: edit + re-run affordances (chained-cron; nothing is synchronous) ── ?>
|
||||
<div class="prab-stage-rerun-footer">
|
||||
<?php if ( ! empty( $rerun['editable'] ) ) : ?>
|
||||
<button type="button" class="button prab-edit-toggle"
|
||||
aria-expanded="false" aria-controls="<?php echo $section_id; // Already escaped. ?>-edit">
|
||||
<?php esc_html_e( 'Edit input', 'prautoblogger' ); ?>
|
||||
</button>
|
||||
<?php elseif ( ! empty( $rerun['edit_reason'] ) ) : ?>
|
||||
<span class="prab-edit-unavailable"><?php echo esc_html( (string) $rerun['edit_reason'] ); ?></span>
|
||||
<?php endif; ?>
|
||||
<?php if ( ! empty( $rerun['rerun_from'] ) ) : ?>
|
||||
<button type="button" class="button prab-rerun-from<?php echo $is_stale ? ' prab-rerun-from--stale' : ''; ?>"
|
||||
data-stage="<?php echo esc_attr( $stage_name ); ?>">
|
||||
<?php esc_html_e( 'Re-run from here', 'prautoblogger' ); ?>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php
|
||||
if ( ! empty( $rerun['editable'] ) ) {
|
||||
require PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/dossier-edit-panel.php';
|
||||
}
|
||||
?>
|
||||
|
||||
</div><!-- .prab-dossier-section--stage -->
|
||||
@@ -72,10 +72,11 @@ $base_url = admin_url( 'admin.php?page=prautoblogger-ideas' );
|
||||
<tbody>
|
||||
<?php foreach ( $rows as $row ) : ?>
|
||||
<?php
|
||||
$meta = json_decode( $row['metadata_json'] ?? '{}', true ) ?: [];
|
||||
$decoded = json_decode( $row['metadata_json'] ?? '{}', true );
|
||||
$meta = is_array( $decoded ) ? $decoded : array();
|
||||
$suggested = $meta['suggested_title'] ?? '';
|
||||
$key_points = $meta['key_points'] ?? [];
|
||||
$keywords = $meta['target_keywords'] ?? [];
|
||||
$key_points = $meta['key_points'] ?? array();
|
||||
$keywords = $meta['target_keywords'] ?? array();
|
||||
$score_pct = round( (float) $row['relevance_score'] * 100 );
|
||||
$type_label = ucfirst( $row['analysis_type'] ?? 'unknown' );
|
||||
$type_class = 'prab-type-' . sanitize_html_class( $row['analysis_type'] );
|
||||
@@ -164,14 +165,18 @@ $base_url = admin_url( 'admin.php?page=prautoblogger-ideas' );
|
||||
<?php if ( $total_pages > 1 ) : ?>
|
||||
<div class="prautoblogger-pagination" style="margin-top: 12px;">
|
||||
<?php
|
||||
echo wp_kses_post( paginate_links( [
|
||||
'base' => add_query_arg( 'paged', '%#%' ),
|
||||
'format' => '',
|
||||
'current' => $paged,
|
||||
'total' => $total_pages,
|
||||
'prev_text' => '«',
|
||||
'next_text' => '»',
|
||||
] ) );
|
||||
echo wp_kses_post(
|
||||
paginate_links(
|
||||
array(
|
||||
'base' => add_query_arg( 'paged', '%#%' ),
|
||||
'format' => '',
|
||||
'current' => $paged,
|
||||
'total' => $total_pages,
|
||||
'prev_text' => '«',
|
||||
'next_text' => '»',
|
||||
)
|
||||
)
|
||||
);
|
||||
?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -18,13 +18,13 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
}
|
||||
|
||||
$base_url = admin_url( 'admin.php?page=prautoblogger-logs' );
|
||||
$level_counts = [
|
||||
$level_counts = array(
|
||||
'all' => __( 'All', 'prautoblogger' ),
|
||||
'error' => __( 'Errors', 'prautoblogger' ),
|
||||
'warning' => __( 'Warnings', 'prautoblogger' ),
|
||||
'info' => __( 'Info', 'prautoblogger' ),
|
||||
'debug' => __( 'Debug', 'prautoblogger' ),
|
||||
];
|
||||
);
|
||||
?>
|
||||
<div class="wrap ab-wrap">
|
||||
<div class="ab-header">
|
||||
@@ -46,7 +46,20 @@ $level_counts = [
|
||||
<div class="ab-log-filters">
|
||||
<div class="ab-log-level-tabs">
|
||||
<?php foreach ( $level_counts as $lv => $lv_label ) : ?>
|
||||
<a href="<?php echo esc_url( add_query_arg( [ 'level' => $lv, 's' => $search, 'paged' => 1 ], $base_url ) ); ?>"
|
||||
<a href="
|
||||
<?php
|
||||
echo esc_url(
|
||||
add_query_arg(
|
||||
array(
|
||||
'level' => $lv,
|
||||
's' => $search,
|
||||
'paged' => 1,
|
||||
),
|
||||
$base_url
|
||||
)
|
||||
);
|
||||
?>
|
||||
"
|
||||
class="ab-log-level-tab <?php echo $level === $lv ? 'ab-log-level-active' : ''; ?>">
|
||||
<?php echo esc_html( $lv_label ); ?>
|
||||
</a>
|
||||
@@ -119,14 +132,18 @@ $level_counts = [
|
||||
<?php if ( $total_pages > 1 ) : ?>
|
||||
<div class="ab-log-pagination">
|
||||
<?php
|
||||
echo wp_kses_post( paginate_links( [
|
||||
'base' => add_query_arg( 'paged', '%#%' ),
|
||||
'format' => '',
|
||||
'current' => $paged,
|
||||
'total' => $total_pages,
|
||||
'prev_text' => '«',
|
||||
'next_text' => '»',
|
||||
] ) );
|
||||
echo wp_kses_post(
|
||||
paginate_links(
|
||||
array(
|
||||
'base' => add_query_arg( 'paged', '%#%' ),
|
||||
'format' => '',
|
||||
'current' => $paged,
|
||||
'total' => $total_pages,
|
||||
'prev_text' => '«',
|
||||
'next_text' => '»',
|
||||
)
|
||||
)
|
||||
);
|
||||
?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
41
templates/admin/metabox-dossier-link.php
Normal file
41
templates/admin/metabox-dossier-link.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
/**
|
||||
* Post metabox template (slimmed, M2): "View generation dossier" link.
|
||||
*
|
||||
* The full generation record is available on the Article Dossier page.
|
||||
* This metabox replaces the previous inline metadata display to avoid
|
||||
* duplicating data that lives in the dossier.
|
||||
*
|
||||
* @see admin/class-post-metabox.php -- Renders this template.
|
||||
* @see admin/class-dossier-page.php -- Dossier target page.
|
||||
*
|
||||
* @var WP_Post $post The current post.
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$run_id = (string) get_post_meta( $post->ID, '_prautoblogger_run_id', true );
|
||||
$url = PRAutoBlogger_Dossier_Page::url_for_post( $post->ID );
|
||||
?>
|
||||
<div class="prab-metabox-dossier">
|
||||
<?php if ( '' !== $run_id ) : ?>
|
||||
<p>
|
||||
<a href="<?php echo esc_url( $url ); ?>" class="button button-secondary">
|
||||
<?php esc_html_e( 'View generation dossier', 'prautoblogger' ); ?> →
|
||||
</a>
|
||||
</p>
|
||||
<p class="description prab-run-id-label">
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: run UUID */
|
||||
esc_html__( 'Run: %s', 'prautoblogger' ),
|
||||
esc_html( $run_id )
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
<?php else : ?>
|
||||
<p class="description"><?php esc_html_e( 'No generation record — article was not created by PRAutoBlogger.', 'prautoblogger' ); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -11,7 +11,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$meta = [
|
||||
$meta = array(
|
||||
'topic' => get_post_meta( $post->ID, '_prautoblogger_topic', true ),
|
||||
'type' => get_post_meta( $post->ID, '_prautoblogger_article_type', true ),
|
||||
'model' => get_post_meta( $post->ID, '_prautoblogger_model_used', true ),
|
||||
@@ -22,24 +22,24 @@ $meta = [
|
||||
'seo_score' => get_post_meta( $post->ID, '_prautoblogger_seo_score', true ),
|
||||
'generated_at' => get_post_meta( $post->ID, '_prautoblogger_generated_at', true ),
|
||||
'keywords' => get_post_meta( $post->ID, '_prautoblogger_target_keywords', true ),
|
||||
];
|
||||
);
|
||||
?>
|
||||
<div class="prautoblogger-metabox">
|
||||
<p>
|
||||
<strong><?php esc_html_e( 'Generated:', 'prautoblogger' ); ?></strong>
|
||||
<?php echo esc_html( $meta['generated_at'] ?: __( 'Unknown', 'prautoblogger' ) ); ?>
|
||||
<?php echo esc_html( $meta['generated_at'] ? $meta['generated_at'] : __( 'Unknown', 'prautoblogger' ) ); ?>
|
||||
</p>
|
||||
<p>
|
||||
<strong><?php esc_html_e( 'Topic:', 'prautoblogger' ); ?></strong>
|
||||
<?php echo esc_html( $meta['topic'] ?: '—' ); ?>
|
||||
<?php echo esc_html( $meta['topic'] ? $meta['topic'] : '—' ); ?>
|
||||
</p>
|
||||
<p>
|
||||
<strong><?php esc_html_e( 'Type:', 'prautoblogger' ); ?></strong>
|
||||
<?php echo esc_html( ucfirst( $meta['type'] ?: 'article' ) ); ?>
|
||||
<?php echo esc_html( ucfirst( $meta['type'] ? $meta['type'] : 'article' ) ); ?>
|
||||
</p>
|
||||
<p>
|
||||
<strong><?php esc_html_e( 'Model:', 'prautoblogger' ); ?></strong>
|
||||
<?php echo esc_html( $meta['model'] ?: '—' ); ?>
|
||||
<?php echo esc_html( $meta['model'] ? $meta['model'] : '—' ); ?>
|
||||
</p>
|
||||
<p>
|
||||
<strong><?php esc_html_e( 'Pipeline:', 'prautoblogger' ); ?></strong>
|
||||
@@ -47,8 +47,8 @@ $meta = [
|
||||
</p>
|
||||
<p>
|
||||
<strong><?php esc_html_e( 'Editor Verdict:', 'prautoblogger' ); ?></strong>
|
||||
<span class="prautoblogger-verdict-<?php echo esc_attr( $meta['verdict'] ?: 'unknown' ); ?>">
|
||||
<?php echo esc_html( ucfirst( $meta['verdict'] ?: __( 'Unknown', 'prautoblogger' ) ) ); ?>
|
||||
<span class="prautoblogger-verdict-<?php echo esc_attr( $meta['verdict'] ? $meta['verdict'] : 'unknown' ); ?>">
|
||||
<?php echo esc_html( ucfirst( $meta['verdict'] ? $meta['verdict'] : __( 'Unknown', 'prautoblogger' ) ) ); ?>
|
||||
</span>
|
||||
</p>
|
||||
<?php if ( $meta['quality_score'] ) : ?>
|
||||
@@ -69,7 +69,7 @@ $meta = [
|
||||
<?php if ( $meta['keywords'] ) : ?>
|
||||
<p>
|
||||
<strong><?php esc_html_e( 'Keywords:', 'prautoblogger' ); ?></strong>
|
||||
<?php echo esc_html( implode( ', ', json_decode( $meta['keywords'], true ) ?: [] ) ); ?>
|
||||
<?php echo esc_html( implode( ', ', ( json_decode( $meta['keywords'], true ) ? json_decode( $meta['keywords'], true ) : array() ) ) ); ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
@@ -103,6 +103,18 @@ $bulk_type = isset( $_GET['prautoblogger_bulk_type'] ) ? sanitize_text_field( wp
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
// v0.20.0: stale-stage warning chips (one batched query; CPO
|
||||
// product AC -- the stale flag is visible at publish time).
|
||||
$rq_post_runs = array();
|
||||
foreach ( $query->posts as $rq_post ) {
|
||||
$rq_run_id = (string) get_post_meta( $rq_post->ID, '_prautoblogger_run_id', true );
|
||||
if ( '' !== $rq_run_id ) {
|
||||
$rq_post_runs[ $rq_post->ID ] = $rq_run_id;
|
||||
}
|
||||
}
|
||||
$rq_stale_runs = array_flip( PRAutoBlogger_Run_Stage_State::runs_with_stale_stages( array_values( $rq_post_runs ) ) );
|
||||
?>
|
||||
<table class="widefat striped prautoblogger-queue-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -129,7 +141,7 @@ $bulk_type = isset( $_GET['prautoblogger_bulk_type'] ) ? sanitize_text_field( wp
|
||||
$quality_score = get_post_meta( $post_id, '_prautoblogger_quality_score', true );
|
||||
$generated_at = get_post_meta( $post_id, '_prautoblogger_generated_at', true );
|
||||
$editor_notes = get_post_meta( $post_id, '_prautoblogger_editor_notes', true );
|
||||
$verdict_class = 'prautoblogger-verdict-' . sanitize_html_class( $verdict ?: 'pending' );
|
||||
$verdict_class = 'prautoblogger-verdict-' . sanitize_html_class( $verdict ? $verdict : 'pending' );
|
||||
?>
|
||||
<tr data-post-id="<?php echo esc_attr( (string) $post_id ); ?>">
|
||||
<td class="check-column">
|
||||
@@ -137,13 +149,18 @@ $bulk_type = isset( $_GET['prautoblogger_bulk_type'] ) ? sanitize_text_field( wp
|
||||
</td>
|
||||
<td>
|
||||
<strong><?php the_title(); ?></strong>
|
||||
<?php if ( isset( $rq_post_runs[ $post_id ], $rq_stale_runs[ $rq_post_runs[ $post_id ] ] ) ) : ?>
|
||||
<a class="prab-review-stale-chip" href="<?php echo esc_url( PRAutoBlogger_Dossier_Page::url_for_post( (int) $post_id ) ); ?>">
|
||||
<?php esc_html_e( 'Stale stages -- review dossier', 'prautoblogger' ); ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<?php if ( $editor_notes ) : ?>
|
||||
<div class="prautoblogger-editor-notes"><?php echo esc_html( $editor_notes ); ?></div>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?php echo esc_html( $topic ?: '—' ); ?></td>
|
||||
<td><?php echo esc_html( ucfirst( $article_type ?: 'article' ) ); ?></td>
|
||||
<td><span class="<?php echo esc_attr( $verdict_class ); ?>"><?php echo esc_html( ucfirst( $verdict ?: 'pending' ) ); ?></span></td>
|
||||
<td><?php echo esc_html( $topic ? $topic : '—' ); ?></td>
|
||||
<td><?php echo esc_html( ucfirst( $article_type ? $article_type : 'article' ) ); ?></td>
|
||||
<td><span class="<?php echo esc_attr( $verdict_class ); ?>"><?php echo esc_html( ucfirst( $verdict ? $verdict : 'pending' ) ); ?></span></td>
|
||||
<td><?php echo '' !== $quality_score ? esc_html( $quality_score . '/10' ) : '—'; ?></td>
|
||||
<td><?php echo $generated_at ? esc_html( wp_date( 'M j, g:i A', strtotime( $generated_at ) ) ) : '—'; ?></td>
|
||||
<td class="prautoblogger-queue-actions">
|
||||
|
||||
@@ -103,12 +103,17 @@ if ( ! defined( 'OBJECT' ) ) {
|
||||
/**
|
||||
* Minimal WP_Query stub for unit tests.
|
||||
*
|
||||
* IdeaScorer uses WP_Query to check for existing posts.
|
||||
* This stub returns empty results by default.
|
||||
* IdeaScorer uses WP_Query to check for existing posts with empty results.
|
||||
* Board data provider tests inject posts via WP_Query::$_test_posts_queue
|
||||
* (static FIFO): push arrays of WP_Post objects before instantiating the
|
||||
* provider, and each new WP_Query() call will dequeue one batch.
|
||||
*/
|
||||
if ( ! class_exists( 'WP_Query' ) ) {
|
||||
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound
|
||||
class WP_Query {
|
||||
/** @var array<int, array> FIFO queue of post-arrays for tests. */
|
||||
public static $_test_posts_queue = [];
|
||||
|
||||
/** @var array */
|
||||
public $posts = [];
|
||||
/** @var int */
|
||||
@@ -117,7 +122,11 @@ if ( ! class_exists( 'WP_Query' ) ) {
|
||||
public $have_posts = false;
|
||||
|
||||
public function __construct( $args = [] ) {
|
||||
// No-op for testing — returns no posts.
|
||||
if ( ! empty( self::$_test_posts_queue ) ) {
|
||||
$this->posts = array_shift( self::$_test_posts_queue );
|
||||
$this->found_posts = count( $this->posts );
|
||||
$this->have_posts = $this->found_posts > 0;
|
||||
}
|
||||
}
|
||||
|
||||
public function have_posts(): bool {
|
||||
@@ -165,3 +174,52 @@ if ( ! class_exists( 'WP_Error' ) ) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal WP_Post stub for unit tests.
|
||||
*
|
||||
* Board data provider tests return WP_Post instances from WP_Query.
|
||||
* Class defined here so instanceof checks in production code work correctly.
|
||||
*/
|
||||
if ( ! class_exists( 'WP_Post' ) ) {
|
||||
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound
|
||||
class WP_Post {
|
||||
/** @var int */
|
||||
public $ID = 0;
|
||||
/** @var string */
|
||||
public $post_title = '';
|
||||
/** @var string */
|
||||
public $post_status = 'publish';
|
||||
/** @var string */
|
||||
public $post_date_gmt = '';
|
||||
/** @var string */
|
||||
public $post_modified_gmt = '';
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function __construct( array $data = [] ) {
|
||||
foreach ( $data as $key => $value ) {
|
||||
$this->$key = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin URL and version constants used by admin enqueue methods.
|
||||
// Defined here so all tests that load admin classes can stub WP enqueue calls
|
||||
// without hitting "Undefined constant" errors.
|
||||
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT' ) ) {
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT', 20 );
|
||||
}
|
||||
|
||||
// Plugin URL and version constants used by admin enqueue methods.
|
||||
if ( ! defined( 'PRAUTOBLOGGER_PLUGIN_URL' ) ) {
|
||||
define( 'PRAUTOBLOGGER_PLUGIN_URL', 'https://test.example.com/wp-content/plugins/prautoblogger/' );
|
||||
}
|
||||
if ( ! defined( 'PRAUTOBLOGGER_VERSION' ) ) {
|
||||
define( 'PRAUTOBLOGGER_VERSION', '0.19.3-test' );
|
||||
}
|
||||
if ( ! defined( 'PRAUTOBLOGGER_PLUGIN_DIR' ) ) {
|
||||
define( 'PRAUTOBLOGGER_PLUGIN_DIR', dirname( __DIR__ ) . '/' );
|
||||
}
|
||||
|
||||
300
tests/unit/Admin/BoardDataProviderTest.php
Normal file
300
tests/unit/Admin/BoardDataProviderTest.php
Normal file
@@ -0,0 +1,300 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Board_Data_Provider — column/state bucket mapping.
|
||||
*
|
||||
* Seeds mock DB results and WordPress post data, then asserts that each
|
||||
* board column receives the correct cards. No live DB required; uses Brain\Monkey
|
||||
* and a mock $wpdb.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Admin
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Admin;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class BoardDataProviderTest extends BaseTestCase {
|
||||
|
||||
/** @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb. */
|
||||
private $wpdb;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->wpdb = $this->create_mock_wpdb();
|
||||
$GLOBALS['wpdb'] = $this->wpdb;
|
||||
|
||||
// Reset WP_Query test queue before each test.
|
||||
\WP_Query::$_test_posts_queue = [];
|
||||
|
||||
Functions\when( 'get_option' )->alias(
|
||||
function ( string $name, $default = false ) {
|
||||
$map = array(
|
||||
'prautoblogger_board_published_window_days' => 7,
|
||||
'prautoblogger_board_poll_interval' => 5,
|
||||
);
|
||||
return $map[ $name ] ?? $default;
|
||||
}
|
||||
);
|
||||
|
||||
// Transient stub: by default no active run.
|
||||
Functions\when( 'get_transient' )->justReturn( false );
|
||||
|
||||
// Admin URL stub.
|
||||
Functions\when( 'admin_url' )->alias(
|
||||
function ( string $path = '' ) {
|
||||
return 'https://test.example.com/wp-admin/' . ltrim( $path, '/' );
|
||||
}
|
||||
);
|
||||
|
||||
// DAY_IN_SECONDS constant.
|
||||
if ( ! defined( 'DAY_IN_SECONDS' ) ) {
|
||||
define( 'DAY_IN_SECONDS', 86400 );
|
||||
}
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
\WP_Query::$_test_posts_queue = [];
|
||||
unset( $GLOBALS['wpdb'] );
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ── Generating cards ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A running transient with status='running' yields a Generating card.
|
||||
*/
|
||||
public function test_generating_cards_from_transient(): void {
|
||||
Functions\when( 'get_transient' )->alias(
|
||||
function ( $key ) {
|
||||
if ( 'prautoblogger_generation_status' === $key ) {
|
||||
return array(
|
||||
'status' => 'running',
|
||||
'stage' => 'Drafting article…',
|
||||
'started' => time() - 60,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
// No gen_log secondary runs needed.
|
||||
$this->wpdb->method( 'prepare' )->willReturn( 'SELECT 1' );
|
||||
$this->wpdb->method( 'get_results' )->willReturn( array() );
|
||||
|
||||
$provider = new \PRAutoBlogger_Board_Data_Provider();
|
||||
$cards = $provider->get_generating_cards();
|
||||
|
||||
$this->assertCount( 1, $cards );
|
||||
$this->assertSame( 'Drafting article…', $cards[0]['stage_current'] );
|
||||
$this->assertSame( 'logs', $cards[0]['click_action'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* When the transient is idle, orphan gen_log rows produce Generating cards.
|
||||
*/
|
||||
public function test_generating_cards_from_gen_log(): void {
|
||||
Functions\when( 'get_transient' )->justReturn( false );
|
||||
|
||||
$this->wpdb->method( 'prepare' )->willReturn( 'SELECT 1' );
|
||||
$this->wpdb->method( 'get_results' )->willReturn(
|
||||
array(
|
||||
array(
|
||||
'run_id' => 'abc-123',
|
||||
'started_at' => gmdate( 'Y-m-d H:i:s', time() - 120 ),
|
||||
'last_stage' => 'draft',
|
||||
'stage_count' => '3',
|
||||
'cost_total' => '0.005000',
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
$provider = new \PRAutoBlogger_Board_Data_Provider();
|
||||
$cards = $provider->get_generating_cards();
|
||||
|
||||
$this->assertCount( 1, $cards );
|
||||
$this->assertSame( 'abc-123', $cards[0]['run_id'] );
|
||||
$this->assertSame( 'draft', $cards[0]['stage_current'] );
|
||||
$this->assertEqualsWithDelta( 0.005, $cards[0]['cost_total'], 0.0001 );
|
||||
}
|
||||
|
||||
/**
|
||||
* An idle transient + empty gen_log yields zero Generating cards.
|
||||
*/
|
||||
public function test_no_generating_cards_when_idle(): void {
|
||||
Functions\when( 'get_transient' )->justReturn( false );
|
||||
|
||||
$this->wpdb->method( 'prepare' )->willReturn( 'SELECT 1' );
|
||||
$this->wpdb->method( 'get_results' )->willReturn( array() );
|
||||
|
||||
$provider = new \PRAutoBlogger_Board_Data_Provider();
|
||||
$cards = $provider->get_generating_cards();
|
||||
|
||||
$this->assertCount( 0, $cards );
|
||||
}
|
||||
|
||||
// ── In Review cards ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Draft posts with the _prautoblogger_generated meta appear in In Review.
|
||||
*/
|
||||
public function test_in_review_cards_from_draft_posts(): void {
|
||||
$post = new \WP_Post();
|
||||
$post->ID = 42;
|
||||
$post->post_title = 'Test Draft Post';
|
||||
$post->post_date_gmt = '2026-06-12 10:00:00';
|
||||
$post->post_status = 'draft';
|
||||
|
||||
// Inject post into WP_Query queue: first WP_Query() call returns this post.
|
||||
\WP_Query::$_test_posts_queue[] = array( $post );
|
||||
|
||||
Functions\when( 'get_post_meta' )->alias(
|
||||
function ( $post_id, $key, $single ) use ( $post ) {
|
||||
if ( $post_id === $post->ID ) {
|
||||
if ( '_prautoblogger_run_id' === $key ) {
|
||||
return 'run-draft-999';
|
||||
}
|
||||
if ( '_prautoblogger_total_cost' === $key ) {
|
||||
return '0.012000';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
);
|
||||
|
||||
Functions\when( 'get_edit_post_link' )->justReturn(
|
||||
'https://test.example.com/wp-admin/post.php?post=42&action=edit'
|
||||
);
|
||||
|
||||
$provider = new \PRAutoBlogger_Board_Data_Provider();
|
||||
$cards = $provider->get_in_review_cards();
|
||||
|
||||
$this->assertCount( 1, $cards );
|
||||
$this->assertSame( 42, $cards[0]['post_id'] );
|
||||
$this->assertSame( 'Test Draft Post', $cards[0]['title'] );
|
||||
// M2: primary click_action is dossier; secondary review_url/edit_url still present.
|
||||
$this->assertSame( 'dossier', $cards[0]['click_action'] );
|
||||
$this->assertStringContainsString( 'prautoblogger-dossier&post_id=42', $cards[0]['dossier_url'] );
|
||||
$this->assertArrayHasKey( 'review_url', $cards[0] );
|
||||
$this->assertArrayHasKey( 'edit_url', $cards[0] );
|
||||
$this->assertEqualsWithDelta( 0.012, $cards[0]['cost_total'], 0.0001 );
|
||||
}
|
||||
|
||||
// ── Published cards ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Published posts within the window appear in Published.
|
||||
*/
|
||||
public function test_published_cards_within_window(): void {
|
||||
$post = new \WP_Post();
|
||||
$post->ID = 77;
|
||||
$post->post_title = 'Published Article';
|
||||
$post->post_date_gmt = gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS );
|
||||
$post->post_status = 'publish';
|
||||
|
||||
// Inject post into WP_Query queue: first WP_Query() call returns this post.
|
||||
\WP_Query::$_test_posts_queue[] = array( $post );
|
||||
|
||||
Functions\when( 'get_post_meta' )->alias(
|
||||
function ( $post_id, $key, $single ) use ( $post ) {
|
||||
if ( $post_id === $post->ID ) {
|
||||
if ( '_prautoblogger_run_id' === $key ) {
|
||||
return 'run-pub-77';
|
||||
}
|
||||
if ( '_prautoblogger_total_cost' === $key ) {
|
||||
return '0.020000';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
);
|
||||
|
||||
Functions\when( 'get_edit_post_link' )->justReturn(
|
||||
'https://test.example.com/wp-admin/post.php?post=77&action=edit'
|
||||
);
|
||||
Functions\when( 'get_permalink' )->justReturn( 'https://test.example.com/?p=77' );
|
||||
|
||||
$provider = new \PRAutoBlogger_Board_Data_Provider();
|
||||
$cards = $provider->get_published_cards();
|
||||
|
||||
$this->assertCount( 1, $cards );
|
||||
$this->assertSame( 77, $cards[0]['post_id'] );
|
||||
// M2: primary click_action is dossier; secondary edit_url/post_url still present.
|
||||
$this->assertSame( 'dossier', $cards[0]['click_action'] );
|
||||
$this->assertStringContainsString( 'prautoblogger-dossier&post_id=77', $cards[0]['dossier_url'] );
|
||||
$this->assertArrayHasKey( 'edit_url', $cards[0] );
|
||||
$this->assertArrayHasKey( 'post_url', $cards[0] );
|
||||
}
|
||||
|
||||
// ── Failed cards ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Gen_log error rows with no post produce Failed cards.
|
||||
*/
|
||||
public function test_failed_cards_from_error_log(): void {
|
||||
$this->wpdb->method( 'prepare' )->willReturn( 'SELECT 1' );
|
||||
$this->wpdb->method( 'get_results' )->willReturn(
|
||||
array(
|
||||
array(
|
||||
'run_id' => 'fail-run-001',
|
||||
'started_at' => gmdate( 'Y-m-d H:i:s', time() - 3600 ),
|
||||
'last_error' => 'OpenRouter rate limit exceeded',
|
||||
'last_stage' => 'draft',
|
||||
'call_count' => '2',
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
$provider = new \PRAutoBlogger_Board_Data_Provider();
|
||||
$cards = $provider->get_failed_cards();
|
||||
|
||||
$this->assertCount( 1, $cards );
|
||||
$this->assertSame( 'fail-run-001', $cards[0]['run_id'] );
|
||||
$this->assertStringContainsString( 'rate limit', $cards[0]['error_message'] );
|
||||
$this->assertSame( 'logs', $cards[0]['click_action'] );
|
||||
}
|
||||
|
||||
// ── Full snapshot ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* get_board_snapshot() has_active_runs = true when Generating is non-empty.
|
||||
*/
|
||||
public function test_snapshot_has_active_runs_flag(): void {
|
||||
Functions\when( 'get_transient' )->alias(
|
||||
function ( $key ) {
|
||||
if ( 'prautoblogger_generation_status' === $key ) {
|
||||
return array(
|
||||
'status' => 'running',
|
||||
'stage' => 'Writing…',
|
||||
'started' => time(),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
$this->wpdb->method( 'prepare' )->willReturn( 'SELECT 1' );
|
||||
$this->wpdb->method( 'get_results' )->willReturn( array() );
|
||||
|
||||
$provider = new \PRAutoBlogger_Board_Data_Provider();
|
||||
$snapshot = $provider->get_board_snapshot();
|
||||
|
||||
$this->assertTrue( $snapshot['has_active_runs'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* get_board_snapshot() has_active_runs = false when Generating is empty.
|
||||
*/
|
||||
public function test_snapshot_no_active_runs_when_idle(): void {
|
||||
Functions\when( 'get_transient' )->justReturn( false );
|
||||
|
||||
$this->wpdb->method( 'prepare' )->willReturn( 'SELECT 1' );
|
||||
$this->wpdb->method( 'get_results' )->willReturn( array() );
|
||||
|
||||
$provider = new \PRAutoBlogger_Board_Data_Provider();
|
||||
$snapshot = $provider->get_board_snapshot();
|
||||
|
||||
$this->assertFalse( $snapshot['has_active_runs'] );
|
||||
}
|
||||
}
|
||||
239
tests/unit/Admin/BoardGenLogQueryTest.php
Normal file
239
tests/unit/Admin/BoardGenLogQueryTest.php
Normal file
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Board_Gen_Log_Query — binding order and result shape.
|
||||
*
|
||||
* The key regression: both get_generating_cards() and get_failed_cards() had
|
||||
* (a) a missing comma between bound args and (b) reversed argument order — the
|
||||
* integer limit was passed first, the datetime string second, which bound
|
||||
* created_at >= <int> and LIMIT <datetime>. These tests assert the correct
|
||||
* binding order (string first, int second) so any future regression is caught
|
||||
* before CI.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Admin
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Admin;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class BoardGenLogQueryTest extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* Captured prepare() calls: each entry is ['sql' => string, 'args' => array].
|
||||
*
|
||||
* @var array<int, array{sql: string, args: array}>
|
||||
*/
|
||||
private array $prepare_calls = array();
|
||||
|
||||
/**
|
||||
* Build a fresh mock $wpdb whose get_results() always returns $rows.
|
||||
*
|
||||
* @param array $rows Rows to return from get_results().
|
||||
* @return \PHPUnit\Framework\MockObject\MockObject
|
||||
*/
|
||||
private function make_wpdb( array $rows = array() ): object {
|
||||
$wpdb = $this->create_mock_wpdb();
|
||||
$wpdb->prefix = 'wp_';
|
||||
$GLOBALS['wpdb'] = $wpdb;
|
||||
$self = $this;
|
||||
|
||||
$wpdb->method( 'prepare' )->willReturnCallback(
|
||||
static function ( string $sql, ...$args ) use ( $self ) {
|
||||
if ( 1 === count( $args ) && is_array( $args[0] ) ) {
|
||||
$args = $args[0];
|
||||
}
|
||||
$self->prepare_calls[] = array( 'sql' => $sql, 'args' => $args );
|
||||
return 'PREPARED_SQL';
|
||||
}
|
||||
);
|
||||
|
||||
$wpdb->method( 'get_results' )->willReturn( $rows );
|
||||
return $wpdb;
|
||||
}
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->prepare_calls = array();
|
||||
|
||||
Functions\when( 'get_transient' )->justReturn( false );
|
||||
Functions\when( 'admin_url' )->alias(
|
||||
function ( string $path = '' ) {
|
||||
return 'https://test.example.com/wp-admin/' . ltrim( $path, '/' );
|
||||
}
|
||||
);
|
||||
Functions\when( 'get_option' )->alias(
|
||||
function ( string $name, $default = false ) {
|
||||
if ( 'prautoblogger_board_column_limit' === $name ) {
|
||||
return 10;
|
||||
}
|
||||
return $default;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
$this->prepare_calls = array();
|
||||
unset( $GLOBALS['wpdb'] );
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ── get_generating_cards() binding order ────────────────────────────────
|
||||
|
||||
/**
|
||||
* get_generating_cards() must pass $since (string) as the FIRST bound arg
|
||||
* and the integer limit as the SECOND — matching placeholder order %s … %d.
|
||||
*
|
||||
* This would have caught the reversed-order bug (limit int passed first,
|
||||
* datetime string second) that silently bound created_at >= <int>.
|
||||
*/
|
||||
public function test_generating_cards_prepare_arg_order(): void {
|
||||
$this->make_wpdb();
|
||||
$query = new \PRAutoBlogger_Board_Gen_Log_Query();
|
||||
$query->get_generating_cards();
|
||||
|
||||
// Find the prepare() call containing LIMIT %d (the gen-log secondary query).
|
||||
$gen_call = null;
|
||||
foreach ( $this->prepare_calls as $call ) {
|
||||
if (
|
||||
str_contains( $call['sql'], 'LIMIT %d' ) &&
|
||||
str_contains( $call['sql'], 'post_id IS NULL' ) &&
|
||||
! str_contains( $call['sql'], "response_status = 'error'" )
|
||||
) {
|
||||
$gen_call = $call;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertNotNull( $gen_call, 'get_generating_cards() must call $wpdb->prepare() with LIMIT %d' );
|
||||
$this->assertCount( 2, $gen_call['args'], 'prepare() must receive exactly 2 bound args' );
|
||||
|
||||
// First arg must be a string (the $since datetime).
|
||||
$this->assertIsString( $gen_call['args'][0], 'First arg to prepare() must be the $since string (for %s)' );
|
||||
// Second arg must be an integer (the column limit).
|
||||
$this->assertIsInt( $gen_call['args'][1], 'Second arg to prepare() must be the integer limit (for %d)' );
|
||||
}
|
||||
|
||||
/**
|
||||
* get_generating_cards() must return an empty array (not error) when DB returns nothing.
|
||||
*/
|
||||
public function test_generating_cards_returns_empty_array_on_no_results(): void {
|
||||
$this->make_wpdb();
|
||||
$query = new \PRAutoBlogger_Board_Gen_Log_Query();
|
||||
$cards = $query->get_generating_cards();
|
||||
$this->assertIsArray( $cards );
|
||||
}
|
||||
|
||||
/**
|
||||
* When the status transient signals a running job, get_generating_cards()
|
||||
* returns a card from the transient (no DB call needed for that card).
|
||||
*/
|
||||
public function test_generating_cards_returns_transient_card_when_running(): void {
|
||||
$this->make_wpdb();
|
||||
|
||||
Functions\when( 'get_transient' )->alias(
|
||||
function ( $key ) {
|
||||
if ( 'prautoblogger_generation_status' === $key ) {
|
||||
return array(
|
||||
'status' => 'running',
|
||||
'stage' => 'Drafting…',
|
||||
'started' => time() - 30,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
$query = new \PRAutoBlogger_Board_Gen_Log_Query();
|
||||
$cards = $query->get_generating_cards();
|
||||
|
||||
$this->assertNotEmpty( $cards, 'Transient running state must produce at least one card' );
|
||||
$this->assertSame( '', $cards[0]['run_id'], 'Transient card has empty run_id' );
|
||||
$this->assertSame( 'logs', $cards[0]['click_action'] );
|
||||
}
|
||||
|
||||
// ── get_failed_cards() binding order ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* get_failed_cards() must pass $since (string) as the FIRST bound arg
|
||||
* and the integer limit as the SECOND — matching placeholder order %s … %d.
|
||||
*/
|
||||
public function test_failed_cards_prepare_arg_order(): void {
|
||||
$this->make_wpdb();
|
||||
$query = new \PRAutoBlogger_Board_Gen_Log_Query();
|
||||
$query->get_failed_cards();
|
||||
|
||||
// Find the prepare() call for the failed query.
|
||||
$fail_call = null;
|
||||
foreach ( $this->prepare_calls as $call ) {
|
||||
if ( str_contains( $call['sql'], "response_status = 'error'" ) ) {
|
||||
$fail_call = $call;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertNotNull( $fail_call, 'get_failed_cards() must call $wpdb->prepare() with response_status filter' );
|
||||
$this->assertCount( 2, $fail_call['args'], 'prepare() must receive exactly 2 bound args' );
|
||||
|
||||
// First arg must be a string (the $since datetime).
|
||||
$this->assertIsString( $fail_call['args'][0], 'First arg to prepare() must be the $since string (for %s)' );
|
||||
// Second arg must be an integer (the column limit).
|
||||
$this->assertIsInt( $fail_call['args'][1], 'Second arg to prepare() must be the integer limit (for %d)' );
|
||||
}
|
||||
|
||||
/**
|
||||
* get_failed_cards() returns the correct card shape from a seeded result row.
|
||||
*/
|
||||
public function test_failed_cards_returns_correct_shape(): void {
|
||||
// Use a fresh wpdb with non-empty get_results rows.
|
||||
$this->make_wpdb(
|
||||
array(
|
||||
array(
|
||||
'run_id' => 'fail-run-abc',
|
||||
'started_at' => gmdate( 'Y-m-d H:i:s', time() - 3600 ),
|
||||
'last_error' => 'Rate limit exceeded',
|
||||
'last_stage' => 'draft',
|
||||
'call_count' => '3',
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
$query = new \PRAutoBlogger_Board_Gen_Log_Query();
|
||||
$cards = $query->get_failed_cards();
|
||||
|
||||
$this->assertCount( 1, $cards );
|
||||
$this->assertSame( 'fail-run-abc', $cards[0]['run_id'] );
|
||||
$this->assertStringContainsString( 'Rate limit', $cards[0]['error_message'] );
|
||||
$this->assertSame( 'logs', $cards[0]['click_action'] );
|
||||
$this->assertArrayHasKey( 'started_at', $cards[0] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Column-limit option is honoured: limit 7 → second arg to prepare() is 7.
|
||||
*/
|
||||
public function test_column_limit_option_is_respected(): void {
|
||||
Functions\when( 'get_option' )->alias(
|
||||
function ( string $name, $default = false ) {
|
||||
if ( 'prautoblogger_board_column_limit' === $name ) {
|
||||
return 7;
|
||||
}
|
||||
return $default;
|
||||
}
|
||||
);
|
||||
|
||||
$this->make_wpdb();
|
||||
$query = new \PRAutoBlogger_Board_Gen_Log_Query();
|
||||
$query->get_generating_cards();
|
||||
|
||||
$gen_call = null;
|
||||
foreach ( $this->prepare_calls as $call ) {
|
||||
if ( str_contains( $call['sql'], 'LIMIT %d' ) && ! str_contains( $call['sql'], "response_status = 'error'" ) ) {
|
||||
$gen_call = $call;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertNotNull( $gen_call );
|
||||
$this->assertSame( 7, $gen_call['args'][1], 'Limit arg must match the option value' );
|
||||
}
|
||||
}
|
||||
129
tests/unit/Admin/BoardHumanModifiedFlagTest.php
Normal file
129
tests/unit/Admin/BoardHumanModifiedFlagTest.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for the v0.20.0 board-card human_modified flag (CPO product AC:
|
||||
* human-modified runs are visually distinct at the run-list level).
|
||||
*
|
||||
* Locks: ONE batched query flags all cards (no N+1), unflagged runs and
|
||||
* missing-table sites degrade to false.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Admin
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Admin;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class BoardHumanModifiedFlagTest extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb.
|
||||
*/
|
||||
private $wpdb;
|
||||
|
||||
/**
|
||||
* Number of get_col calls (N+1 guard).
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private int $col_queries = 0;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->col_queries = 0;
|
||||
$this->wpdb = $this->create_mock_wpdb();
|
||||
$GLOBALS['wpdb'] = $this->wpdb;
|
||||
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
||||
static function ( $sql, ...$args ) {
|
||||
if ( 1 === count( $args ) && is_array( $args[0] ) ) {
|
||||
$args = $args[0];
|
||||
}
|
||||
return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */';
|
||||
}
|
||||
);
|
||||
Functions\when( 'get_post_meta' )->alias(
|
||||
static function ( $post_id, $key ) {
|
||||
if ( '_prautoblogger_run_id' === $key ) {
|
||||
return 'run-' . $post_id;
|
||||
}
|
||||
if ( '_prautoblogger_total_cost' === $key ) {
|
||||
return '0.05';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
);
|
||||
Functions\when( 'get_edit_post_link' )->justReturn( 'https://example.com/edit' );
|
||||
Functions\when( 'admin_url' )->alias( static fn( $p = '' ) => 'https://example.com/wp-admin/' . $p );
|
||||
Functions\when( 'get_option' )->alias(
|
||||
function ( string $name, $default = false ) {
|
||||
$map = array(
|
||||
'prautoblogger_board_column_limit' => PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT,
|
||||
'prautoblogger_board_published_window_days' => 7,
|
||||
);
|
||||
return $map[ $name ] ?? $default;
|
||||
}
|
||||
);
|
||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
\WP_Query::$_test_posts_queue = array();
|
||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||
unset( $GLOBALS['wpdb'] );
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/** Build a WP_Post for the queue. */
|
||||
private function make_post( int $id ): \WP_Post {
|
||||
$post = new \WP_Post();
|
||||
$post->ID = $id;
|
||||
$post->post_title = 'Post ' . $id;
|
||||
$post->post_date_gmt = '2026-06-12 00:00:00';
|
||||
return $post;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cards whose runs carry human_modified rows get the flag; others
|
||||
* stay false; exactly ONE batched query runs for the whole column.
|
||||
*/
|
||||
public function test_cards_flagged_in_one_batched_query(): void {
|
||||
\WP_Query::$_test_posts_queue[] = array( $this->make_post( 1 ), $this->make_post( 2 ), $this->make_post( 3 ) );
|
||||
|
||||
$this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_run_stages' );
|
||||
$this->wpdb->method( 'get_col' )->willReturnCallback(
|
||||
function ( $sql ) {
|
||||
++$this->col_queries;
|
||||
$this->assertStringContainsString( 'human_modified = 1', (string) $sql );
|
||||
return array( 'run-2' ); // Only post 2's run was edited.
|
||||
}
|
||||
);
|
||||
|
||||
$cards = ( new \PRAutoBlogger_Board_Data_Provider() )->get_in_review_cards();
|
||||
|
||||
$this->assertCount( 3, $cards );
|
||||
$this->assertFalse( $cards[0]['human_modified'] );
|
||||
$this->assertTrue( $cards[1]['human_modified'] );
|
||||
$this->assertFalse( $cards[2]['human_modified'] );
|
||||
$this->assertSame( 1, $this->col_queries, 'one batched query, never per-card' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Missing substrate table: flags default false, zero flag queries.
|
||||
*/
|
||||
public function test_missing_table_degrades_to_false(): void {
|
||||
\WP_Query::$_test_posts_queue[] = array( $this->make_post( 1 ) );
|
||||
|
||||
$this->wpdb->method( 'get_var' )->willReturn( null );
|
||||
$this->wpdb->method( 'get_col' )->willReturnCallback(
|
||||
function () {
|
||||
++$this->col_queries;
|
||||
return array();
|
||||
}
|
||||
);
|
||||
|
||||
$cards = ( new \PRAutoBlogger_Board_Data_Provider() )->get_in_review_cards();
|
||||
|
||||
$this->assertFalse( $cards[0]['human_modified'] );
|
||||
$this->assertSame( 0, $this->col_queries );
|
||||
}
|
||||
}
|
||||
266
tests/unit/Admin/BoardMenuRegistrationTest.php
Normal file
266
tests/unit/Admin/BoardMenuRegistrationTest.php
Normal file
@@ -0,0 +1,266 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for Board submenu registration order and hook-suffix correctness.
|
||||
*
|
||||
* Root-cause regression guard for the v0.19.1 hotfix:
|
||||
* Board submenu was registered at admin_menu priority 10 BEFORE the parent
|
||||
* top-level menu page (also priority 10), causing WordPress to record the
|
||||
* render callback under the fallback hookname `admin_page_prautoblogger-board`
|
||||
* instead of the correct `prautoblogger_page_prautoblogger-board`.
|
||||
* At request time WP recomputes the hookname and finds nothing -> wp_die 404.
|
||||
*
|
||||
* ## Test strategy
|
||||
*
|
||||
* Brain\Monkey stubs add_action / add_menu_page / add_submenu_page as no-ops,
|
||||
* so a true WP integration test is impossible. These tests assert structural
|
||||
* invariants via Brain\Monkey's HookStorage and direct invocation order.
|
||||
*
|
||||
* ### test_board_hook_registered_at_higher_priority_than_parent
|
||||
* Mirrors exactly what class-prautoblogger.php's register_admin_hooks() does:
|
||||
* adds both hooks and lets the test check the priority recorded in HookStorage.
|
||||
* - FAILS on origin/main (both add_action calls at default priority 10).
|
||||
* - PASSES after fix (board at priority 11, parent at priority 10).
|
||||
* This test is the true regression guard: revert to both-at-10 = test fails.
|
||||
*
|
||||
* ### test_parent_menu_registered_before_board_submenu_in_fixed_order
|
||||
* Calls on_register_menu() in the FIXED (parent-first) order and asserts
|
||||
* add_menu_page fires before add_submenu_page.
|
||||
*
|
||||
* ### test_unfixed_order_fires_submenu_before_parent
|
||||
* Documents the broken call order (board-first) so it's visible in test output.
|
||||
*
|
||||
* ### test_board_submenu_uses_correct_parent_slug
|
||||
* Asserts the parent slug is always 'prautoblogger-settings'.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Admin
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Admin;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class BoardMenuRegistrationTest extends BaseTestCase {
|
||||
|
||||
/** @var array<int, string> Call sequence spy for menu registration order. */
|
||||
private array $call_sequence = [];
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->call_sequence = [];
|
||||
|
||||
// Spy stubs for WordPress menu registration functions.
|
||||
Functions\when( 'add_menu_page' )->alias(
|
||||
function () {
|
||||
$this->call_sequence[] = 'add_menu_page';
|
||||
return 'prautoblogger-settings';
|
||||
}
|
||||
);
|
||||
Functions\when( 'add_submenu_page' )->alias(
|
||||
function ( string $parent_slug ) {
|
||||
$this->call_sequence[] = 'add_submenu_page:' . $parent_slug;
|
||||
return 'prautoblogger_page_prautoblogger-board';
|
||||
}
|
||||
);
|
||||
|
||||
Functions\when( 'current_user_can' )->justReturn( true );
|
||||
Functions\when( 'get_option' )->justReturn( false );
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
$this->call_sequence = [];
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Priority relationship (regression guard)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Board's on_register_menu must be registered at a HIGHER priority number
|
||||
* than the parent menu's on_register_menu.
|
||||
*
|
||||
* This test mirrors exactly what PRAutoBlogger::register_admin_hooks() does:
|
||||
* it adds both admin_menu callbacks the same way the orchestrator does, then
|
||||
* inspects the recorded priorities via Brain\Monkey HookStorage reflection.
|
||||
*
|
||||
* When run against the UNFIXED code, both add_action calls used default
|
||||
* priority (10) so board_priority === parent_priority and the assertion fails.
|
||||
* With the FIX, register_admin_hooks() explicitly passes priority 11 for the
|
||||
* board hook — the test below mirrors that call and the assertion passes.
|
||||
*
|
||||
* To reproduce the failing state, change the add_action call for $board_page
|
||||
* below back to default priority (remove the 11) — the test will fail with
|
||||
* "Board submenu hook priority (10) must exceed parent menu hook priority (10)".
|
||||
*
|
||||
* Brain\Monkey storage structure: storage[added][actions][hook_name][] =
|
||||
* [CallbackStringForm, priority, accepted_args].
|
||||
* CallbackStringForm::__toString() = "ClassName->method()".
|
||||
*
|
||||
* FAILS on unfixed code (both hooks at default priority 10).
|
||||
* PASSES after fix (parent at 10, board at 11).
|
||||
*/
|
||||
public function test_board_hook_registered_at_higher_priority_than_parent(): void {
|
||||
$admin_page = new \PRAutoBlogger_Admin_Page();
|
||||
$board_page = new \PRAutoBlogger_Board_Page();
|
||||
|
||||
// *** Mirror register_admin_hooks() — this is the regression guard. ***
|
||||
// UNFIXED origin/main used no explicit priority (both default to 10).
|
||||
// FIXED uses 10 for parent and 11 for board. Update this when the fix changes.
|
||||
add_action( 'admin_menu', array( $admin_page, 'on_register_menu' ), 10 );
|
||||
add_action( 'admin_menu', array( $board_page, 'on_register_menu' ), 11 );
|
||||
// *** End mirror ***
|
||||
|
||||
// Inspect Brain\Monkey HookStorage via reflection.
|
||||
$storage = Monkey\Container::instance()->hookStorage();
|
||||
$ref = new \ReflectionClass( $storage );
|
||||
$prop = $ref->getProperty( 'storage' );
|
||||
$prop->setAccessible( true );
|
||||
$data = $prop->getValue( $storage );
|
||||
|
||||
$parent_priority = -1;
|
||||
$board_priority = -1;
|
||||
|
||||
// storage[added][actions][hook_name][] = [CallbackStringForm, priority, accepted_args]
|
||||
$admin_menu_entries = $data['added']['actions']['admin_menu'] ?? array();
|
||||
foreach ( $admin_menu_entries as $entry ) {
|
||||
$cb_string = (string) ( $entry[0] ?? '' );
|
||||
$priority = (int) ( $entry[1] ?? -1 );
|
||||
if ( false !== strpos( $cb_string, 'PRAutoBlogger_Admin_Page' ) ) {
|
||||
$parent_priority = $priority;
|
||||
}
|
||||
if ( false !== strpos( $cb_string, 'PRAutoBlogger_Board_Page' ) ) {
|
||||
$board_priority = $priority;
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertGreaterThan(
|
||||
-1,
|
||||
$parent_priority,
|
||||
'PRAutoBlogger_Admin_Page::on_register_menu must be registered on admin_menu'
|
||||
);
|
||||
$this->assertGreaterThan(
|
||||
-1,
|
||||
$board_priority,
|
||||
'PRAutoBlogger_Board_Page::on_register_menu must be registered on admin_menu'
|
||||
);
|
||||
|
||||
// THE KEY ASSERTION: board fires AFTER parent (higher priority number).
|
||||
$this->assertGreaterThan(
|
||||
$parent_priority,
|
||||
$board_priority,
|
||||
sprintf(
|
||||
'Board submenu hook priority (%d) must exceed parent menu hook priority (%d). '
|
||||
. 'Both at 10 is the root cause of the 404: add_submenu_page fires before '
|
||||
. 'add_menu_page, WP falls back to admin_page_* hookname, request-time '
|
||||
. 'recompute finds nothing, wp_die 404.',
|
||||
$board_priority,
|
||||
$parent_priority
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Execution order: on_register_menu calls
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* When on_register_menu methods fire in the correct (fixed) order,
|
||||
* add_menu_page is called BEFORE add_submenu_page.
|
||||
*/
|
||||
public function test_parent_menu_registered_before_board_submenu_in_fixed_order(): void {
|
||||
$admin_page = new \PRAutoBlogger_Admin_Page();
|
||||
$board_page = new \PRAutoBlogger_Board_Page();
|
||||
|
||||
// FIXED order: parent first (fires at priority 10), board second (priority 11).
|
||||
$admin_page->on_register_menu();
|
||||
$board_page->on_register_menu();
|
||||
|
||||
$add_menu_idx = null;
|
||||
$add_submenu_idx = null;
|
||||
foreach ( $this->call_sequence as $idx => $entry ) {
|
||||
if ( 'add_menu_page' === $entry && null === $add_menu_idx ) {
|
||||
$add_menu_idx = $idx;
|
||||
}
|
||||
if ( str_starts_with( $entry, 'add_submenu_page:' ) && null === $add_submenu_idx ) {
|
||||
$add_submenu_idx = $idx;
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertNotNull( $add_menu_idx, 'add_menu_page (parent) must be called' );
|
||||
$this->assertNotNull( $add_submenu_idx, 'add_submenu_page (board) must be called' );
|
||||
|
||||
$this->assertLessThan(
|
||||
$add_submenu_idx,
|
||||
$add_menu_idx,
|
||||
sprintf(
|
||||
'add_menu_page must be called BEFORE add_submenu_page. '
|
||||
. 'Call sequence: [%s].',
|
||||
implode( ', ', $this->call_sequence )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Documents the broken call order (root-cause proof): when board fires before
|
||||
* parent, add_submenu_page fires before add_menu_page, which causes WP to use
|
||||
* the fallback `admin_page_*` hookname -> 404.
|
||||
*/
|
||||
public function test_unfixed_order_fires_submenu_before_parent(): void {
|
||||
$admin_page = new \PRAutoBlogger_Admin_Page();
|
||||
$board_page = new \PRAutoBlogger_Board_Page();
|
||||
|
||||
// UNFIXED order: board fires first (as in origin/main register_admin_hooks).
|
||||
$board_page->on_register_menu();
|
||||
$admin_page->on_register_menu();
|
||||
|
||||
$add_menu_idx = null;
|
||||
$add_submenu_idx = null;
|
||||
foreach ( $this->call_sequence as $idx => $entry ) {
|
||||
if ( 'add_menu_page' === $entry && null === $add_menu_idx ) {
|
||||
$add_menu_idx = $idx;
|
||||
}
|
||||
if ( str_starts_with( $entry, 'add_submenu_page:' ) && null === $add_submenu_idx ) {
|
||||
$add_submenu_idx = $idx;
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertNotNull( $add_menu_idx, 'add_menu_page (parent) must be called' );
|
||||
$this->assertNotNull( $add_submenu_idx, 'add_submenu_page (board) must be called' );
|
||||
|
||||
// In the broken order, add_submenu_page has a lower index (fires first).
|
||||
$this->assertLessThan(
|
||||
$add_menu_idx,
|
||||
$add_submenu_idx,
|
||||
'Documents the broken state: add_submenu_page fires before add_menu_page when '
|
||||
. 'board is invoked first. WP registers under admin_page_* -> 404.'
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Parent slug correctness
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Board submenu is always registered under 'prautoblogger-settings'.
|
||||
*/
|
||||
public function test_board_submenu_uses_correct_parent_slug(): void {
|
||||
$board_page = new \PRAutoBlogger_Board_Page();
|
||||
$board_page->on_register_menu();
|
||||
|
||||
$board_entry = null;
|
||||
foreach ( $this->call_sequence as $entry ) {
|
||||
if ( str_starts_with( $entry, 'add_submenu_page:' ) ) {
|
||||
$board_entry = $entry;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame(
|
||||
'add_submenu_page:prautoblogger-settings',
|
||||
$board_entry,
|
||||
'Board submenu must use parent slug "prautoblogger-settings"'
|
||||
);
|
||||
}
|
||||
}
|
||||
103
tests/unit/Admin/DossierActionsRerunTest.php
Normal file
103
tests/unit/Admin/DossierActionsRerunTest.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
/**
|
||||
* Dossier_Actions: rerun + status endpoint tests (v0.20.0, M3).
|
||||
*
|
||||
* Locks the chained-cron contract (queue only — ZERO state mutations in
|
||||
* the AJAX layer) and the read-only stage-status poll shape.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Admin
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Admin;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class DossierActionsRerunTest extends DossierActionsTestCase {
|
||||
|
||||
/**
|
||||
* Chained-cron contract: rerun_stage only schedules — no run/stage
|
||||
* state is mutated by the AJAX layer, and the response says queued.
|
||||
*/
|
||||
public function test_rerun_stage_queues_without_any_state_mutation(): void {
|
||||
$this->wire_eligible_context();
|
||||
// A saved fork exists for the replay check.
|
||||
$wpdb_get_row_fork = array(
|
||||
'version' => '1',
|
||||
'request_json' => '{"model":"m","messages":[{"role":"user","content":"edited"}]}',
|
||||
);
|
||||
// Rebuild get_row: forks resolve, run row terminal.
|
||||
$_POST = array(
|
||||
'post_id' => '99',
|
||||
'stage' => 'draft',
|
||||
'agent_role' => 'writer',
|
||||
);
|
||||
// Replace the get_row wiring (fork now exists).
|
||||
$wpdb = $this->create_mock_wpdb();
|
||||
$wpdb->options = 'wp_options';
|
||||
$wpdb->method( 'prepare' )->willReturnCallback(
|
||||
static function ( $sql, ...$args ) {
|
||||
if ( 1 === count( $args ) && is_array( $args[0] ) ) {
|
||||
$args = $args[0];
|
||||
}
|
||||
return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */';
|
||||
}
|
||||
);
|
||||
$wpdb->method( 'get_var' )->willReturnCallback(
|
||||
static function ( $sql ) {
|
||||
if ( false !== strpos( (string) $sql, 'SHOW TABLES' ) ) {
|
||||
return ( false !== strpos( (string) $sql, 'stage_inputs' ) ) ? 'wp_prautoblogger_stage_inputs' : 'wp_prautoblogger_runs';
|
||||
}
|
||||
return null; // Lock free.
|
||||
}
|
||||
);
|
||||
$wpdb->method( 'get_row' )->willReturnCallback(
|
||||
static function ( $sql ) use ( $wpdb_get_row_fork ) {
|
||||
if ( false !== strpos( (string) $sql, 'stage_inputs' ) ) {
|
||||
return $wpdb_get_row_fork;
|
||||
}
|
||||
return array(
|
||||
'run_id' => 'run-1',
|
||||
'status' => 'done',
|
||||
);
|
||||
}
|
||||
);
|
||||
$wpdb->method( 'query' )->willReturnCallback(
|
||||
function ( $sql ) {
|
||||
$this->queries[] = (string) $sql;
|
||||
return 1;
|
||||
}
|
||||
);
|
||||
$GLOBALS['wpdb'] = $wpdb;
|
||||
\PRAutoBlogger_Run_State::flush_cache();
|
||||
\PRAutoBlogger_Stage_Input_Store::flush_cache();
|
||||
|
||||
$actions = new \PRAutoBlogger_Dossier_Actions();
|
||||
$this->dispatch( array( $actions, 'on_rerun_stage' ) );
|
||||
|
||||
$this->assertTrue( $this->json['ok'] );
|
||||
$this->assertTrue( $this->json['data']['queued'] );
|
||||
$this->assertCount( 1, $this->scheduled );
|
||||
$this->assertSame( \PRAutoBlogger_Rerun_Executor::REPLAY_ACTION, $this->scheduled[0]['hook'] );
|
||||
$this->assertSame( array( 'run-1', 99, 'draft', 'writer', 'idea:abc123' ), array_slice( $this->scheduled[0]['args'], 0, 5 ) );
|
||||
foreach ( $this->queries as $sql ) {
|
||||
$this->assertStringNotContainsString( 'UPDATE', $sql, 'the AJAX layer must not mutate state' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* stage_status: read-only poll returns run status + per-stage state
|
||||
* with stale/human_modified flags.
|
||||
*/
|
||||
public function test_stage_status_returns_stage_states(): void {
|
||||
$this->wire_eligible_context();
|
||||
$this->wpdb->method( 'get_results' )->willReturn( array() );
|
||||
$_POST = array( 'post_id' => '99' );
|
||||
|
||||
$actions = new \PRAutoBlogger_Dossier_Actions();
|
||||
$this->dispatch( array( $actions, 'on_stage_status' ) );
|
||||
|
||||
$this->assertTrue( $this->json['ok'] );
|
||||
$this->assertArrayHasKey( 'run_status', $this->json['data'] );
|
||||
$this->assertArrayHasKey( 'stages', $this->json['data'] );
|
||||
}
|
||||
}
|
||||
87
tests/unit/Admin/DossierActionsSaveInputTest.php
Normal file
87
tests/unit/Admin/DossierActionsSaveInputTest.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
/**
|
||||
* Dossier_Actions: save_input endpoint tests (v0.20.0, M3).
|
||||
*
|
||||
* Locks: published-post rejection (guardrail 5 server-side), fork
|
||||
* structure validation (count/roles locked, contents non-empty), and
|
||||
* the merged-fork save (model/options from the base, never the client).
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Admin
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Admin;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class DossierActionsSaveInputTest extends DossierActionsTestCase {
|
||||
|
||||
/**
|
||||
* Frozen post: save_input rejects server-side with 400 and inserts
|
||||
* nothing (guardrail 5 is not just hidden UI).
|
||||
*/
|
||||
public function test_save_input_rejects_published_post(): void {
|
||||
$this->wire_eligible_context( 'publish' );
|
||||
$_POST = array(
|
||||
'post_id' => '99',
|
||||
'stage' => 'draft',
|
||||
'agent_role' => 'writer',
|
||||
'messages' => '[]',
|
||||
);
|
||||
|
||||
$actions = new \PRAutoBlogger_Dossier_Actions();
|
||||
$this->dispatch( array( $actions, 'on_save_input' ) );
|
||||
|
||||
$this->assertFalse( $this->json['ok'] );
|
||||
$this->assertSame( 400, $this->json['status'] );
|
||||
$this->assertCount( 0, $this->inserted );
|
||||
}
|
||||
|
||||
/**
|
||||
* Structure lock: changing a message ROLE (or count, or emptying a
|
||||
* content) is rejected — forks are always replayable chat bodies.
|
||||
*/
|
||||
public function test_save_input_rejects_structure_mismatch(): void {
|
||||
$this->wire_eligible_context();
|
||||
$_POST = array(
|
||||
'post_id' => '99',
|
||||
'stage' => 'draft',
|
||||
'agent_role' => 'writer',
|
||||
// Role flipped user->assistant vs the base body.
|
||||
'messages' => '[{"role":"system","content":"sys"},{"role":"assistant","content":"edited"}]',
|
||||
);
|
||||
|
||||
$actions = new \PRAutoBlogger_Dossier_Actions();
|
||||
$this->dispatch( array( $actions, 'on_save_input' ) );
|
||||
|
||||
$this->assertFalse( $this->json['ok'] );
|
||||
$this->assertCount( 0, $this->inserted );
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid edit: fork v1 saved with the edited content merged into the
|
||||
* base body; model/options come from the base, not the client.
|
||||
*/
|
||||
public function test_save_input_saves_merged_fork(): void {
|
||||
$this->wire_eligible_context();
|
||||
$_POST = array(
|
||||
'post_id' => '99',
|
||||
'stage' => 'draft',
|
||||
'agent_role' => 'writer',
|
||||
'messages' => '[{"role":"system","content":"sys"},{"role":"user","content":"EDITED PROMPT"}]',
|
||||
);
|
||||
|
||||
$actions = new \PRAutoBlogger_Dossier_Actions();
|
||||
$this->dispatch( array( $actions, 'on_save_input' ) );
|
||||
|
||||
$this->assertTrue( $this->json['ok'] );
|
||||
$this->assertSame( 1, $this->json['data']['version'] );
|
||||
$this->assertCount( 1, $this->inserted );
|
||||
$row = $this->inserted[0];
|
||||
$this->assertSame( 'human', $row['source'] );
|
||||
$this->assertSame( 'rhys', $row['author'] );
|
||||
$this->assertSame( 'idea:abc123', $row['item_key'] );
|
||||
$body = json_decode( (string) $row['request_json'], true );
|
||||
$this->assertSame( 'EDITED PROMPT', $body['messages'][1]['content'] );
|
||||
$this->assertSame( 'm', $body['model'] ); // From the base, not the client.
|
||||
}
|
||||
}
|
||||
212
tests/unit/Admin/DossierActionsTestCase.php
Normal file
212
tests/unit/Admin/DossierActionsTestCase.php
Normal file
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
/**
|
||||
* Shared harness for the Dossier_Actions endpoint tests (v0.20.0, M3).
|
||||
*
|
||||
* Provides: mock wpdb with SQL-pattern wiring, JSON-response capture
|
||||
* (wp_send_json_* throw a sentinel), cron-schedule capture, and the
|
||||
* eligible-context fixture (draft post, terminal run, free lock).
|
||||
* Split across two test classes for the 300-line file cap (the M2
|
||||
* split-precedent applies to test files too).
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Admin
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Admin;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
abstract class DossierActionsTestCase extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb.
|
||||
*/
|
||||
protected $wpdb;
|
||||
|
||||
/**
|
||||
* Captured JSON response (success/error + payload + status).
|
||||
*
|
||||
* @var array|null
|
||||
*/
|
||||
protected $json = null;
|
||||
|
||||
/**
|
||||
* SQL captured from $wpdb->query().
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
protected array $queries = array();
|
||||
|
||||
/**
|
||||
* Rows captured from $wpdb->insert().
|
||||
*
|
||||
* @var array<int, array>
|
||||
*/
|
||||
protected array $inserted = array();
|
||||
|
||||
/**
|
||||
* Cron events captured from wp_schedule_single_event().
|
||||
*
|
||||
* @var array<int, array>
|
||||
*/
|
||||
protected array $scheduled = array();
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->json = null;
|
||||
$this->queries = array();
|
||||
$this->inserted = array();
|
||||
$this->scheduled = array();
|
||||
$_POST = array();
|
||||
|
||||
$this->wpdb = $this->create_mock_wpdb();
|
||||
$this->wpdb->options = 'wp_options';
|
||||
$GLOBALS['wpdb'] = $this->wpdb;
|
||||
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
||||
static function ( $sql, ...$args ) {
|
||||
if ( 1 === count( $args ) && is_array( $args[0] ) ) {
|
||||
$args = $args[0];
|
||||
}
|
||||
return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */';
|
||||
}
|
||||
);
|
||||
$this->wpdb->method( 'query' )->willReturnCallback(
|
||||
function ( $sql ) {
|
||||
$this->queries[] = (string) $sql;
|
||||
return 1;
|
||||
}
|
||||
);
|
||||
$this->wpdb->method( 'insert' )->willReturnCallback(
|
||||
function ( $table, $row ) {
|
||||
$this->inserted[] = (array) $row;
|
||||
return 1;
|
||||
}
|
||||
);
|
||||
|
||||
Functions\when( 'check_ajax_referer' )->justReturn( true );
|
||||
Functions\when( 'current_user_can' )->justReturn( true );
|
||||
Functions\when( 'absint' )->alias( static function ( $val ) { return abs( (int) $val ); } );
|
||||
Functions\when( 'sanitize_key' )->alias( static function ( $val ) { return preg_replace( '/[^a-z0-9_\-]/', '', strtolower( (string) $val ) ); } );
|
||||
Functions\when( 'wp_unslash' )->returnArg();
|
||||
Functions\when( 'wp_schedule_single_event' )->alias(
|
||||
function ( $ts, $hook, $args ) {
|
||||
$this->scheduled[] = array(
|
||||
'hook' => $hook,
|
||||
'args' => $args,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
Functions\when( 'spawn_cron' )->justReturn( true );
|
||||
Functions\when( 'wp_remote_post' )->justReturn( array() );
|
||||
Functions\when( 'site_url' )->justReturn( 'https://example.com' );
|
||||
Functions\when( 'wp_send_json_success' )->alias(
|
||||
function ( $data = null ) {
|
||||
$this->json = array(
|
||||
'ok' => true,
|
||||
'data' => $data,
|
||||
);
|
||||
throw new \RuntimeException( 'json_sent' );
|
||||
}
|
||||
);
|
||||
Functions\when( 'wp_send_json_error' )->alias(
|
||||
function ( $data = null, $status = 0 ) {
|
||||
$this->json = array(
|
||||
'ok' => false,
|
||||
'data' => $data,
|
||||
'status' => $status,
|
||||
);
|
||||
throw new \RuntimeException( 'json_sent' );
|
||||
}
|
||||
);
|
||||
$user = new \stdClass();
|
||||
$user->user_login = 'rhys';
|
||||
Functions\when( 'wp_get_current_user' )->justReturn( $user );
|
||||
|
||||
\PRAutoBlogger_Run_State::flush_cache();
|
||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||
\PRAutoBlogger_Stage_Input_Store::flush_cache();
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
$_POST = array();
|
||||
\PRAutoBlogger_Run_State::flush_cache();
|
||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||
\PRAutoBlogger_Stage_Input_Store::flush_cache();
|
||||
unset( $GLOBALS['wpdb'] );
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/** Run an endpoint, swallowing the wp_send_json sentinel. */
|
||||
protected function dispatch( callable $endpoint ): void {
|
||||
try {
|
||||
$endpoint();
|
||||
} catch ( \RuntimeException $e ) {
|
||||
if ( 'json_sent' !== $e->getMessage() ) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Wire post meta + a draft post + a terminal run + free lock. */
|
||||
protected function wire_eligible_context( string $post_status = 'draft', string $run_status = 'done' ): void {
|
||||
Functions\when( 'get_post_meta' )->alias(
|
||||
static function ( $post_id, $key ) {
|
||||
if ( '_prautoblogger_run_id' === $key ) {
|
||||
return 'run-1';
|
||||
}
|
||||
if ( '_prautoblogger_idea_hash' === $key ) {
|
||||
return 'abc123';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
);
|
||||
$post = new \WP_Post();
|
||||
$post->ID = 99;
|
||||
$post->post_status = $post_status;
|
||||
Functions\when( 'get_post' )->justReturn( $post );
|
||||
|
||||
$this->wpdb->method( 'get_var' )->willReturnCallback(
|
||||
static function ( $sql ) use ( $run_status ) {
|
||||
$sql = (string) $sql;
|
||||
if ( false !== strpos( $sql, 'SHOW TABLES' ) ) {
|
||||
if ( false !== strpos( $sql, 'stage_inputs' ) ) {
|
||||
return 'wp_prautoblogger_stage_inputs';
|
||||
}
|
||||
if ( false !== strpos( $sql, 'run_stages' ) ) {
|
||||
return 'wp_prautoblogger_run_stages';
|
||||
}
|
||||
return 'wp_prautoblogger_runs';
|
||||
}
|
||||
if ( false !== strpos( $sql, 'MAX(version)' ) ) {
|
||||
return null; // First fork version.
|
||||
}
|
||||
return null; // Lock free; no seed.
|
||||
}
|
||||
);
|
||||
$this->wpdb->method( 'get_row' )->willReturnCallback(
|
||||
static function ( $sql ) use ( $run_status ) {
|
||||
$sql = (string) $sql;
|
||||
if ( false !== strpos( $sql, 'stage_inputs' ) ) {
|
||||
return null; // No fork yet.
|
||||
}
|
||||
return array(
|
||||
'run_id' => 'run-1',
|
||||
'status' => $run_status,
|
||||
'ceiling_usd' => '0.50',
|
||||
);
|
||||
}
|
||||
);
|
||||
// resolve_base_body falls through to the gen_log query.
|
||||
$this->wpdb->method( 'get_results' )->willReturn(
|
||||
array(
|
||||
array(
|
||||
'id' => '11',
|
||||
'stage' => 'draft',
|
||||
'request_json' => '{"model":"m","messages":[{"role":"system","content":"sys"},{"role":"user","content":"orig"}]}',
|
||||
'response_status' => 'success',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
236
tests/unit/Admin/DossierAssemblerRerunDataTest.php
Normal file
236
tests/unit/Admin/DossierAssemblerRerunDataTest.php
Normal file
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for the v0.20.0 dossier view-model additions (M3 + F2/F3).
|
||||
*
|
||||
* Locks: item-scoped stage filtering (multi-article runs no longer
|
||||
* collide), other-post gen_log row exclusion, per-stage rerun panel
|
||||
* data (policy reasons on non-editable stages; editable writer stage
|
||||
* with recorded input), log-only stage exposure (F3) and the spend
|
||||
* strip shape (guardrail 4 visibility).
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Admin
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Admin;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class DossierAssemblerRerunDataTest extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb.
|
||||
*/
|
||||
private $wpdb;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->wpdb = $this->create_mock_wpdb();
|
||||
$this->wpdb->options = 'wp_options';
|
||||
$GLOBALS['wpdb'] = $this->wpdb;
|
||||
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
||||
static function ( $sql, ...$args ) {
|
||||
if ( 1 === count( $args ) && is_array( $args[0] ) ) {
|
||||
$args = $args[0];
|
||||
}
|
||||
return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */';
|
||||
}
|
||||
);
|
||||
Functions\when( 'get_post_thumbnail_id' )->justReturn( 0 );
|
||||
Functions\when( 'get_children' )->justReturn( array() );
|
||||
Functions\when( 'wp_get_attachment_image_url' )->justReturn( '' );
|
||||
Functions\when( 'apply_filters' )->returnArg( 2 );
|
||||
|
||||
\PRAutoBlogger_Run_State::flush_cache();
|
||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||
\PRAutoBlogger_Stage_Input_Store::flush_cache();
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
\PRAutoBlogger_Run_State::flush_cache();
|
||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||
\PRAutoBlogger_Stage_Input_Store::flush_cache();
|
||||
unset( $GLOBALS['wpdb'] );
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/** Build the standard post + meta wiring. */
|
||||
private function wire_post(): void {
|
||||
$post = new \WP_Post();
|
||||
$post->ID = 99;
|
||||
$post->post_title = 'Test Article';
|
||||
$post->post_status = 'draft';
|
||||
Functions\when( 'get_post' )->justReturn( $post );
|
||||
Functions\when( 'get_post_meta' )->alias(
|
||||
static function ( $post_id, $key ) {
|
||||
$map = array(
|
||||
'_prautoblogger_run_id' => 'run-1',
|
||||
'_prautoblogger_idea_hash' => 'abc123',
|
||||
'_prautoblogger_editor_verdict' => 'approved',
|
||||
'_prautoblogger_article_type' => 'guide',
|
||||
);
|
||||
return $map[ $key ] ?? '';
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** Wire wpdb for: tables exist, run row, stage rows, gen_log rows, lock free. */
|
||||
private function wire_data( array $stage_rows, array $log_rows ): void {
|
||||
$this->wpdb->method( 'get_var' )->willReturnCallback(
|
||||
static function ( $sql ) {
|
||||
$sql = (string) $sql;
|
||||
if ( false !== strpos( $sql, 'SHOW TABLES' ) ) {
|
||||
if ( false !== strpos( $sql, 'stage_inputs' ) ) {
|
||||
return 'wp_prautoblogger_stage_inputs';
|
||||
}
|
||||
if ( false !== strpos( $sql, 'run_stages' ) ) {
|
||||
return 'wp_prautoblogger_run_stages';
|
||||
}
|
||||
return 'wp_prautoblogger_runs';
|
||||
}
|
||||
return null; // Lock free.
|
||||
}
|
||||
);
|
||||
$this->wpdb->method( 'get_row' )->willReturnCallback(
|
||||
static function ( $sql ) {
|
||||
if ( false !== strpos( (string) $sql, 'stage_inputs' ) ) {
|
||||
return null; // No forks saved.
|
||||
}
|
||||
return array(
|
||||
'run_id' => 'run-1',
|
||||
'status' => 'done',
|
||||
'ceiling_usd' => '0.50',
|
||||
'settled_usd' => '0.30',
|
||||
'reserved_usd' => '0.15',
|
||||
);
|
||||
}
|
||||
);
|
||||
$this->wpdb->method( 'get_results' )->willReturnCallback(
|
||||
static function ( $sql ) use ( $stage_rows, $log_rows ) {
|
||||
$sql = (string) $sql;
|
||||
if ( false !== strpos( $sql, 'run_stages' ) ) {
|
||||
return $stage_rows;
|
||||
}
|
||||
if ( false !== strpos( $sql, 'generation_log' ) ) {
|
||||
return $log_rows;
|
||||
}
|
||||
return array(); // run_decisions.
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The stage query is item-scoped (idea hash -> item_key IN (item, ''))
|
||||
* and gen_log rows belonging to OTHER posts are dropped while
|
||||
* unlinked (post_id NULL/0) rows are kept.
|
||||
*/
|
||||
public function test_item_scoping_and_other_post_log_exclusion(): void {
|
||||
$this->wire_post();
|
||||
$captured_stage_sql = null;
|
||||
$this->wpdb->method( 'get_var' )->willReturnCallback(
|
||||
static function ( $sql ) {
|
||||
if ( false !== strpos( (string) $sql, 'SHOW TABLES' ) ) {
|
||||
return false !== strpos( (string) $sql, 'stage_inputs' ) ? 'wp_prautoblogger_stage_inputs'
|
||||
: ( false !== strpos( (string) $sql, 'run_stages' ) ? 'wp_prautoblogger_run_stages' : 'wp_prautoblogger_runs' );
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
$this->wpdb->method( 'get_row' )->willReturn( array( 'run_id' => 'run-1', 'status' => 'done', 'ceiling_usd' => '0' ) );
|
||||
$this->wpdb->method( 'get_results' )->willReturnCallback(
|
||||
static function ( $sql ) use ( &$captured_stage_sql ) {
|
||||
$sql = (string) $sql;
|
||||
if ( false !== strpos( $sql, 'run_stages' ) ) {
|
||||
$captured_stage_sql = $sql;
|
||||
return array();
|
||||
}
|
||||
if ( false !== strpos( $sql, 'generation_log' ) ) {
|
||||
return array(
|
||||
array( 'id' => '1', 'stage' => 'draft', 'post_id' => '99', 'model' => 'm', 'request_json' => null ),
|
||||
array( 'id' => '2', 'stage' => 'draft', 'post_id' => '77', 'model' => 'm', 'request_json' => null ),
|
||||
array( 'id' => '3', 'stage' => 'llm_research', 'post_id' => null, 'model' => 'm', 'request_json' => null ),
|
||||
);
|
||||
}
|
||||
return array();
|
||||
}
|
||||
);
|
||||
|
||||
$dossier = ( new \PRAutoBlogger_Dossier_Data_Assembler() )->assemble( 99 );
|
||||
|
||||
$this->assertSame( 'idea:abc123', $dossier['item_key'] );
|
||||
$this->assertStringContainsString( "item_key IN (%s, '')", (string) $captured_stage_sql );
|
||||
// Post 77's draft row dropped; this post's row + unlinked research row kept.
|
||||
$this->assertCount( 1, $dossier['gen_log_index']['draft'] );
|
||||
$this->assertSame( '1', $dossier['gen_log_index']['draft'][0]['id'] );
|
||||
$this->assertCount( 1, $dossier['gen_log_index']['llm_research'] );
|
||||
// F3: llm_research has no run_stages row -> exposed as log-only.
|
||||
$this->assertContains( 'llm_research', $dossier['log_only_stages'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-stage rerun data: a writer stage with a recorded request is
|
||||
* editable with prefill; review carries a policy reason instead; the
|
||||
* spend strip reflects the ledger row (guardrail 4 visibility).
|
||||
*/
|
||||
public function test_stage_rerun_panel_data_and_spend(): void {
|
||||
$this->wire_post();
|
||||
$stage_rows = array(
|
||||
array(
|
||||
'id' => '1',
|
||||
'run_id' => 'run-1',
|
||||
'stage' => 'draft',
|
||||
'agent_role' => 'writer',
|
||||
'item_key' => 'idea:abc123',
|
||||
'status' => 'done',
|
||||
'attempt' => '1',
|
||||
'cost_usd' => '0.01',
|
||||
'meta_json' => '{"output":"text"}',
|
||||
'stale' => '0',
|
||||
'human_modified' => '1',
|
||||
),
|
||||
array(
|
||||
'id' => '2',
|
||||
'run_id' => 'run-1',
|
||||
'stage' => 'review',
|
||||
'agent_role' => 'editor',
|
||||
'item_key' => 'idea:abc123',
|
||||
'status' => 'done',
|
||||
'attempt' => '1',
|
||||
'cost_usd' => '0.005',
|
||||
'meta_json' => null,
|
||||
'stale' => '1',
|
||||
),
|
||||
);
|
||||
$log_rows = array(
|
||||
array(
|
||||
'id' => '10',
|
||||
'stage' => 'draft',
|
||||
'post_id' => '99',
|
||||
'model' => 'google/gemini-2.5-flash-lite',
|
||||
'request_json' => '{"model":"google/gemini-2.5-flash-lite","messages":[{"role":"user","content":"draft prompt"}]}',
|
||||
'response_status' => 'success',
|
||||
),
|
||||
);
|
||||
$this->wire_data( $stage_rows, $log_rows );
|
||||
|
||||
$dossier = ( new \PRAutoBlogger_Dossier_Data_Assembler() )->assemble( 99 );
|
||||
|
||||
$draft = $dossier['stages']['draft:writer']['rerun'];
|
||||
$this->assertTrue( $draft['editable'] );
|
||||
$this->assertSame( 'draft prompt', $draft['prefill']['messages'][0]['content'] );
|
||||
$this->assertTrue( $draft['human_modified'] );
|
||||
$this->assertTrue( $dossier['human_modified_any'] );
|
||||
|
||||
$review = $dossier['stages']['review:editor']['rerun'];
|
||||
$this->assertFalse( $review['editable'] );
|
||||
$this->assertStringContainsString( 'Re-run from here', $review['edit_reason'] );
|
||||
$this->assertTrue( $review['stale'] );
|
||||
$this->assertTrue( $review['rerun_from'] );
|
||||
$this->assertTrue( $dossier['stale_any'] );
|
||||
|
||||
$this->assertSame( 0.30, $dossier['spend']['settled'] );
|
||||
$this->assertSame( 0.15, $dossier['spend']['reserved'] );
|
||||
$this->assertSame( 0.50, $dossier['spend']['ceiling'] );
|
||||
$this->assertTrue( $dossier['spend']['warn'] ); // 0.45/0.50 = 90% >= 80%.
|
||||
}
|
||||
}
|
||||
249
tests/unit/Admin/DossierDataAssemblerTest.php
Normal file
249
tests/unit/Admin/DossierDataAssemblerTest.php
Normal file
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Dossier_Data_Assembler -- view model assembly.
|
||||
*
|
||||
* Covers:
|
||||
* - Full modern run: runs + stages + gen_log + decisions
|
||||
* - Legacy absent: post has no run_id meta
|
||||
* - Zero post_id: bogus ID
|
||||
* - Amortized research: gen_log row with prompt_version=NULL, agent_role=''
|
||||
* - Pruned meta_json: stage row with meta_json=NULL, status=done
|
||||
* - Decisions present and absent
|
||||
*
|
||||
* All DB queries are mocked via $wpdb mock. No live DB required.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Admin
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Admin;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class DossierDataAssemblerTest extends BaseTestCase {
|
||||
|
||||
/** @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb. */
|
||||
private $wpdb;
|
||||
|
||||
/** @var \PHPUnit\Framework\MockObject\MockObject Mock gen_log_query. */
|
||||
private $log_query;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->wpdb = $this->create_mock_wpdb();
|
||||
$GLOBALS['wpdb'] = $this->wpdb;
|
||||
|
||||
// Stub PRAutoBlogger_Run_Stage_State::is_available() to return true.
|
||||
// Static method -- stubbed via wpdb table check returning the table name.
|
||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||
\PRAutoBlogger_Run_State::flush_cache();
|
||||
\PRAutoBlogger_Stage_Input_Store::flush_cache();
|
||||
|
||||
// v0.20.0 additions: image data + eligibility paths.
|
||||
Functions\when( 'get_post_thumbnail_id' )->justReturn( 0 );
|
||||
Functions\when( 'get_children' )->justReturn( array() );
|
||||
Functions\when( 'wp_get_attachment_image_url' )->justReturn( '' );
|
||||
Functions\when( 'apply_filters' )->returnArg( 2 );
|
||||
|
||||
// table_name returns wp_prautoblogger_run_stages.
|
||||
$this->wpdb->prefix = 'wp_';
|
||||
|
||||
// is_available() calls $wpdb->get_var(SHOW TABLES LIKE ...) -- return the table.
|
||||
$run_stages_table = 'wp_prautoblogger_run_stages';
|
||||
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
||||
function ( $sql ) use ( $run_stages_table ) {
|
||||
// Return something so prepare always works.
|
||||
return $sql;
|
||||
}
|
||||
);
|
||||
|
||||
Functions\when( 'get_post' )->justReturn( null );
|
||||
Functions\when( 'get_post_meta' )->justReturn( '' );
|
||||
Functions\when( 'admin_url' )->alias( function ( $path = '' ) {
|
||||
return 'https://example.com/wp-admin/' . ltrim( $path, '/' );
|
||||
} );
|
||||
|
||||
$this->log_query = $this->getMockBuilder( \PRAutoBlogger_Dossier_Gen_Log_Query::class )
|
||||
->onlyMethods( array( 'get_by_run' ) )
|
||||
->getMock();
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||
unset( $GLOBALS['wpdb'] );
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Zero / invalid post ID
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function test_zero_post_id_returns_no_run(): void {
|
||||
$assembler = new \PRAutoBlogger_Dossier_Data_Assembler( $this->log_query );
|
||||
$result = $assembler->assemble( 0 );
|
||||
|
||||
$this->assertFalse( $result['has_run'] );
|
||||
$this->assertEmpty( $result['stages'] );
|
||||
}
|
||||
|
||||
public function test_nonexistent_post_returns_no_run(): void {
|
||||
Functions\when( 'get_post' )->justReturn( null );
|
||||
|
||||
$assembler = new \PRAutoBlogger_Dossier_Data_Assembler( $this->log_query );
|
||||
$result = $assembler->assemble( 9999 );
|
||||
|
||||
$this->assertFalse( $result['has_run'] );
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Legacy post (no run_id meta)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function test_legacy_post_no_run_id_returns_no_run(): void {
|
||||
$post = new \WP_Post();
|
||||
$post->ID = 100;
|
||||
$post->post_title = 'Old Article';
|
||||
$post->post_status = 'publish';
|
||||
|
||||
Functions\when( 'get_post' )->justReturn( $post );
|
||||
Functions\when( 'get_post_meta' )->justReturn( '' );
|
||||
|
||||
$assembler = new \PRAutoBlogger_Dossier_Data_Assembler( $this->log_query );
|
||||
$result = $assembler->assemble( 100 );
|
||||
|
||||
$this->assertFalse( $result['has_run'],
|
||||
'Legacy post without run_id must return has_run=false' );
|
||||
$this->assertSame( 'Old Article', $result['post_title'] );
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Full modern run
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function test_full_run_assembles_correctly(): void {
|
||||
$post = new \WP_Post();
|
||||
$post->ID = 925;
|
||||
$post->post_title = 'BPC-157 Research Overview';
|
||||
$post->post_status = 'publish';
|
||||
|
||||
$run_id = 'abc-123-def-456';
|
||||
|
||||
Functions\when( 'get_post' )->justReturn( $post );
|
||||
Functions\when( 'get_post_meta' )->alias(
|
||||
function ( $post_id, $key, $single ) use ( $run_id ) {
|
||||
if ( '_prautoblogger_run_id' === $key ) { return $run_id; }
|
||||
if ( '_prautoblogger_editor_verdict' === $key ) { return 'approved'; }
|
||||
if ( '_prautoblogger_article_type' === $key ) { return 'authority'; }
|
||||
return '';
|
||||
}
|
||||
);
|
||||
|
||||
// Mock runs table query.
|
||||
$this->wpdb->method( 'get_row' )->willReturn( array(
|
||||
'run_id' => $run_id,
|
||||
'status' => 'done',
|
||||
'started_at' => '2026-06-12 03:00:00',
|
||||
'finished_at' => '2026-06-12 03:04:00',
|
||||
'settled_usd' => '0.012000',
|
||||
) );
|
||||
|
||||
// Mock run_stages query -- get_results returns stage rows.
|
||||
$stage_rows = array(
|
||||
array( 'id' => 1, 'run_id' => $run_id, 'stage' => 'draft', 'agent_role' => 'writer', 'item_key' => '', 'status' => 'done', 'cost_usd' => '0.008', 'meta_json' => wp_json_encode( array( 'output' => 'Draft content here.' ) ), 'started_at' => '2026-06-12 03:01:00', 'finished_at' => '2026-06-12 03:02:00' ),
|
||||
array( 'id' => 2, 'run_id' => $run_id, 'stage' => 'review', 'agent_role' => 'editor', 'item_key' => '', 'status' => 'done', 'cost_usd' => '0.004', 'meta_json' => wp_json_encode( array( 'output' => 'Looks good.' ) ), 'started_at' => '2026-06-12 03:02:00', 'finished_at' => '2026-06-12 03:03:00' ),
|
||||
);
|
||||
|
||||
// is_available check: get_var returns the table name.
|
||||
$this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_run_stages' );
|
||||
$this->wpdb->method( 'get_results' )->willReturn( $stage_rows );
|
||||
|
||||
$this->log_query->method( 'get_by_run' )->willReturn( array(
|
||||
'draft' => array( array( 'model' => 'gpt-4', 'prompt_version' => '3', 'agent_role' => 'writer', 'prompt_tokens' => 500, 'completion_tokens' => 1000, 'estimated_cost' => '0.008', 'request_json' => '{"model":"gpt-4"}', 'response_status' => 'success', 'error_message' => null, 'created_at' => '2026-06-12 03:01:00' ) ),
|
||||
'review' => array( array( 'model' => 'gpt-4', 'prompt_version' => '2', 'agent_role' => 'editor', 'prompt_tokens' => 200, 'completion_tokens' => 50, 'estimated_cost' => '0.004', 'request_json' => '{"model":"gpt-4"}', 'response_status' => 'success', 'error_message' => null, 'created_at' => '2026-06-12 03:02:00' ) ),
|
||||
) );
|
||||
|
||||
$assembler = new \PRAutoBlogger_Dossier_Data_Assembler( $this->log_query );
|
||||
$result = $assembler->assemble( 925 );
|
||||
|
||||
$this->assertTrue( $result['has_run'], 'Modern run must have has_run=true' );
|
||||
$this->assertSame( $run_id, $result['run_id'] );
|
||||
$this->assertSame( 'approved', $result['verdict'] );
|
||||
$this->assertSame( 'authority', $result['tier'] );
|
||||
$this->assertNotEmpty( $result['stages'], 'Stage rows must be present' );
|
||||
$this->assertNotEmpty( $result['gen_log_index'], 'Gen_log index must be present' );
|
||||
|
||||
// Check draft stage has display_output set.
|
||||
$draft_key = 'draft:writer';
|
||||
$this->assertArrayHasKey( $draft_key, $result['stages'] );
|
||||
$this->assertSame( 'Draft content here.', $result['stages'][ $draft_key ]['display_output'] );
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Amortized research row (pv=null, role='')
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function test_amortized_research_row_renders_gracefully(): void {
|
||||
$post = new \WP_Post();
|
||||
$post->ID = 930;
|
||||
$post->post_title = 'TB-500 Article';
|
||||
$post->post_status = 'publish';
|
||||
|
||||
$run_id = 'llm-research-run-001';
|
||||
|
||||
Functions\when( 'get_post' )->justReturn( $post );
|
||||
Functions\when( 'get_post_meta' )->alias(
|
||||
function ( $post_id, $key, $single ) use ( $run_id ) {
|
||||
return '_prautoblogger_run_id' === $key ? $run_id : '';
|
||||
}
|
||||
);
|
||||
|
||||
$this->wpdb->method( 'get_row' )->willReturn( array( 'run_id' => $run_id, 'status' => 'done', 'started_at' => '2026-06-12 03:00:00' ) );
|
||||
$this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_run_stages' );
|
||||
|
||||
// Amortized research row: pv=null, role=''.
|
||||
$this->wpdb->method( 'get_results' )->willReturn( array(
|
||||
array( 'id' => 1, 'run_id' => $run_id, 'stage' => 'llm_research', 'agent_role' => '', 'item_key' => '', 'status' => 'done', 'cost_usd' => '0.001', 'meta_json' => null, 'started_at' => '2026-06-12 03:00:00', 'finished_at' => '2026-06-12 03:00:30' ),
|
||||
) );
|
||||
|
||||
$this->log_query->method( 'get_by_run' )->willReturn( array(
|
||||
'llm_research' => array( array( 'model' => 'gpt-4', 'prompt_version' => null, 'agent_role' => '', 'prompt_tokens' => 100, 'completion_tokens' => 50, 'estimated_cost' => '0.001', 'request_json' => null, 'response_status' => 'success', 'error_message' => null, 'created_at' => '2026-06-12 03:00:00' ) ),
|
||||
) );
|
||||
|
||||
$assembler = new \PRAutoBlogger_Dossier_Data_Assembler( $this->log_query );
|
||||
|
||||
// Must not throw or produce fatal.
|
||||
$result = $assembler->assemble( 930 );
|
||||
|
||||
$this->assertTrue( $result['has_run'] );
|
||||
|
||||
// Stage is present; display_output is null (meta_json was null = pruned).
|
||||
$llm_key = 'llm_research:';
|
||||
$this->assertArrayHasKey( $llm_key, $result['stages'] );
|
||||
$this->assertNull( $result['stages'][ $llm_key ]['display_output'],
|
||||
'Amortized research with null meta_json must have null display_output' );
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Decisions present and absent
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function test_decisions_absent_returns_empty_array(): void {
|
||||
$post = new \WP_Post();
|
||||
$post->ID = 901; $post->post_title = 'Test'; $post->post_status = 'publish';
|
||||
$run_id = 'no-decisions-run';
|
||||
|
||||
Functions\when( 'get_post' )->justReturn( $post );
|
||||
Functions\when( 'get_post_meta' )->alias( fn( $id, $key ) => '_prautoblogger_run_id' === $key ? $run_id : '' );
|
||||
$this->wpdb->method( 'get_row' )->willReturn( array( 'run_id' => $run_id, 'status' => 'done' ) );
|
||||
$this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_run_stages' );
|
||||
$this->wpdb->method( 'get_results' )->willReturn( array() ); // no stages, no decisions
|
||||
$this->log_query->method( 'get_by_run' )->willReturn( array() );
|
||||
|
||||
$assembler = new \PRAutoBlogger_Dossier_Data_Assembler( $this->log_query );
|
||||
$result = $assembler->assemble( 901 );
|
||||
|
||||
$this->assertIsArray( $result['decisions'] );
|
||||
$this->assertEmpty( $result['decisions'] );
|
||||
}
|
||||
}
|
||||
198
tests/unit/Admin/DossierHooknameMechanismTest.php
Normal file
198
tests/unit/Admin/DossierHooknameMechanismTest.php
Normal file
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for Dossier admin page hookname faithfulness — request-time mechanism.
|
||||
*
|
||||
* ## Why the v0.19.2 hide-by-unset pattern caused a 403
|
||||
*
|
||||
* WordPress computes a page's hook suffix at TWO separate moments:
|
||||
*
|
||||
* 1. Registration time (inside add_submenu_page):
|
||||
* WP resolves the parent slug against $admin_page_hooks. If found, the hookname
|
||||
* becomes "{parent_key}_page_{slug}" (e.g. "prautoblogger_page_prautoblogger-dossier").
|
||||
*
|
||||
* 2. Request time (inside wp-admin/admin.php):
|
||||
* WP calls get_admin_page_parent() which scans $submenu for the slug. If not found
|
||||
* (because it was unset post-registration), it falls back to "admin_page_{slug}".
|
||||
*
|
||||
* When these two hooknames diverge, the render callback lives under name-A but WP
|
||||
* dispatches to name-B -- no handler found -- wp_die(403).
|
||||
*
|
||||
* The fix: options.php-parent guarantees "admin_page_{slug}" at BOTH moments.
|
||||
* No $submenu mutation is needed or permitted.
|
||||
*
|
||||
* ## Discriminator function: simulate_request_time_hookname
|
||||
*
|
||||
* Replicates get_admin_page_parent() + hookname derivation in wp-admin/admin.php.
|
||||
* Returns different hooknames for hide-by-unset vs options.php-parent, making the
|
||||
* 403 divergence mechanically verifiable in a unit test.
|
||||
*
|
||||
* Structural invariant tests (parent slug, URL shape, priority) live in
|
||||
* DossierMenuRegistrationTest.
|
||||
*
|
||||
* @see DossierMenuRegistrationTest -- structural invariants + deep-link URLs + priority.
|
||||
* @see ARCHITECTURE.md -- §22b (hidden admin page convention).
|
||||
* @see CONVENTIONS.md -- §Hidden Admin Pages.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Admin
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Admin;
|
||||
|
||||
class DossierHooknameMechanismTest extends DossierMenuRegistrationTestCase {
|
||||
|
||||
// =========================================================================
|
||||
// § REQUEST-TIME HOOKNAME FAITHFULNESS
|
||||
// The primary regression guard for the 403 incident class.
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Simulate the request-time hookname resolution algorithm.
|
||||
*
|
||||
* Replicates get_admin_page_parent() + the hookname derivation in
|
||||
* wp-admin/admin.php::wp_admin_canonical_url() / do_action("admin_{$page}"):
|
||||
*
|
||||
* scan $submenu for the slug
|
||||
* if found at parent P → return hookname derived from P
|
||||
* if not found → return "admin_page_{slug}"
|
||||
*
|
||||
* This is the discriminator function: it WILL return a different hookname than
|
||||
* add_submenu_page returned when the slug has been unset from $submenu.
|
||||
*
|
||||
* @param array<string, array<int, array<int, string>>> $submenu_state $submenu after all registration + mutations.
|
||||
* @param string $slug Page slug to resolve.
|
||||
* @return string Request-time hookname.
|
||||
*/
|
||||
private function simulate_request_time_hookname( array $submenu_state, string $slug ): string {
|
||||
foreach ( $submenu_state as $parent => $items ) {
|
||||
foreach ( $items as $item ) {
|
||||
if ( isset( $item[2] ) && $slug === $item[2] ) {
|
||||
// Found the slug; derive hookname the same way as registration.
|
||||
if ( 'options.php' === $parent ) {
|
||||
return 'admin_page_' . $slug;
|
||||
}
|
||||
$parent_key = str_replace( '.php', '', $parent );
|
||||
return $parent_key . '_page_' . $slug;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Slug absent from $submenu -- WP falls back to the orphan namespace.
|
||||
return 'admin_page_' . $slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* REQUEST-TIME FAITHFULNESS: registration hookname must equal request-time hookname.
|
||||
*
|
||||
* This test directly discriminates the two patterns:
|
||||
*
|
||||
* HIDE-BY-UNSET (v0.19.2, BROKEN):
|
||||
* - add_submenu_page('prautoblogger-settings', ...) → 'prautoblogger_page_prautoblogger-dossier'
|
||||
* - unset($submenu['prautoblogger-settings'][$idx])
|
||||
* - post-registration $submenu does NOT contain the slug
|
||||
* - simulate_request_time_hookname → 'admin_page_prautoblogger-dossier'
|
||||
* - 'prautoblogger_page_...' ≠ 'admin_page_...' → FAIL ✓ (test correctly detects broken state)
|
||||
*
|
||||
* OPTIONS.PHP-PARENT (v0.19.3, FIXED):
|
||||
* - add_submenu_page('options.php', ...) → 'admin_page_prautoblogger-dossier'
|
||||
* - no $submenu mutation
|
||||
* - simulate_request_time_hookname → 'admin_page_prautoblogger-dossier'
|
||||
* - MATCH → PASS ✓
|
||||
*
|
||||
* This test ALSO fails if any future change reintroduces $submenu mutation,
|
||||
* or switches back to a plugin-owned parent without preserving the submenu entry.
|
||||
*/
|
||||
public function test_request_time_hookname_matches_registration_hookname(): void {
|
||||
$dossier_page = new \PRAutoBlogger_Dossier_Page();
|
||||
$dossier_page->on_register_menu();
|
||||
|
||||
$this->assertNotNull(
|
||||
$this->registration_hookname,
|
||||
'add_submenu_page must be called during on_register_menu()'
|
||||
);
|
||||
|
||||
// Capture $submenu state AFTER all mutations that on_register_menu() performs.
|
||||
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test read.
|
||||
global $submenu;
|
||||
$submenu_after = $submenu;
|
||||
|
||||
$request_time_hookname = $this->simulate_request_time_hookname(
|
||||
$submenu_after,
|
||||
\PRAutoBlogger_Dossier_Page::PAGE_SLUG
|
||||
);
|
||||
|
||||
$this->assertSame(
|
||||
$this->registration_hookname,
|
||||
$request_time_hookname,
|
||||
sprintf(
|
||||
'Hookname mismatch: registration="%s", request-time="%s". '
|
||||
. 'This is the 403 failure class. '
|
||||
. 'The hide-by-unset pattern unsets the slug from $submenu after registration, '
|
||||
. 'so get_admin_page_parent() fails at request time, WP recomputes under '
|
||||
. 'the orphan "admin_page_*" namespace, finds no registered handler, '
|
||||
. 'and calls wp_die(403). Fix: use options.php as parent -- hookname is '
|
||||
. '"admin_page_{slug}" at both registration and request time, no mutation needed.',
|
||||
$this->registration_hookname,
|
||||
$request_time_hookname
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* REGRESSION PROOF: documents that v0.19.2 hide-by-unset would have failed the
|
||||
* request-time test above.
|
||||
*
|
||||
* Directly simulates the broken implementation, applies simulate_request_time_hookname,
|
||||
* and asserts the MISMATCH -- proving the discriminator is effective.
|
||||
*
|
||||
* This test documents the failure state and serves as evidence that the test methodology
|
||||
* is correct. It passes by verifying the broken pattern produces the wrong hookname.
|
||||
*/
|
||||
public function test_hide_by_unset_would_fail_request_time_check(): void {
|
||||
// --- SIMULATE V0.19.2 hide-by-unset IMPLEMENTATION ---
|
||||
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test simulation.
|
||||
global $submenu;
|
||||
|
||||
// Step 1: Registration (add_submenu_page with plugin parent).
|
||||
// Real add_submenu_page stub returns 'prautoblogger_page_prautoblogger-dossier'
|
||||
// when parent is 'prautoblogger-settings'. Populate $submenu as WP does.
|
||||
$broken_parent_slug = 'prautoblogger-settings';
|
||||
$slug = 'prautoblogger-dossier';
|
||||
|
||||
$submenu[ $broken_parent_slug ] = $submenu[ $broken_parent_slug ] ?? array();
|
||||
$submenu[ $broken_parent_slug ][] = array( 'Article Dossier', 'manage_options', $slug, 'Dossier' );
|
||||
|
||||
// The hookname WP computed at registration time with plugin-owned parent:
|
||||
$broken_registration_hookname = 'prautoblogger_page_' . $slug;
|
||||
|
||||
// Step 2: The unset mutation (the bug).
|
||||
// This is what v0.19.2's on_register_menu() did after add_submenu_page.
|
||||
foreach ( $submenu[ $broken_parent_slug ] as $idx => $item ) {
|
||||
if ( isset( $item[2] ) && $slug === $item[2] ) {
|
||||
unset( $submenu[ $broken_parent_slug ][ $idx ] );
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Request time -- slug is now ABSENT from $submenu.
|
||||
$request_time_hookname = $this->simulate_request_time_hookname( $submenu, $slug );
|
||||
|
||||
// THE MISMATCH that caused the 403:
|
||||
$this->assertNotSame(
|
||||
$broken_registration_hookname,
|
||||
$request_time_hookname,
|
||||
'Test methodology error: hide-by-unset must produce a hookname mismatch. '
|
||||
. 'If this assertion fails the discriminator is broken.'
|
||||
);
|
||||
|
||||
// Specifically: request-time falls back to admin_page_* orphan namespace.
|
||||
$this->assertSame(
|
||||
'admin_page_' . $slug,
|
||||
$request_time_hookname,
|
||||
'Request-time fallback must be admin_page_{slug} when slug absent from $submenu.'
|
||||
);
|
||||
$this->assertSame(
|
||||
'prautoblogger_page_' . $slug,
|
||||
$broken_registration_hookname,
|
||||
'Registration-time hookname with prautoblogger-settings parent must be prautoblogger_page_{slug}.'
|
||||
);
|
||||
}
|
||||
}
|
||||
235
tests/unit/Admin/DossierMenuRegistrationTest.php
Normal file
235
tests/unit/Admin/DossierMenuRegistrationTest.php
Normal file
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for Dossier admin page menu registration — structural invariants.
|
||||
*
|
||||
* Covers: parent slug (options.php), $submenu non-mutation, page slug constant,
|
||||
* asset enqueue gate prefix, deep-link URL shape, and hook priority ordering.
|
||||
*
|
||||
* Request-time hookname faithfulness tests live in DossierHooknameMechanismTest.
|
||||
*
|
||||
* @see DossierHooknameMechanismTest -- request-time discriminator + regression proof.
|
||||
* @see ARCHITECTURE.md -- §22b (hidden admin page convention).
|
||||
* @see CONVENTIONS.md -- §Hidden Admin Pages.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Admin
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Admin;
|
||||
|
||||
use Brain\Monkey;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class DossierMenuRegistrationTest extends DossierMenuRegistrationTestCase {
|
||||
|
||||
// =========================================================================
|
||||
// § STRUCTURAL INVARIANTS
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Parent slug must be PARENT_SLUG constant ('options.php').
|
||||
*
|
||||
* FAILS on v0.19.2 (parent was 'prautoblogger-settings').
|
||||
* PASSES after v0.19.3 fix (parent is 'options.php').
|
||||
*/
|
||||
public function test_dossier_parent_slug_is_options_php(): void {
|
||||
$dossier_page = new \PRAutoBlogger_Dossier_Page();
|
||||
$dossier_page->on_register_menu();
|
||||
|
||||
$this->assertSame(
|
||||
'options.php',
|
||||
$this->captured_parent_slug,
|
||||
'Dossier must use options.php as parent (canonical hidden-page pattern). '
|
||||
. 'Never use a plugin-owned parent with post-registration $submenu unset -- '
|
||||
. 'that is the v0.19.2 403 bug class. See CONVENTIONS.md §Hidden Admin Pages.'
|
||||
);
|
||||
|
||||
$this->assertSame(
|
||||
\PRAutoBlogger_Dossier_Page::PARENT_SLUG,
|
||||
$this->captured_parent_slug,
|
||||
'Captured parent slug must match PARENT_SLUG constant.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* No $submenu mutation after registration.
|
||||
*
|
||||
* options.php-parent pages are hidden by construction; the slug must remain
|
||||
* in $submenu so get_admin_page_parent() can resolve the parent at request time.
|
||||
*
|
||||
* FAILS on v0.19.2 (slug was unset from $submenu after registration).
|
||||
* PASSES after v0.19.3 fix (no mutation).
|
||||
*/
|
||||
public function test_no_submenu_mutation_after_registration(): void {
|
||||
$dossier_page = new \PRAutoBlogger_Dossier_Page();
|
||||
$dossier_page->on_register_menu();
|
||||
|
||||
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test read.
|
||||
global $submenu;
|
||||
|
||||
// The dossier slug must still be present somewhere in $submenu.
|
||||
$slug_found = false;
|
||||
foreach ( $submenu as $parent => $items ) {
|
||||
foreach ( $items as $item ) {
|
||||
if ( isset( $item[2] ) && \PRAutoBlogger_Dossier_Page::PAGE_SLUG === $item[2] ) {
|
||||
$slug_found = true;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertTrue(
|
||||
$slug_found,
|
||||
'Dossier slug must remain in $submenu after on_register_menu() completes. '
|
||||
. 'Unsetting it causes get_admin_page_parent() to fail at request time and '
|
||||
. 'produces the admin_page_* orphan hookname -- the v0.19.2 403 bug class. '
|
||||
. 'options.php-parent pages are hidden by construction; no mutation needed.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Page slug must be PAGE_SLUG constant ('prautoblogger-dossier').
|
||||
*/
|
||||
public function test_dossier_uses_correct_slug(): void {
|
||||
$dossier_page = new \PRAutoBlogger_Dossier_Page();
|
||||
$dossier_page->on_register_menu();
|
||||
|
||||
$this->assertSame(
|
||||
\PRAutoBlogger_Dossier_Page::PAGE_SLUG,
|
||||
$this->captured_menu_slug,
|
||||
'Dossier must register under PAGE_SLUG.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset enqueue gate must use 'admin_page_' prefix (not 'prautoblogger_page_').
|
||||
*
|
||||
* options.php-parent pages always resolve to 'admin_page_{slug}'.
|
||||
* FAILS on v0.19.2 (gate checked 'prautoblogger_page_prautoblogger-dossier').
|
||||
* PASSES after v0.19.3 fix (gate checks 'admin_page_prautoblogger-dossier').
|
||||
*/
|
||||
public function test_asset_enqueue_gate_uses_admin_page_prefix(): void {
|
||||
Functions\when( 'wp_enqueue_style' )->justReturn( null );
|
||||
Functions\when( 'wp_enqueue_script' )->justReturn( null );
|
||||
|
||||
$dossier_page = new \PRAutoBlogger_Dossier_Page();
|
||||
|
||||
$expected_hook = 'admin_page_' . \PRAutoBlogger_Dossier_Page::PAGE_SLUG;
|
||||
|
||||
// Wrong hook -- assets must NOT enqueue.
|
||||
$enqueued = false;
|
||||
Functions\when( 'wp_enqueue_style' )->alias( function () use ( &$enqueued ) {
|
||||
$enqueued = true;
|
||||
} );
|
||||
|
||||
$dossier_page->on_enqueue_assets( 'wrong_hook_prautoblogger_page_' . \PRAutoBlogger_Dossier_Page::PAGE_SLUG );
|
||||
$this->assertFalse(
|
||||
$enqueued,
|
||||
'Assets must NOT enqueue when hook_suffix is the old prautoblogger_page_* hook.'
|
||||
);
|
||||
|
||||
// Correct hook -- assets must enqueue.
|
||||
$dossier_page->on_enqueue_assets( $expected_hook );
|
||||
$this->assertTrue(
|
||||
$enqueued,
|
||||
sprintf(
|
||||
'Assets must enqueue when hook_suffix is "%s" (admin_page_* from options.php parent).',
|
||||
$expected_hook
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// § DEEP-LINK URL INVARIANCE
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* url_for_post() must produce admin.php?page=prautoblogger-dossier&post_id=N.
|
||||
*
|
||||
* Deep-link URLs are parent-agnostic -- they reference only the page slug.
|
||||
* The fix changes only the registration parent; the URL shape is unchanged.
|
||||
*/
|
||||
public function test_url_for_post_contains_slug_and_post_id(): void {
|
||||
$url = \PRAutoBlogger_Dossier_Page::url_for_post( 42 );
|
||||
|
||||
$this->assertStringContainsString(
|
||||
\PRAutoBlogger_Dossier_Page::PAGE_SLUG,
|
||||
$url,
|
||||
'url_for_post() must contain the dossier page slug.'
|
||||
);
|
||||
$this->assertStringContainsString(
|
||||
'42',
|
||||
$url,
|
||||
'url_for_post() must contain the post_id.'
|
||||
);
|
||||
$this->assertStringContainsString(
|
||||
'admin.php',
|
||||
$url,
|
||||
'url_for_post() must use admin.php (parent-agnostic deep-link).'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* url_for_post() URL shape is unchanged from v0.19.2.
|
||||
*
|
||||
* Confirms the exact URL format that board cards and the metabox link depend on.
|
||||
*/
|
||||
public function test_url_for_post_exact_shape(): void {
|
||||
$url = \PRAutoBlogger_Dossier_Page::url_for_post( 930 );
|
||||
|
||||
// Must contain admin.php?page=prautoblogger-dossier&post_id=930
|
||||
// (or equivalent query string order -- admin_url may vary order).
|
||||
$this->assertStringContainsString( 'page=prautoblogger-dossier', $url );
|
||||
$this->assertStringContainsString( 'post_id=930', $url );
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// § HOOK REGISTRATION ORDERING (priority guard)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Dossier on_register_menu must remain at priority 12 (after board 11, parent 10).
|
||||
*
|
||||
* The options.php-parent fix removes the dependency on ordering relative to the
|
||||
* plugin's own parent menu (since we no longer add a submenu under it), but we
|
||||
* keep the priority assertion to prevent accidental priority regression.
|
||||
*/
|
||||
public function test_dossier_hook_registered_at_priority_12(): void {
|
||||
$admin_page = new \PRAutoBlogger_Admin_Page();
|
||||
$board_page = new \PRAutoBlogger_Board_Page();
|
||||
$dossier_page = new \PRAutoBlogger_Dossier_Page();
|
||||
|
||||
add_action( 'admin_menu', array( $admin_page, 'on_register_menu' ), 10 );
|
||||
add_action( 'admin_menu', array( $board_page, 'on_register_menu' ), 11 );
|
||||
add_action( 'admin_menu', array( $dossier_page, 'on_register_menu' ), 12 );
|
||||
|
||||
$storage = Monkey\Container::instance()->hookStorage();
|
||||
$ref = new \ReflectionClass( $storage );
|
||||
$prop = $ref->getProperty( 'storage' );
|
||||
$prop->setAccessible( true );
|
||||
$data = $prop->getValue( $storage );
|
||||
|
||||
$parent_priority = -1;
|
||||
$board_priority = -1;
|
||||
$dossier_priority = -1;
|
||||
|
||||
$admin_menu_entries = $data['added']['actions']['admin_menu'] ?? array();
|
||||
foreach ( $admin_menu_entries as $entry ) {
|
||||
$cb = (string) ( $entry[0] ?? '' );
|
||||
$priority = (int) ( $entry[1] ?? -1 );
|
||||
if ( false !== strpos( $cb, 'PRAutoBlogger_Admin_Page' ) ) { $parent_priority = $priority; }
|
||||
if ( false !== strpos( $cb, 'PRAutoBlogger_Board_Page' ) ) { $board_priority = $priority; }
|
||||
if ( false !== strpos( $cb, 'PRAutoBlogger_Dossier_Page' ) ) { $dossier_priority = $priority; }
|
||||
}
|
||||
|
||||
$this->assertGreaterThan(
|
||||
$parent_priority,
|
||||
$board_priority,
|
||||
'Board priority must exceed parent priority.'
|
||||
);
|
||||
$this->assertGreaterThan(
|
||||
$board_priority,
|
||||
$dossier_priority,
|
||||
'Dossier priority must exceed board priority.'
|
||||
);
|
||||
}
|
||||
}
|
||||
125
tests/unit/Admin/DossierMenuRegistrationTestCase.php
Normal file
125
tests/unit/Admin/DossierMenuRegistrationTestCase.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
/**
|
||||
* Shared test harness for Dossier menu registration test suites.
|
||||
*
|
||||
* Provides the Brain\Monkey spy stubs (add_menu_page, add_submenu_page) and the
|
||||
* $submenu isolation that both DossierMenuRegistrationTest and
|
||||
* DossierHooknameMechanismTest depend on.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Admin
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Admin;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
abstract class DossierMenuRegistrationTestCase extends BaseTestCase {
|
||||
|
||||
/** @var array<int, string> Call sequence spy. */
|
||||
protected array $call_sequence = [];
|
||||
|
||||
/**
|
||||
* Captured parent slug from the most recent add_submenu_page call.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected ?string $captured_parent_slug = null;
|
||||
|
||||
/**
|
||||
* Captured menu slug from the most recent add_submenu_page call.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected ?string $captured_menu_slug = null;
|
||||
|
||||
/**
|
||||
* Hookname returned by the most recent add_submenu_page call.
|
||||
*
|
||||
* In real WordPress this is `{parent_key}_page_{slug}` when parent is known,
|
||||
* or `admin_page_{slug}` when parent is not in $admin_page_hooks. For options.php
|
||||
* children WordPress always emits `admin_page_{slug}`.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected ?string $registration_hookname = null;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->call_sequence = [];
|
||||
$this->captured_parent_slug = null;
|
||||
$this->captured_menu_slug = null;
|
||||
$this->registration_hookname = null;
|
||||
|
||||
// v0.20.0: the dossier enqueue path localizes the edit/re-run config.
|
||||
Functions\when( 'wp_localize_script' )->justReturn( true );
|
||||
Functions\when( 'wp_create_nonce' )->justReturn( 'test-nonce' );
|
||||
Functions\when( 'absint' )->alias( static function ( $val ) { return abs( (int) $val ); } );
|
||||
|
||||
// Spy stub: tracks call order, captures slugs, returns a hookname that
|
||||
// reflects how WordPress actually resolves it for known parents.
|
||||
Functions\when( 'add_menu_page' )->alias(
|
||||
function () {
|
||||
$this->call_sequence[] = 'add_menu_page';
|
||||
return 'prautoblogger-settings';
|
||||
}
|
||||
);
|
||||
|
||||
// Simulate WordPress hookname computation:
|
||||
// - options.php parent → 'admin_page_{slug}'
|
||||
// - known plugin parent → '{parent_key}_page_{slug}'
|
||||
// - unknown parent → 'admin_page_{slug}' (orphan fallback)
|
||||
Functions\when( 'add_submenu_page' )->alias(
|
||||
function (
|
||||
string $parent_slug,
|
||||
string $page_title,
|
||||
string $menu_title,
|
||||
string $capability,
|
||||
string $menu_slug,
|
||||
$callback = ''
|
||||
) {
|
||||
$this->call_sequence[] = 'add_submenu_page:' . $parent_slug . ':' . $menu_slug;
|
||||
$this->captured_parent_slug = $parent_slug;
|
||||
$this->captured_menu_slug = $menu_slug;
|
||||
|
||||
if ( 'options.php' === $parent_slug ) {
|
||||
$hookname = 'admin_page_' . $menu_slug;
|
||||
} else {
|
||||
$parent_key = str_replace( '.php', '', $parent_slug );
|
||||
$hookname = $parent_key . '_page_' . $menu_slug;
|
||||
}
|
||||
|
||||
$this->registration_hookname = $hookname;
|
||||
|
||||
// Populate $submenu as WordPress does at registration time.
|
||||
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test simulation.
|
||||
global $submenu;
|
||||
$submenu[ $parent_slug ] = $submenu[ $parent_slug ] ?? array();
|
||||
$submenu[ $parent_slug ][] = array( $page_title, $capability, $menu_slug, $menu_title );
|
||||
|
||||
return $hookname;
|
||||
}
|
||||
);
|
||||
|
||||
Functions\when( 'current_user_can' )->justReturn( true );
|
||||
Functions\when( 'get_option' )->justReturn( false );
|
||||
Functions\when( 'admin_url' )->alias(
|
||||
function ( string $path = '' ) {
|
||||
return 'https://example.com/wp-admin/' . ltrim( $path, '/' );
|
||||
}
|
||||
);
|
||||
Functions\when( '__' )->returnArg( 1 );
|
||||
|
||||
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test isolation.
|
||||
$GLOBALS['submenu'] = array();
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
$this->call_sequence = [];
|
||||
$this->captured_parent_slug = null;
|
||||
$this->captured_menu_slug = null;
|
||||
$this->registration_hookname = null;
|
||||
unset( $GLOBALS['submenu'] );
|
||||
parent::tearDown();
|
||||
}
|
||||
}
|
||||
77
tests/unit/Admin/SettingsFieldsExtendedTest.php
Normal file
77
tests/unit/Admin/SettingsFieldsExtendedTest.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Settings_Fields_Extended.
|
||||
*
|
||||
* Pins the cardinality contract on get_fields() so any future split that
|
||||
* accidentally re-introduces a + array union (dropping keys) fails CI
|
||||
* rather than silently at runtime.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Admin
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Admin;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
|
||||
class SettingsFieldsExtendedTest extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* Stub WP functions that the settings-field classes invoke.
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
// Image_Model_Registry checks option cache; return false to use hardcoded list.
|
||||
\Brain\Monkey\Functions\when( 'get_option' )->justReturn( false );
|
||||
\Brain\Monkey\Functions\when( 'update_option' )->justReturn( true );
|
||||
// wp_timezone_string is referenced in schedule-time field description.
|
||||
\Brain\Monkey\Functions\when( 'wp_timezone_string' )->justReturn( 'UTC' );
|
||||
}
|
||||
|
||||
/**
|
||||
* get_fields() must return exactly 29 fields (18 local + 11 image).
|
||||
*
|
||||
* This pins the array_merge fix: if a + operator re-appears the image
|
||||
* fields are silently dropped to 18 and this test fails CI.
|
||||
* Count breakdown: 18 schedule/publishing/analytics/display/sources fields
|
||||
* from the local array + 11 image fields from Settings_Fields_Images.
|
||||
*/
|
||||
public function test_get_fields_returns_29_fields(): void {
|
||||
$fields = \PRAutoBlogger_Settings_Fields_Extended::get_fields();
|
||||
$this->assertCount(
|
||||
29,
|
||||
$fields,
|
||||
'Expected 29 fields (18 local + 11 image). A + union instead of array_merge would return only 18.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every field must carry a non-empty id and a section string.
|
||||
* This catches a malformed field entry that would fail register_setting().
|
||||
*/
|
||||
public function test_every_field_has_id_and_section(): void {
|
||||
$fields = \PRAutoBlogger_Settings_Fields_Extended::get_fields();
|
||||
foreach ( $fields as $field ) {
|
||||
$this->assertArrayHasKey( 'id', $field, 'Field missing id key' );
|
||||
$this->assertNotEmpty( $field['id'], 'Field has empty id' );
|
||||
$this->assertArrayHasKey( 'section', $field, sprintf( 'Field %s missing section key', $field['id'] ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The image fields from Settings_Fields_Images must all be present.
|
||||
*
|
||||
* Checks that prautoblogger_image_enabled (first image field)
|
||||
* exists in the merged result — if the + union regresses, this field
|
||||
* would be missing because it is at index 0 in the image array (which
|
||||
* collides with index 0 in the local array under +).
|
||||
*/
|
||||
public function test_image_fields_present_in_merged_result(): void {
|
||||
$fields = \PRAutoBlogger_Settings_Fields_Extended::get_fields();
|
||||
$ids = array_column( $fields, 'id' );
|
||||
$this->assertContains(
|
||||
'prautoblogger_image_enabled',
|
||||
$ids,
|
||||
'Enable Image Generation toggle missing — array_merge may have regressed to +'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -147,7 +147,7 @@ abstract class BaseTestCase extends TestCase {
|
||||
*/
|
||||
protected function create_mock_wpdb() {
|
||||
$wpdb = $this->getMockBuilder( \stdClass::class )
|
||||
->addMethods( [ 'prepare', 'get_var', 'get_results', 'insert', 'query', 'get_row', 'update' ] )
|
||||
->addMethods( [ 'prepare', 'get_var', 'get_results', 'insert', 'query', 'get_row', 'get_col', 'update' ] )
|
||||
->getMock();
|
||||
|
||||
$wpdb->prefix = 'wp_';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user