Compare commits
5 Commits
claude/pra
...
claude/pha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6727e073cc | ||
|
|
6ed639d1db | ||
|
|
38500b2781 | ||
|
|
aaf1b4274e | ||
|
|
1b31ef6396 |
@@ -35,33 +35,25 @@ 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',
|
||||
array(
|
||||
'methods' => 'POST',
|
||||
'callback' => 'prautoblogger_deploy_handle',
|
||||
'permission_callback' => 'prautoblogger_deploy_check_auth',
|
||||
)
|
||||
);
|
||||
register_rest_route( 'prautoblogger-deploy/v1', '/deploy', [
|
||||
'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',
|
||||
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',
|
||||
)
|
||||
);
|
||||
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',
|
||||
] );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,7 +78,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.',
|
||||
array( 'status' => 429 )
|
||||
[ 'status' => 429 ]
|
||||
);
|
||||
}
|
||||
set_transient( 'prautoblogger_deploy_lock', time(), 60 );
|
||||
@@ -95,7 +87,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.', array( 'status' => 400 ) );
|
||||
return new WP_Error( 'no_file', 'No plugin zip uploaded.', [ 'status' => 400 ] );
|
||||
}
|
||||
|
||||
$tmp_file = $files['plugin']['tmp_name'];
|
||||
@@ -104,11 +96,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.', array( 'status' => 400 ) );
|
||||
return new WP_Error( 'invalid_zip', 'Uploaded file is not a valid zip.', [ 'status' => 400 ] );
|
||||
}
|
||||
|
||||
$has_main_file = false;
|
||||
for ( $i = 0; $i < $zip->numFiles; $i++ ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
|
||||
for ( $i = 0; $i < $zip->numFiles; $i++ ) {
|
||||
if ( 'prautoblogger/prautoblogger.php' === $zip->getNameIndex( $i ) ) {
|
||||
$has_main_file = true;
|
||||
break;
|
||||
@@ -121,7 +113,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.',
|
||||
array( 'status' => 400 )
|
||||
[ 'status' => 400 ]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -151,7 +143,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.', array( 'status' => 500 ) );
|
||||
return new WP_Error( 'backup_failed', 'Could not backup current plugin.', [ 'status' => 500 ] );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +161,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.', array( 'status' => 500 ) );
|
||||
return new WP_Error( 'extract_failed', 'Zip extraction failed. Rolled back.', [ 'status' => 500 ] );
|
||||
}
|
||||
|
||||
// 7. Activate plugin.
|
||||
@@ -186,7 +178,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.',
|
||||
array( 'status' => 500 )
|
||||
[ 'status' => 500 ]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -196,23 +188,18 @@ 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 ? $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 ?: 'fresh install',
|
||||
gmdate( 'Y-m-d H:i:s' )
|
||||
) );
|
||||
|
||||
return new WP_REST_Response(
|
||||
array(
|
||||
'success' => true,
|
||||
'version' => $incoming_version,
|
||||
'message' => "PRAutoBlogger v{$incoming_version} deployed and activated.",
|
||||
),
|
||||
200
|
||||
);
|
||||
return new WP_REST_Response( [
|
||||
'success' => true,
|
||||
'version' => $incoming_version,
|
||||
'message' => "PRAutoBlogger v{$incoming_version} deployed and activated.",
|
||||
], 200 );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -223,7 +210,7 @@ function prautoblogger_deploy_get_installed_version(): string {
|
||||
if ( ! file_exists( $main_file ) ) {
|
||||
return '';
|
||||
}
|
||||
$data = get_file_data( $main_file, array( 'Version' => 'Version' ) );
|
||||
$data = get_file_data( $main_file, [ 'Version' => 'Version' ] );
|
||||
return $data['Version'] ?? '';
|
||||
}
|
||||
|
||||
|
||||
134
.github/workflows/ci.yml
vendored
134
.github/workflows/ci.yml
vendored
@@ -1,53 +1,111 @@
|
||||
##
|
||||
# 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
|
||||
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.
|
||||
|
||||
on:
|
||||
pull_request: {}
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_call: {}
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
uses: peptide-repo/peptide-e2e/.github/workflows/ci.yml@main
|
||||
with:
|
||||
tests: brain-monkey
|
||||
secrets: inherit
|
||||
|
||||
check-constants:
|
||||
name: Check PRAUTOBLOGGER_* constants
|
||||
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 8.1
|
||||
- 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
|
||||
|
||||
phpcs:
|
||||
name: PHPCS (WordPress standards)
|
||||
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
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.1'
|
||||
coverage: none
|
||||
- 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
|
||||
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
|
||||
|
||||
299
.github/workflows/cto-review.yml
vendored
299
.github/workflows/cto-review.yml
vendored
@@ -1,17 +1,20 @@
|
||||
name: CTO Review
|
||||
|
||||
# Automated PR review powered by Claude Haiku (via OpenRouter).
|
||||
# Automated PR review powered by Claude + DeepSeek + Kimi (via OpenRouter)
|
||||
# plus Neurometric PR-Summarizer (a Qwen3-4B task-specialist SLM).
|
||||
#
|
||||
# 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.
|
||||
# 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.
|
||||
#
|
||||
# Does NOT merge. Posts formal APPROVE/REQUEST_CHANGES/COMMENT reviews.
|
||||
# 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.
|
||||
#
|
||||
# Requires repo secret: OPENROUTER_API_KEY
|
||||
# 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).
|
||||
#
|
||||
# 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.
|
||||
@@ -122,8 +125,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".
|
||||
# File transport also sidesteps the ~1MB step-output cap.
|
||||
# runner ARG_MAX and fails the next step with "Argument list too long"
|
||||
# (issue #110). 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)
|
||||
@@ -148,11 +151,12 @@ jobs:
|
||||
echo "EOF_CONV"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Call Haiku via OpenRouter
|
||||
- name: Call 4 models in parallel (OpenRouter + Neurometric)
|
||||
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 }}
|
||||
@@ -180,17 +184,24 @@ jobs:
|
||||
fi
|
||||
|
||||
python3 - <<'PY'
|
||||
import json, os, sys, urllib.request
|
||||
import json, os, sys, urllib.request, concurrent.futures
|
||||
|
||||
api_key = os.environ["OPENROUTER_API_KEY"]
|
||||
|
||||
# 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
|
||||
# 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"),
|
||||
]
|
||||
|
||||
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.
|
||||
|
||||
@@ -253,100 +264,218 @@ jobs:
|
||||
}
|
||||
}]
|
||||
|
||||
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"
|
||||
)
|
||||
PER_MODEL_TIMEOUT = 60 # seconds; one hung model should not block siblings
|
||||
|
||||
error = None
|
||||
review = None
|
||||
cost_cents = 0.0
|
||||
ptok = 0
|
||||
ctok = 0
|
||||
# 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",
|
||||
]
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
data = json.loads(resp.read())
|
||||
except Exception as e:
|
||||
error = str(e)[:300]
|
||||
data = {}
|
||||
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,
|
||||
}
|
||||
|
||||
if not error:
|
||||
choice = data.get("choices", [{}])[0].get("message", {})
|
||||
tool_calls = choice.get("tool_calls", [])
|
||||
if not tool_calls:
|
||||
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}"
|
||||
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,
|
||||
}
|
||||
|
||||
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
|
||||
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,
|
||||
}
|
||||
|
||||
# Build comment
|
||||
if error:
|
||||
heading_verdict = "ERROR"
|
||||
lines = [f"## CTO Review — {heading_verdict}", ""]
|
||||
lines.append(f"Model failed: `{error}`")
|
||||
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"]
|
||||
lines.append("")
|
||||
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}")
|
||||
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("")
|
||||
lines.append(review.get("summary") or "_(model returned no summary)_")
|
||||
lines.append(rev.get("summary") or "_(model returned no summary)_")
|
||||
lines.append("")
|
||||
if review.get("concerns"):
|
||||
if rev.get("concerns"):
|
||||
lines.append("**Concerns:**")
|
||||
for c in review["concerns"]:
|
||||
for c in rev["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 review.get("positives"):
|
||||
lines.append("**Positives:** " + "; ".join(review["positives"]))
|
||||
if rev.get("positives"):
|
||||
lines.append("**Positives:** " + "; ".join(rev["positives"]))
|
||||
lines.append("")
|
||||
lines.append(f"_tokens in/out: {ptok}/{ctok} · cost: ${cost_cents/100:.4f}_")
|
||||
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("")
|
||||
|
||||
lines.append("---")
|
||||
lines.append(f"_Total review cost: **${cost_cents/100:.4f}**. Reviewer: {MODEL_DISPLAY}. Roster configured in `.github/workflows/cto-review.yml`._")
|
||||
lines.append(f"_Total review cost: **${total_cents/100:.4f}**. A/B 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(cost_cents))}\n")
|
||||
f.write(f"added_cents={int(round(total_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 Haiku's verdict as the formal event. Falls back to COMMENT
|
||||
# if the model failed or returned something unexpected.
|
||||
# Uses primary model's verdict as the formal event. Falls back to COMMENT
|
||||
# if the primary 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,17 +9,29 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
name: CI Gate (reusable)
|
||||
uses: ./.github/workflows/ci.yml
|
||||
permissions:
|
||||
contents: write
|
||||
secrets: inherit
|
||||
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
|
||||
|
||||
deploy:
|
||||
name: Deploy (shared pipeline)
|
||||
needs: ci
|
||||
uses: peptide-repo/peptide-e2e/.github/workflows/deploy-app.yml@main
|
||||
needs: validate
|
||||
uses: peptiderepo/peptide-e2e/.github/workflows/deploy-app.yml@main
|
||||
with:
|
||||
target_path: wp-content/plugins/prautoblogger
|
||||
secrets: inherit
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
> **Cross-app context:** decisions that affect multiple plugins (Cloudflare AI Gateway routing, OpenRouter account sharing, the interface pattern, image-generation stack, social distributor choice) are recorded in `Peptide Repo CTO/docs/engineering/decisions/`. The incident runbook for cross-app failure modes is at `Peptide Repo CTO/docs/engineering/INCIDENT-RUNBOOK.md`. Engineer PRAutoBlogger should read both before making decisions that cross plugin boundaries.
|
||||
|
||||
> **App-local decisions:** choices that affect only this plugin are recorded in [`docs/adr/`](docs/adr/README.md) — a per-repo numbered log (0001, 0002, …). Read `docs/adr/README.md` for the index. The routing rule and required template live centrally in `Peptide Repo CTO/docs/engineering/ADR-PROCESS.md`.
|
||||
|
||||
PRAutoBlogger is a WordPress plugin that monitors social media (starting with Reddit) for recurring questions, complaints, and comparisons in a configured niche, uses LLM agents (via OpenRouter) to generate high-quality blog articles from those insights, runs them through a chief editor agent for quality review, and auto-publishes them on a configurable daily schedule. All collected data and generation metrics are stored for a self-improvement feedback loop.
|
||||
|
||||
---
|
||||
@@ -1086,66 +1084,3 @@ 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 (async) | `wp prautoblogger generate` → `WP_CLI_Commands::generate_command()` → `kick_off()` |
|
||||
| WP-CLI (sync / VPS) | `wp prautoblogger generate --sync` → `WP_CLI_Commands::run_sync()` → `set_sync_mode(true)` → `on_orchestrate_tick()` → tick loop `on_generate_tick()` (×N) → `set_sync_mode(false)` |
|
||||
| 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.
|
||||
|
||||
159
CHANGELOG.md
159
CHANGELOG.md
@@ -5,163 +5,6 @@ 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/).
|
||||
|
||||
## [Unreleased - Internal]
|
||||
|
||||
### Internal
|
||||
- ADR log scaffolded: `docs/adr/` created as the app-local decision log for this plugin; `docs/adr/README.md` is the index; ADR-0001 documents the v0.16.0 editorial illustration image-style pivot. `ARCHITECTURE.md` updated to point to the folder. No runtime changes; docs-only.
|
||||
- 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.
|
||||
|
||||
## [0.22.1] - 2026-06-22
|
||||
|
||||
### Added
|
||||
- **`wp prautoblogger generate --sync`** (VPS orchestrator mode): runs the full
|
||||
checkpoint pipeline synchronously in the calling process — acquires lock, calls
|
||||
`on_orchestrate_tick()` in-process, then loops `on_generate_tick()` until the
|
||||
queue is empty or a terminal state is reached (max 20 ticks). Exits 0 on
|
||||
success with a JSON summary line; exits 1 on error. Designed to be called from
|
||||
the Coolify VPS over a held-open SSH session, where Hostinger's ~10-min
|
||||
background-process kill does not apply.
|
||||
|
||||
### Fixed
|
||||
- **`$sync_mode` guard in `Generation_Checkpoint_Runner::on_generate_tick()`**:
|
||||
when running under `--sync`, the internal `wp_schedule_single_event()` +
|
||||
`fire_cron_now()` reschedule is suppressed so the VPS's external loop cannot
|
||||
race with a competing background wp-cron tick. Existing async (cron/AJAX) paths
|
||||
are unaffected.
|
||||
- **Reaper timezone fix** in `Run_Reaper::sweep_stuck_stages()` and
|
||||
`sweep_stuck_runs()`: `$cutoff` was computed with `gmdate()` (UTC) while
|
||||
`updated_at` is stored via `current_time('mysql')` (server local time, UTC+8).
|
||||
Changed to `wp_date()` so the cutoff respects the site timezone. On this host
|
||||
the 8-hour delta could suppress reaping of a same-day-stalled run; the 44h-old
|
||||
run `ee6e7d91` was already far outside the threshold and unaffected.
|
||||
- **Scoped self-heal migration** in `DB_Migrations`: restores the low-stakes
|
||||
maintenance cron events (`prautoblogger_reap_orphan_research_rows`,
|
||||
`prautoblogger_collect_metrics`, `prautoblogger_sync_runware_models`) that
|
||||
vanished after the 06-15 deploy cycle. **`prautoblogger_daily_generation` is
|
||||
deliberately NOT re-registered** — the VPS systemd timer owns generation
|
||||
scheduling as of v0.22.1. Note: wp-cron reliability on this Hostinger host is
|
||||
suspect (background PHP killed ~10 min); maintenance crons are low-stakes and
|
||||
acceptable on this transport; generation is not.
|
||||
- **Sync-mode guard tests** (QA NF-1 remediation 2026-06-23): moved sync-mode
|
||||
tests to `GenerationCheckpointRunnerSyncModeTest.php`; fixed
|
||||
`test_orchestrate_tick_in_sync_mode_does_not_schedule_background_event` to
|
||||
return >= 1 idea from `orchestrate_only()` so execution reaches the sync guard
|
||||
at line ~181 (was vacuous -- previous stub returned `[]` causing early return);
|
||||
added `test_generate_tick_sync_mode_suppresses_reschedule` for the
|
||||
`on_generate_tick()` guard; async regression test preserved in same file.
|
||||
- **Root `CONTEXT.md`**: created domain glossary (run/run_stages/checkpoint/
|
||||
idea-queue/Generation_Lock) and execution mode comparison (async wp-cron vs.
|
||||
`--sync` VPS).
|
||||
- **`ARCHITECTURE.md` SS25**: added `WP-CLI (sync / VPS)` row to Entry Points table.
|
||||
|
||||
## [0.22.0] - 2026-06-16
|
||||
|
||||
### Changed
|
||||
- **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
|
||||
- **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`.
|
||||
|
||||
### 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.
|
||||
|
||||
## [0.20.1] - 2026-06-12
|
||||
|
||||
### Fixed
|
||||
- **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.20.0] - 2026-06-12
|
||||
|
||||
### Added
|
||||
@@ -521,6 +364,7 @@ and this project uses [Semantic Versioning](https://semver.org/).
|
||||
the catalog + OpenRouter static list. Breaking change: callers that expect a
|
||||
specific static list should note the new dynamic Runware side.
|
||||
|
||||
|
||||
### Fixed
|
||||
- Runware image generation now correctly uses the admin-selected model instead
|
||||
of silently falling back to FLUX.1 schnell (runware:100@1) for all non-schnell
|
||||
@@ -620,6 +464,7 @@ and this project uses [Semantic Versioning](https://semver.org/).
|
||||
### Fixed
|
||||
- Image prompt builder now properly delegates to static parser methods
|
||||
|
||||
|
||||
### Fixed
|
||||
- **Opik autoloader gap** — `class-autoloader.php` listed `services/` as a scan directory but not `services/opik/`, where all four Opik classes live (`Opik_Client`, `Opik_Trace_Context`, `Opik_Span_Queue`, `Opik_Dispatcher`). Composer's classmap (used in PHPUnit) covers `includes/` recursively so tests passed, but the WordPress runtime autoloader would fatal on first Opik class reference in production. Added `services/opik/` to the scan list.
|
||||
|
||||
|
||||
77
CONTEXT.md
77
CONTEXT.md
@@ -1,77 +0,0 @@
|
||||
# PRAutoBlogger — Domain Glossary
|
||||
|
||||
Quick reference for the generation pipeline domain model. For full design rationale
|
||||
see `ARCHITECTURE.md` (§21–§25) and `CONVENTIONS.md`.
|
||||
|
||||
---
|
||||
|
||||
## Core Domain Terms
|
||||
|
||||
**run**
|
||||
A single end-to-end article generation cycle: orchestrate → generate idea(s) →
|
||||
finalize. Represented as a row in `wp_prautoblogger_runs` (status, cost ceiling,
|
||||
timestamps). One run may produce multiple articles if the daily idea-count > 1.
|
||||
|
||||
**run\_stages** (`wp_prautoblogger_run_stages`)
|
||||
Sub-steps within a run (research, analysis, generation, publish). Each stage has
|
||||
its own status and can be replayed independently via `Rerun_Executor`. Generation
|
||||
checkpoint runs populate stages as Article_Worker progresses.
|
||||
|
||||
**checkpoint**
|
||||
The persistence boundary between pipeline ticks. After each tick the idea queue
|
||||
and run state are written to `wp_options` so a process kill cannot lose work. The
|
||||
next tick reads the queue option and continues. See §25 of ARCHITECTURE.md.
|
||||
|
||||
**idea queue** (`prautoblogger_article_queue` option)
|
||||
Serialized array of `Article_Idea` objects written by `on_orchestrate_tick()` and
|
||||
consumed one-at-a-time by successive `on_generate_tick()` calls. Deleted on
|
||||
finalize or cleanup.
|
||||
|
||||
**Generation\_Lock** (`wp_prautoblogger_generation_lock` or `wp_options`)
|
||||
DB mutex that prevents two concurrent runs. Acquired at the start of
|
||||
`on_orchestrate_tick()`, released by `Helpers::finalize()` or on error/halt.
|
||||
|
||||
---
|
||||
|
||||
## Execution Modes
|
||||
|
||||
### Async (wp-cron driven)
|
||||
|
||||
Default path for the Board "New Article" button and the plain
|
||||
`wp prautoblogger generate` WP-CLI command.
|
||||
|
||||
1. `kick_off()` schedules `prautoblogger_gen_orchestrate` and calls `fire_cron_now()`.
|
||||
2. `on_orchestrate_tick()` runs in a background PHP process, schedules
|
||||
`prautoblogger_gen_tick`, and calls `fire_cron_now()` to spawn the next process.
|
||||
3. Each `on_generate_tick()` spawns the next until the queue drains.
|
||||
|
||||
Limitation: Hostinger kills background PHP processes at ~10 min. Orchestrate tick
|
||||
(LLM-free) and individual generate ticks (one LLM call each) are both well within
|
||||
this limit; the checkpoint design is the mitigation.
|
||||
|
||||
### Sync (VPS-driven / `--sync`)
|
||||
|
||||
Used by the Coolify KVM8 VPS systemd timer via `infra/vps/orchestrate-generation.sh`
|
||||
in `peptide-e2e`. Introduced v0.22.1.
|
||||
|
||||
1. `run_sync()` sets `Generation_Checkpoint_Runner::$sync_mode = true`.
|
||||
2. Calls `on_orchestrate_tick()` **in-process** (no background spawn).
|
||||
3. Loops calling `on_generate_tick()` directly until queue empty or terminal state.
|
||||
4. `$sync_mode = true` suppresses all `wp_schedule_single_event` +
|
||||
`fire_cron_now` calls inside both tick methods, preventing a competing
|
||||
background cron chain from forming in parallel.
|
||||
|
||||
The VPS SSH session is held open; the process is not subject to Hostinger's
|
||||
background-kill constraint.
|
||||
|
||||
---
|
||||
|
||||
## Table Shorthand
|
||||
|
||||
Throughout the codebase `wp_prautoblogger_*` tables are referenced by their
|
||||
`$wpdb->` property names (e.g. `$wpdb->prab_runs`, `$wpdb->prab_run_stages`).
|
||||
Full table names are defined in `class-activator.php`.
|
||||
|
||||
---
|
||||
|
||||
*See also: `ARCHITECTURE.md` §25 (checkpoint design), `CONVENTIONS.md` (coding rules).*
|
||||
@@ -1,84 +0,0 @@
|
||||
# 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)*
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* 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 ) );
|
||||
@@ -164,11 +164,6 @@
|
||||
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' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -230,7 +225,22 @@
|
||||
} );
|
||||
}
|
||||
|
||||
/** Initialise the board poller on DOM ready. */
|
||||
$( function () {
|
||||
$board = $( '#prab-board' );
|
||||
if ( ! $board.length ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Inject the error banner after the heading.
|
||||
$errorBanner = $( '<div class="prab-board-error" role="alert">' + escHtml( cfg.strings.pollError || '' ) + '</div>' );
|
||||
$board.before( $errorBanner );
|
||||
|
||||
// Check if any generating card is present in the initial server render.
|
||||
var hasActive = ( $board.find( '[data-column="generating"] .prab-board-card' ).length > 0 );
|
||||
backoffFactor = hasActive ? 1 : 1; // Always start at base on first load.
|
||||
|
||||
schedulePoll( nextDelay( hasActive ) );
|
||||
} );
|
||||
|
||||
}( jQuery ) );
|
||||
|
||||
@@ -1,26 +1,23 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
"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/"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
# ADR-0001: Editorial illustration image style — pivot from comic to text-free illustration with caption-as-HTML
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-05-29
|
||||
- **Deciders:** CTO, Engineer PRAutoBlogger
|
||||
- **Supersedes:** —
|
||||
- **Superseded by:** —
|
||||
- **Related:** ARCHITECTURE.md §#20; CHANGELOG.md [0.16.0]; PR #146 (d00e746);
|
||||
`docs/proposals/2026-05-29-image-pipeline-in-plugin-brief.md`;
|
||||
ARCHITECTURE.md §#21 (v0.17.0 deterministic composer, the follow-on commit)
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
PRAutoBlogger generates two AI images (A/B pair) for each article and publishes
|
||||
them as featured images. From the initial comic-strip launch (commit b17ed47,
|
||||
`feat: switch to single-panel newspaper comic style for article images`) through
|
||||
v0.15.x, the image style was a **single-panel newspaper comic** — the rewriter
|
||||
LLM was asked to produce a comic gag or visual pun, and the result was appended
|
||||
to a style suffix and sent to the diffusion model.
|
||||
|
||||
By April 2026 two problems had accumulated:
|
||||
|
||||
1. **Text legibility.** Small diffusion models (FLUX.1 schnell is the default
|
||||
since v0.9.0 at ~$0.0006/image) cannot reliably render readable text inside
|
||||
an image. Comic panels depend on legible speech bubbles or captions burned
|
||||
into the image; both were routinely garbled (confirmed in the 2026-04-21 A/B
|
||||
finding documented in ARCHITECTURE.md §#21).
|
||||
|
||||
2. **Caption path already existed.** A separate HTML caption below the image had
|
||||
been extracted as early as commit 214f57b (`Separate comic captions from
|
||||
generated images`, 2026-04-18). The scene/caption split in
|
||||
`Image_Scene_Parser` was already live, meaning there was an established path
|
||||
for readable text that did not depend on the diffusion model rendering it.
|
||||
|
||||
Together these created pressure to stop asking the diffusion model to render
|
||||
text at all, and instead shift all readable copy to the HTML caption path that
|
||||
was already in place.
|
||||
|
||||
## Decision drivers
|
||||
|
||||
1. **Image quality on default model.** FLUX.1 schnell (the default since v0.9.0)
|
||||
produces garbled text in generated images. A style that depends on readable
|
||||
in-image text degrades silently on the model we actually ship.
|
||||
2. **Editorial trust signal.** Peptide Repo's editorial direction is
|
||||
science-informational; comic gags undercut the authority the site needs for
|
||||
health-adjacent content.
|
||||
3. **Reuse the existing caption path.** The scene/caption split and HTML caption
|
||||
path were already live; the change is a prompt rewrite + template substitution
|
||||
with no new infrastructure.
|
||||
4. **Admin configurability.** The style suffix was a single string appended to
|
||||
every prompt; a template with a named substitution token (`{{ topic_summary
|
||||
}}`) is more maintainable and admin-overridable without a code deploy.
|
||||
5. **Cost and provider neutrality.** The change is prompt-only; provider, model,
|
||||
and pricing are unchanged.
|
||||
|
||||
## Options considered
|
||||
|
||||
### Option A — Keep the comic style, fix text rendering per-model
|
||||
|
||||
Ask the rewriter LLM to omit text from comic panels (no speech bubbles, no
|
||||
caption burns), and describe a visual scene instead. Keep the style suffix
|
||||
pattern.
|
||||
|
||||
**Pros:**
|
||||
- Minimal code change — just a prompt edit.
|
||||
- Comic aesthetic is recognisable and differentiating.
|
||||
|
||||
**Cons:**
|
||||
- Does not solve the underlying mismatch: the style suffix is a static string
|
||||
that gives no per-article context to the diffusion model. The rewriter scene
|
||||
still has no slot to insert topic-specific imagery.
|
||||
- Comic gags require the LLM to invent a joke per peptide, which is inconsistent
|
||||
in tone.
|
||||
- Comic aesthetic conflicts with the editorial trust direction for health content.
|
||||
|
||||
### Option B — Pivot to text-free editorial scientific illustration + template substitution (chosen)
|
||||
|
||||
Change the rewriter system prompt so the LLM emits a concise 1-2 sentence
|
||||
topic/mechanism summary as the SCENE — one concrete centered focal subject, no
|
||||
text, no people-as-gag, no logos. Replace the style suffix with an editable
|
||||
template (`prautoblogger_image_style_template`) that contains a single
|
||||
`{{ topic_summary }}` token filled per-article by
|
||||
`PRAutoBlogger_Image_Template_Filler`. The CAPTION line from the scene/caption
|
||||
split continues to flow to the HTML-caption-below-image path unchanged.
|
||||
|
||||
**Pros:**
|
||||
- Diffusion model is never asked to render text; garbled-text failures are
|
||||
eliminated.
|
||||
- The editorial scientific illustration style is consistent with the site's
|
||||
authority positioning.
|
||||
- Template substitution gives admins a single editable field to control the
|
||||
entire visual style without touching the per-article rewriter logic.
|
||||
- Degradation is explicit: missing token → append + warning; empty summary →
|
||||
style-only prompt; a blank prompt is never sent to the provider.
|
||||
- Provider, model, and cost are unchanged.
|
||||
|
||||
**Cons:**
|
||||
- Breaks the comic gag aesthetic for any site running PRAutoBlogger in a
|
||||
context where that was intentional (low risk for this deployment).
|
||||
- The old `prautoblogger_image_style_suffix` option must be migrated
|
||||
(handled via one-time migration `prautoblogger_migrated_style_template_v0160`
|
||||
and mirroring to `_deprecated` for one cycle).
|
||||
|
||||
### Option C — Generate text overlays in PHP (deterministic composer only)
|
||||
|
||||
Skip the style change; solve legibility by compositing text onto images in PHP
|
||||
after the diffusion model runs.
|
||||
|
||||
**Pros:**
|
||||
- Diffusion model input is unchanged (comic style preserved).
|
||||
- Composited text is always readable regardless of model.
|
||||
|
||||
**Cons:**
|
||||
- Compositing a comic-style gag is much harder than compositing a caption band
|
||||
(arbitrary layout vs. a fixed panel at the bottom).
|
||||
- Does not address the editorial direction mismatch.
|
||||
- The deterministic PHP composer (ARCHITECTURE.md §#21) was already planned as
|
||||
a follow-on commit specifically for OG/social variants — pursuing it as the
|
||||
sole fix conflates two separate problems.
|
||||
|
||||
## Decision
|
||||
|
||||
**Option B** — pivot to text-free editorial scientific illustration with
|
||||
caption-as-HTML and template substitution.
|
||||
|
||||
This was shipped as v0.16.0 (2026-05-29, PR #146, commit d00e746). The changes
|
||||
are entirely in the prompt layer:
|
||||
|
||||
- `Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT` now asks for a concise
|
||||
topic/mechanism SCENE and keeps the CAPTION line for the HTML path.
|
||||
- `PRAutoBlogger_Image_Template_Filler::fill()` replaces the old
|
||||
`trim($scene . ' ' . $style_suffix)` concat at all three entry points
|
||||
(`build_article_prompt`, `build_source_prompt`, `build_fallback_prompt`).
|
||||
- New admin setting `prautoblogger_image_style_template` with
|
||||
single-token validation replaces the Style Suffix field.
|
||||
- One-time migration preserves the old suffix value for one cycle.
|
||||
|
||||
The deterministic PHP composer for OG/social branding (ARCHITECTURE.md §#21)
|
||||
was deliberately deferred to a follow-on commit (v0.17.0, #152) so that the
|
||||
prompt-only change could ship and be verified first.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Accepted benefits:**
|
||||
- Garbled-text image failures are eliminated on the default FLUX.1 schnell model.
|
||||
- The editorial scientific illustration style is consistent with the site's
|
||||
health-content positioning and sustainable across future model changes.
|
||||
- The template + token pattern makes style updates admin-configurable without
|
||||
code changes.
|
||||
|
||||
**Accepted costs:**
|
||||
- The comic aesthetic is gone and cannot be restored by toggling a setting;
|
||||
switching back requires a prompt and template change.
|
||||
- The `prautoblogger_image_style_suffix` option is deprecated; the migration
|
||||
runs once on upgrade and the old option is not deleted until a future cycle.
|
||||
|
||||
**Follow-up created:**
|
||||
- ARCHITECTURE.md §#21 (v0.17.0): deterministic PHP image composer to composite
|
||||
branded OG and square card variants — necessary because the social/OG image
|
||||
travels without the HTML caption and therefore needs text burned in by PHP,
|
||||
not by the diffusion model.
|
||||
@@ -1,26 +0,0 @@
|
||||
# PRAutoBlogger — App-Local ADR Log
|
||||
|
||||
This folder is the per-repo decision log for the **PRAutoBlogger** plugin.
|
||||
It records architectural choices that affect this codebase alone.
|
||||
|
||||
**Cross-cutting decisions** (spanning multiple plugins, shared infra, or
|
||||
CI/deploy standards) live centrally in
|
||||
`Peptide Repo CTO/docs/engineering/decisions/`.
|
||||
|
||||
For the routing rule, required sections, and status lifecycle, read the
|
||||
canonical process document:
|
||||
`Peptide Repo CTO/docs/engineering/ADR-PROCESS.md`
|
||||
|
||||
The canonical template for new ADRs is:
|
||||
`Peptide Repo CTO/docs/engineering/adr-template.md`
|
||||
|
||||
## Numbering
|
||||
|
||||
ADRs in this folder use a per-repo zero-padded sequence starting at `0001`.
|
||||
A new ADR always gets the next unused number; numbers are never reused.
|
||||
|
||||
## Index
|
||||
|
||||
| Number | Title | Status | Date |
|
||||
|--------|-------|--------|------|
|
||||
| [0001](0001-editorial-illustration-image-style.md) | Editorial illustration image style — pivot from comic to text-free illustration with caption-as-HTML | Accepted | 2026-05-29 |
|
||||
@@ -270,14 +270,84 @@ 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 ) {
|
||||
return PRAutoBlogger_Settings_Sanitizer::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' );
|
||||
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 );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ class PRAutoBlogger_Board_Data_Provider {
|
||||
array(
|
||||
'post_type' => 'post',
|
||||
'post_status' => 'draft',
|
||||
'posts_per_page' => max( 5, (int) get_option( 'prautoblogger_board_column_limit', PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT ) ),
|
||||
'posts_per_page' => 20,
|
||||
'meta_query' => array(
|
||||
array(
|
||||
'key' => '_prautoblogger_generated',
|
||||
@@ -144,7 +144,7 @@ class PRAutoBlogger_Board_Data_Provider {
|
||||
array(
|
||||
'post_type' => 'post',
|
||||
'post_status' => 'publish',
|
||||
'posts_per_page' => max( 5, (int) get_option( 'prautoblogger_board_column_limit', PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT ) ),
|
||||
'posts_per_page' => 20,
|
||||
'date_query' => array(
|
||||
array(
|
||||
'after' => $date_after,
|
||||
|
||||
@@ -76,9 +76,8 @@ class PRAutoBlogger_Board_Gen_Log_Query {
|
||||
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 ) )
|
||||
LIMIT 10",
|
||||
$since
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
@@ -149,9 +148,8 @@ class PRAutoBlogger_Board_Gen_Log_Query {
|
||||
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 ) )
|
||||
LIMIT 20",
|
||||
$since
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
@@ -4,27 +4,27 @@ 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.
|
||||
* 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.
|
||||
* backing off to 2× 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
|
||||
* - 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).
|
||||
* @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 — §Board (kanban dashboard).
|
||||
*/
|
||||
class PRAutoBlogger_Board_Page {
|
||||
|
||||
@@ -48,7 +48,7 @@ class PRAutoBlogger_Board_Page {
|
||||
* $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.
|
||||
* 'admin_page_prautoblogger-board'. See ARCHITECTURE.md §Board.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
@@ -128,13 +128,6 @@ class PRAutoBlogger_Board_Page {
|
||||
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 ) );
|
||||
@@ -145,7 +138,6 @@ class PRAutoBlogger_Board_Page {
|
||||
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,
|
||||
@@ -159,12 +151,8 @@ class PRAutoBlogger_Board_Page {
|
||||
'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' ),
|
||||
'pollError' => __( 'Board update failed — retrying.', 'prautoblogger' ),
|
||||
'humanModified' => __( 'Human-modified', 'prautoblogger' ),
|
||||
),
|
||||
)
|
||||
);
|
||||
@@ -175,7 +163,7 @@ class PRAutoBlogger_Board_Page {
|
||||
*
|
||||
* Nonce: `prautoblogger_board`. Requires `manage_options` capability.
|
||||
*
|
||||
* Side effects: DB reads only -- no writes.
|
||||
* Side effects: DB reads only — no writes.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
<?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,14 +8,20 @@ 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(
|
||||
@@ -37,12 +43,11 @@ class PRAutoBlogger_Ideas_Browser {
|
||||
$paged = isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1;
|
||||
$type_filter = isset( $_GET['type'] ) ? sanitize_key( $_GET['type'] ) : '';
|
||||
|
||||
$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();
|
||||
$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();
|
||||
|
||||
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/ideas-browser.php';
|
||||
}
|
||||
@@ -69,26 +74,23 @@ 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 = PRAutoBlogger_Ideas_Browser_Query::load_idea_data( $idea_id );
|
||||
$idea_data = self::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( $status_prefix . 'data_' . $idea_id, $idea_data, $status_ttl );
|
||||
set_transient( self::STATUS_PREFIX . 'data_' . $idea_id, $idea_data, self::STATUS_TTL );
|
||||
set_transient(
|
||||
$status_prefix . $idea_id,
|
||||
self::STATUS_PREFIX . $idea_id,
|
||||
array(
|
||||
'status' => 'running',
|
||||
'stage' => __( 'Starting generation…', 'prautoblogger' ),
|
||||
'started' => time(),
|
||||
),
|
||||
$status_ttl
|
||||
self::STATUS_TTL
|
||||
);
|
||||
|
||||
// Schedule background cron — passes idea_id as argument.
|
||||
@@ -122,7 +124,7 @@ class PRAutoBlogger_Ideas_Browser {
|
||||
}
|
||||
|
||||
$idea_id = absint( $_POST['idea_id'] ?? 0 );
|
||||
$status = get_transient( PRAutoBlogger_Ideas_Browser_Query::STATUS_PREFIX . $idea_id );
|
||||
$status = get_transient( self::STATUS_PREFIX . $idea_id );
|
||||
if ( ! is_array( $status ) ) {
|
||||
wp_send_json_success( array( 'status' => 'idle' ) );
|
||||
return;
|
||||
@@ -147,19 +149,16 @@ class PRAutoBlogger_Ideas_Browser {
|
||||
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
@set_time_limit( 300 );
|
||||
|
||||
$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 );
|
||||
$key = self::STATUS_PREFIX . $idea_id;
|
||||
$idea_data = get_transient( self::STATUS_PREFIX . 'data_' . $idea_id );
|
||||
if ( ! is_array( $idea_data ) ) {
|
||||
set_transient(
|
||||
$key,
|
||||
array(
|
||||
'status' => 'error',
|
||||
'status' => 'error',
|
||||
'message' => 'Idea data expired.',
|
||||
),
|
||||
$ttl
|
||||
self::STATUS_TTL
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -168,10 +167,10 @@ class PRAutoBlogger_Ideas_Browser {
|
||||
set_transient(
|
||||
$key,
|
||||
array(
|
||||
'status' => 'error',
|
||||
'status' => 'error',
|
||||
'message' => 'Another generation is running.',
|
||||
),
|
||||
$ttl
|
||||
self::STATUS_TTL
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -181,7 +180,7 @@ class PRAutoBlogger_Ideas_Browser {
|
||||
$cost_tracker = new PRAutoBlogger_Cost_Tracker();
|
||||
$cost_tracker->set_run_id( wp_generate_uuid4() );
|
||||
|
||||
PRAutoBlogger_Ideas_Browser_Query::update_idea_stage( $idea_id, __( 'Generating article draft…', 'prautoblogger' ) );
|
||||
self::update_idea_stage( $idea_id, __( 'Generating article draft…', 'prautoblogger' ) );
|
||||
$worker = new PRAutoBlogger_Article_Worker( $cost_tracker );
|
||||
$result = $worker->generate( $idea );
|
||||
|
||||
@@ -189,7 +188,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 = PRAutoBlogger_Ideas_Browser_Query::find_post_by_run_id( $cost_tracker->get_run_id() );
|
||||
$post_id = self::find_post_by_run_id( $cost_tracker->get_run_id() );
|
||||
|
||||
set_transient(
|
||||
$key,
|
||||
@@ -200,7 +199,7 @@ class PRAutoBlogger_Ideas_Browser {
|
||||
'cost' => $result['cost'],
|
||||
'post_id' => $post_id,
|
||||
),
|
||||
$ttl
|
||||
self::STATUS_TTL
|
||||
);
|
||||
} catch ( \Throwable $e ) {
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
@@ -210,14 +209,129 @@ class PRAutoBlogger_Ideas_Browser {
|
||||
set_transient(
|
||||
$key,
|
||||
array(
|
||||
'status' => 'error',
|
||||
'status' => 'error',
|
||||
'message' => $e->getMessage(),
|
||||
),
|
||||
$ttl
|
||||
self::STATUS_TTL
|
||||
);
|
||||
}
|
||||
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
delete_transient( $prefix . 'data_' . $idea_id );
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,194 +35,282 @@ class PRAutoBlogger_Settings_Fields_Extended {
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function get_fields(): array {
|
||||
return array_merge(
|
||||
return array(
|
||||
// ── Sources (extended) ──────────────────────────────────────
|
||||
array(
|
||||
// ── 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' ),
|
||||
),
|
||||
'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' ),
|
||||
),
|
||||
'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' ),
|
||||
// ── 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' ),
|
||||
),
|
||||
// ── 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' ),
|
||||
// ── 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' )
|
||||
),
|
||||
),
|
||||
PRAutoBlogger_Settings_Fields_Images::get_fields()
|
||||
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' ),
|
||||
),
|
||||
|
||||
// ── 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' ),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
<?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' ),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
<?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 );
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,6 @@ declare(strict_types=1);
|
||||
*/
|
||||
class PRAutoBlogger_WP_CLI_Commands {
|
||||
|
||||
/**
|
||||
* Max generate-tick iterations for the --sync loop (covers daily_article_target = 1
|
||||
* with ample headroom; prevents infinite loops if the queue never drains).
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private const SYNC_MAX_TICKS = 20;
|
||||
|
||||
/**
|
||||
* Register WP-CLI commands.
|
||||
*
|
||||
@@ -26,22 +18,7 @@ class PRAutoBlogger_WP_CLI_Commands {
|
||||
return;
|
||||
}
|
||||
|
||||
\WP_CLI::add_command(
|
||||
'prautoblogger generate',
|
||||
array( self::class, 'generate_command' ),
|
||||
array(
|
||||
'shortdesc' => 'Queue (or synchronously run) a new article generation run',
|
||||
'synopsis' => array(
|
||||
array(
|
||||
'type' => 'flag',
|
||||
'name' => 'sync',
|
||||
'description' => 'Run the full pipeline synchronously in this process (VPS orchestrator mode). Blocks until complete.',
|
||||
'optional' => true,
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
// Command: wp prautoblogger opik:eval
|
||||
\WP_CLI::add_command(
|
||||
'prautoblogger opik:eval',
|
||||
array( self::class, 'opik_eval_command' ),
|
||||
@@ -66,148 +43,6 @@ class PRAutoBlogger_WP_CLI_Commands {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue or synchronously run a new article generation run.
|
||||
*
|
||||
* Without --sync: routes through kick_off() (same as the board "New Article"
|
||||
* button). Returns immediately; all pipeline work happens in cron.
|
||||
*
|
||||
* With --sync: runs the pipeline synchronously in this SSH/CLI process.
|
||||
* 1. Acquires the generation lock (skips if a live run is in progress).
|
||||
* 2. Calls on_orchestrate_tick() to collect/score ideas in-process.
|
||||
* 3. Loops calling on_generate_tick() until the queue is empty or the run
|
||||
* reaches a terminal state, up to SYNC_MAX_TICKS iterations.
|
||||
* 4. Emits a JSON summary line on stdout and exits 0 (success) or 1 (error).
|
||||
*
|
||||
* The --sync path suppresses wp-cron reschedule inside on_generate_tick() so
|
||||
* the external loop cannot race with a background cron tick.
|
||||
*
|
||||
* ## OPTIONS
|
||||
*
|
||||
* [--sync]
|
||||
* : Run synchronously (VPS orchestrator mode).
|
||||
*
|
||||
* ## EXAMPLES
|
||||
*
|
||||
* wp prautoblogger generate
|
||||
* wp prautoblogger generate --sync
|
||||
*
|
||||
* @param array $args Positional args (unused).
|
||||
* @param array $assoc_args Associative args: sync.
|
||||
* @return void
|
||||
*/
|
||||
public static function generate_command( array $args, array $assoc_args ): void {
|
||||
if ( isset( $assoc_args['sync'] ) ) {
|
||||
self::run_sync();
|
||||
return;
|
||||
}
|
||||
|
||||
PRAutoBlogger_Generation_Checkpoint_Runner::kick_off();
|
||||
\WP_CLI::success( 'Generation queued on chained-cron checkpoints. Check the board or Activity Log for progress.' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full pipeline synchronously in this process (--sync mode).
|
||||
*
|
||||
* Called only from generate_command(); never from wp-cron or AJAX paths.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function run_sync(): void {
|
||||
// Honour ignore_user_abort so a dropped SSH session does not kill us.
|
||||
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
@ignore_user_abort( true );
|
||||
|
||||
// Check for a live (non-expired) lock. Generation_Lock::acquire() already
|
||||
// cleans expired locks on its DELETE pass, so if acquire() returns false
|
||||
// there is a genuinely live concurrent run — skip cleanly.
|
||||
if ( ! PRAutoBlogger_Generation_Lock::is_locked() ) {
|
||||
// Lock is not held; proceed. acquire() is called inside on_orchestrate_tick().
|
||||
} else {
|
||||
$acquired_at = PRAutoBlogger_Generation_Lock::get_acquired_at();
|
||||
$age = null !== $acquired_at ? ( time() - $acquired_at ) : HOUR_IN_SECONDS;
|
||||
if ( $age < HOUR_IN_SECONDS ) {
|
||||
\WP_CLI::log(
|
||||
json_encode(
|
||||
array(
|
||||
'status' => 'skipped',
|
||||
'reason' => 'run_in_progress',
|
||||
'lock_age_s' => $age,
|
||||
)
|
||||
)
|
||||
);
|
||||
exit( 0 );
|
||||
}
|
||||
// Expired lock — on_orchestrate_tick()'s acquire() will clean it.
|
||||
}
|
||||
|
||||
// Enable sync-mode: on_generate_tick() will skip the wp-cron reschedule.
|
||||
PRAutoBlogger_Generation_Checkpoint_Runner::set_sync_mode( true );
|
||||
|
||||
\WP_CLI::log( 'PRAB-SYNC: starting orchestrate tick' );
|
||||
PRAutoBlogger_Generation_Checkpoint_Runner::on_orchestrate_tick();
|
||||
|
||||
// If the orchestrate tick failed to acquire the lock or found no ideas,
|
||||
// the RUN_ID option will be absent. Detect and exit cleanly.
|
||||
$run_id = (string) get_option( 'prautoblogger_checkpoint_run_id', '' );
|
||||
if ( '' === $run_id ) {
|
||||
$queue = get_option( 'prautoblogger_article_queue' );
|
||||
if ( ! is_array( $queue ) || empty( $queue['ideas'] ) ) {
|
||||
\WP_CLI::log(
|
||||
json_encode(
|
||||
array(
|
||||
'status' => 'done',
|
||||
'generated' => 0,
|
||||
'reason' => 'no_ideas_or_lock_failed',
|
||||
)
|
||||
)
|
||||
);
|
||||
exit( 0 );
|
||||
}
|
||||
}
|
||||
|
||||
// Tick loop: drive generate ticks synchronously.
|
||||
$ticks = 0;
|
||||
while ( $ticks < self::SYNC_MAX_TICKS ) {
|
||||
$queue = get_option( 'prautoblogger_article_queue' );
|
||||
if ( ! is_array( $queue ) || empty( $queue['ideas'] ) ) {
|
||||
break; // Queue drained or finalized.
|
||||
}
|
||||
++$ticks;
|
||||
\WP_CLI::log( sprintf( 'PRAB-SYNC: generate tick %d (ideas remaining: %d)', $ticks, count( $queue['ideas'] ) ) );
|
||||
PRAutoBlogger_Generation_Checkpoint_Runner::on_generate_tick();
|
||||
}
|
||||
|
||||
PRAutoBlogger_Generation_Checkpoint_Runner::set_sync_mode( false );
|
||||
|
||||
if ( $ticks >= self::SYNC_MAX_TICKS ) {
|
||||
\WP_CLI::warning( 'PRAB-SYNC: hit max-tick guard (' . self::SYNC_MAX_TICKS . '). Queue may not be fully drained.' );
|
||||
}
|
||||
|
||||
// Read final status transient for summary.
|
||||
$status = get_transient( 'prautoblogger_generation_status' );
|
||||
$result = is_array( $status ) ? $status : array();
|
||||
|
||||
$summary = array(
|
||||
'status' => 'done',
|
||||
'generated' => (int) ( $result['generated'] ?? 0 ),
|
||||
'published' => (int) ( $result['published'] ?? 0 ),
|
||||
'rejected' => (int) ( $result['rejected'] ?? 0 ),
|
||||
'cost_usd' => round( (float) ( $result['cost'] ?? 0.0 ), 4 ),
|
||||
'ticks' => $ticks,
|
||||
);
|
||||
|
||||
\WP_CLI::log( json_encode( $summary ) );
|
||||
|
||||
if ( 'error' === ( $result['status'] ?? '' ) ) {
|
||||
\WP_CLI::error( 'PRAB-SYNC: generation completed with error: ' . ( $result['message'] ?? 'unknown' ), false );
|
||||
exit( 1 );
|
||||
}
|
||||
|
||||
\WP_CLI::success( 'PRAB-SYNC: generation complete.' );
|
||||
exit( 0 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Run eval on the frozen dataset.
|
||||
*
|
||||
|
||||
@@ -80,10 +80,7 @@ class PRAutoBlogger_Activator {
|
||||
'prautoblogger_table_borders' => '1',
|
||||
'prautoblogger_schedule_time' => '03:00',
|
||||
'prautoblogger_log_level' => 'info',
|
||||
'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,
|
||||
'prautoblogger_image_nsfw_retry' => '1',
|
||||
);
|
||||
|
||||
foreach ( $defaults as $key => $value ) {
|
||||
|
||||
@@ -125,32 +125,5 @@ class PRAutoBlogger_DB_Migrations {
|
||||
}
|
||||
update_option( 'prautoblogger_migrated_enc_prefix', '1' );
|
||||
}
|
||||
|
||||
// v0.22.1 self-heal: restore low-stakes maintenance cron events that were
|
||||
// lost when the WP-Cron schedule vanished after the 06-15 deploy cycle.
|
||||
// NOTE: wp-cron reliability on this Hostinger host is suspect (background
|
||||
// PHP processes are killed at ~10 min). These low-stakes events (metrics,
|
||||
// reaper, Runware sync) are acceptable on wp-cron; daily GENERATION is NOT
|
||||
// re-registered here — the VPS systemd timer owns generation scheduling.
|
||||
// The prautoblogger_migrated_schedule_tz_v082 guard is intentionally NOT
|
||||
// re-used here; this migration targets the missing events themselves.
|
||||
if ( ! get_option( 'prautoblogger_migrated_maintenance_crons_v0221' ) ) {
|
||||
// Re-register maintenance crons only — daily_generation deliberately excluded.
|
||||
// Activator::schedule_cron() uses wp_next_scheduled() guards so it is
|
||||
// idempotent and will not overwrite events that are already present.
|
||||
if ( ! wp_next_scheduled( 'prautoblogger_reap_orphan_research_rows' ) ) {
|
||||
$tomorrow_reap = strtotime( 'tomorrow 03:15' );
|
||||
if ( false !== $tomorrow_reap ) {
|
||||
wp_schedule_event( (int) $tomorrow_reap, 'daily', 'prautoblogger_reap_orphan_research_rows' );
|
||||
}
|
||||
}
|
||||
if ( ! wp_next_scheduled( 'prautoblogger_collect_metrics' ) ) {
|
||||
wp_schedule_event( time() + HOUR_IN_SECONDS, 'prautoblogger_six_hours', 'prautoblogger_collect_metrics' );
|
||||
}
|
||||
if ( ! wp_next_scheduled( 'prautoblogger_sync_runware_models' ) ) {
|
||||
wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'prautoblogger_sync_runware_models' );
|
||||
}
|
||||
update_option( 'prautoblogger_migrated_maintenance_crons_v0221', '1' );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,21 +85,68 @@ class PRAutoBlogger_Executor {
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron handler: kick off the chained-cron orchestration tick (v0.21.0, M4).
|
||||
* Cron handler: runs pipeline orchestration + first article.
|
||||
*
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
public function on_manual_generation(): void {
|
||||
PRAutoBlogger_Generation_Checkpoint_Runner::on_orchestrate_tick();
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
/** Cron handler: process next queued article. Chained by Pipeline_Runner. */
|
||||
|
||||
@@ -115,8 +115,7 @@ class PRAutoBlogger_Pipeline_Schema_Installer {
|
||||
updated_at DATETIME NOT NULL,
|
||||
finished_at DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (run_id),
|
||||
KEY status (status),
|
||||
KEY started_at (started_at)
|
||||
KEY status (status)
|
||||
) {$charset_collate};";
|
||||
|
||||
// Per-run per-stage state rows. The idempotency key is
|
||||
|
||||
@@ -146,10 +146,6 @@ class PRAutoBlogger {
|
||||
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' ) );
|
||||
add_action( 'prautoblogger_reap_orphan_research_rows', array( 'PRAutoBlogger_Research_Reaper', 'on_cron' ) );
|
||||
add_action( 'prautoblogger_reap_orphan_research_rows', array( 'PRAutoBlogger_Run_Reaper', 'on_cron' ) );
|
||||
|
||||
@@ -71,8 +71,7 @@ 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_type (analyzed_at, analysis_type)
|
||||
KEY analyzed_at (analyzed_at)
|
||||
) {$charset_collate};";
|
||||
|
||||
// Generation log — every API call with cost tracking. `run_id`
|
||||
@@ -101,9 +100,7 @@ class PRAutoBlogger_Schema_Installer {
|
||||
KEY post_id (post_id),
|
||||
KEY run_id (run_id),
|
||||
KEY created_at (created_at),
|
||||
KEY stage (stage),
|
||||
KEY run_id_post_id (run_id, post_id),
|
||||
KEY created_status (created_at, response_status)
|
||||
KEY stage (stage)
|
||||
) {$charset_collate};";
|
||||
|
||||
// Content scores — post performance metrics for self-improvement.
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
<?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,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
<?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';
|
||||
|
||||
/**
|
||||
* When true, on_generate_tick() skips wp-cron rescheduling so the VPS
|
||||
* orchestrator's external loop is the sole driver. Set via set_sync_mode().
|
||||
* Never true on the wp-cron path; reset to false after the sync run.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
private static bool $sync_mode = false;
|
||||
|
||||
/**
|
||||
* Enable or disable synchronous-driver mode.
|
||||
* Called by WP_CLI_Commands::run_sync() before and after the tick loop.
|
||||
*
|
||||
* @param bool $enabled True to suppress internal cron reschedule.
|
||||
* @return void
|
||||
*/
|
||||
public static function set_sync_mode( bool $enabled ): void {
|
||||
self::$sync_mode = $enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 )
|
||||
)
|
||||
);
|
||||
|
||||
// In sync mode the VPS tick loop is the external driver; skip
|
||||
// wp-cron reschedule to prevent a competing background tick.
|
||||
// on_generate_tick() has the same guard for subsequent reschedules.
|
||||
if ( ! self::$sync_mode ) {
|
||||
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 );
|
||||
// In sync mode the VPS tick loop is the external driver; skip
|
||||
// wp-cron reschedule to prevent a competing background tick.
|
||||
if ( ! self::$sync_mode ) {
|
||||
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,10 +11,9 @@ declare(strict_types=1);
|
||||
* API is unavailable.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Pipeline_Runner (step 3).
|
||||
* Dependencies: Semantic_Dedup, Keyword_Extractor, WP_Query.
|
||||
* Dependencies: Semantic_Dedup, WP_Query.
|
||||
*
|
||||
* @see core/class-semantic-dedup.php — Embedding + keyword dedup engine.
|
||||
* @see core/class-keyword-extractor.php — Keyword tokeniser (fallback dedup).
|
||||
* @see core/class-semantic-dedup.php — Embedding + keyword dedup engine.
|
||||
* @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.
|
||||
@@ -68,7 +67,7 @@ class PRAutoBlogger_Idea_Scorer {
|
||||
|
||||
if ( ! $this->skip_dedup ) {
|
||||
$topic = $result->get_topic();
|
||||
$topic_keywords = PRAutoBlogger_Keyword_Extractor::extract( $topic );
|
||||
$topic_keywords = $this->extract_meaningful_keywords( $topic );
|
||||
|
||||
if ( $dedup->is_batch_duplicate( $topic, $topic_keywords, $accepted_keyword_sets ) ) {
|
||||
++$deduped_count;
|
||||
@@ -166,6 +165,133 @@ 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.
|
||||
*
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
<?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,19 +168,6 @@ 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,41 +103,4 @@ 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,9 +133,7 @@ class PRAutoBlogger_Run_Reaper {
|
||||
* @param int $seconds Expected seconds (default 300).
|
||||
*/
|
||||
$expected = (int) apply_filters( 'prautoblogger_filter_stage_expected_seconds', self::EXPECTED_STAGE_SECONDS );
|
||||
// Use wp_date() so the cutoff is expressed in the site's local timezone,
|
||||
// matching the updated_at values written by current_time('mysql') (v0.22.1 TZ fix).
|
||||
$cutoff = wp_date( 'Y-m-d H:i:s', time() - ( 2 * $expected ) );
|
||||
$cutoff = gmdate( 'Y-m-d H:i:s', time() - ( 2 * $expected ) );
|
||||
$now = current_time( 'mysql' );
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
@@ -171,8 +169,7 @@ class PRAutoBlogger_Run_Reaper {
|
||||
* @param int $seconds Expected seconds (default 1800).
|
||||
*/
|
||||
$expected = (int) apply_filters( 'prautoblogger_filter_expected_run_seconds', self::EXPECTED_RUN_SECONDS );
|
||||
// Use wp_date() so the cutoff matches updated_at's local-timezone storage (v0.22.1 TZ fix).
|
||||
$cutoff = wp_date( 'Y-m-d H:i:s', time() - ( 2 * $expected ) );
|
||||
$cutoff = gmdate( 'Y-m-d H:i:s', time() - ( 2 * $expected ) );
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$stuck = $wpdb->get_col(
|
||||
|
||||
@@ -204,7 +204,7 @@ class PRAutoBlogger_Semantic_Dedup {
|
||||
return false;
|
||||
}
|
||||
foreach ( $this->recent_titles as $title ) {
|
||||
$title_keywords = PRAutoBlogger_Keyword_Extractor::extract( $title );
|
||||
$title_keywords = $this->extract_meaningful_keywords( $title );
|
||||
$overlap = count( array_intersect( $topic_keywords, $title_keywords ) );
|
||||
if ( $overlap / count( $topic_keywords ) >= 0.6 ) {
|
||||
return true;
|
||||
@@ -241,4 +241,131 @@ 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 ) );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
<?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,7 +262,56 @@ class PRAutoBlogger_OpenRouter_Image_Batch {
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function build_request_body( string $prompt, int $width, int $height, string $model ): array {
|
||||
$aspect = PRAutoBlogger_Image_Aspect_Ratio::snap( $width, $height );
|
||||
$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'];
|
||||
|
||||
return array(
|
||||
'model' => $model,
|
||||
|
||||
@@ -233,10 +233,51 @@ 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 = PRAutoBlogger_Image_Aspect_Ratio::snap( $width, $height );
|
||||
$aspect = $this->snap_aspect_ratio( $width, $height );
|
||||
|
||||
return array(
|
||||
'model' => $model,
|
||||
@@ -255,6 +296,29 @@ 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.
|
||||
*
|
||||
|
||||
@@ -2,7 +2,19 @@
|
||||
// phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
declare(strict_types=1);
|
||||
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions -- curl_multi required for parallel image generation; no WP HTTP equivalent.
|
||||
// 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
|
||||
|
||||
/**
|
||||
* Parallel image generation via curl_multi for Runware.
|
||||
|
||||
@@ -178,14 +178,71 @@ class PRAutoBlogger_Runware_Image_Provider implements PRAutoBlogger_Image_Provid
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* Delegates to PRAutoBlogger_Runware_Validator (extracted for 300-line compliance).
|
||||
*
|
||||
* @see providers/class-runware-image-validator.php
|
||||
* 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 function validate_credentials_detailed(): array {
|
||||
return PRAutoBlogger_Runware_Validator::validate( $this->support );
|
||||
}
|
||||
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' ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the JSON task-array body for a single imageInference request.
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
<?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.22.1
|
||||
* Version: 0.20.0
|
||||
* Requires at least: 6.0
|
||||
* Requires PHP: 7.4
|
||||
* Author: PeptideRepo
|
||||
@@ -35,20 +35,8 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
| Defined here so every file in the plugin can reference paths, versions,
|
||||
| and limits without magic strings.
|
||||
*/
|
||||
define( 'PRAUTOBLOGGER_VERSION', '0.22.1' );
|
||||
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_VERSION', '0.20.0' );
|
||||
define( 'PRAUTOBLOGGER_DB_VERSION', '1.3.0' );
|
||||
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
|
||||
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||
define( 'PRAUTOBLOGGER_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
|
||||
|
||||
@@ -1,252 +0,0 @@
|
||||
<?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 );
|
||||
@@ -98,16 +98,7 @@ function prab_render_board_card( array $card, string $column_key ): void {
|
||||
<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' ); ?>">
|
||||
|
||||
|
||||
@@ -72,11 +72,10 @@ $base_url = admin_url( 'admin.php?page=prautoblogger-ideas' );
|
||||
<tbody>
|
||||
<?php foreach ( $rows as $row ) : ?>
|
||||
<?php
|
||||
$decoded = json_decode( $row['metadata_json'] ?? '{}', true );
|
||||
$meta = is_array( $decoded ) ? $decoded : array();
|
||||
$meta = json_decode( $row['metadata_json'] ?? '{}', true ) ?: [];
|
||||
$suggested = $meta['suggested_title'] ?? '';
|
||||
$key_points = $meta['key_points'] ?? array();
|
||||
$keywords = $meta['target_keywords'] ?? array();
|
||||
$key_points = $meta['key_points'] ?? [];
|
||||
$keywords = $meta['target_keywords'] ?? [];
|
||||
$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'] );
|
||||
@@ -165,18 +164,14 @@ $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(
|
||||
array(
|
||||
'base' => add_query_arg( 'paged', '%#%' ),
|
||||
'format' => '',
|
||||
'current' => $paged,
|
||||
'total' => $total_pages,
|
||||
'prev_text' => '«',
|
||||
'next_text' => '»',
|
||||
)
|
||||
)
|
||||
);
|
||||
echo wp_kses_post( paginate_links( [
|
||||
'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 = array(
|
||||
$level_counts = [
|
||||
'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,20 +46,7 @@ $level_counts = array(
|
||||
<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(
|
||||
array(
|
||||
'level' => $lv,
|
||||
's' => $search,
|
||||
'paged' => 1,
|
||||
),
|
||||
$base_url
|
||||
)
|
||||
);
|
||||
?>
|
||||
"
|
||||
<a href="<?php echo esc_url( add_query_arg( [ '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>
|
||||
@@ -132,18 +119,14 @@ $level_counts = array(
|
||||
<?php if ( $total_pages > 1 ) : ?>
|
||||
<div class="ab-log-pagination">
|
||||
<?php
|
||||
echo wp_kses_post(
|
||||
paginate_links(
|
||||
array(
|
||||
'base' => add_query_arg( 'paged', '%#%' ),
|
||||
'format' => '',
|
||||
'current' => $paged,
|
||||
'total' => $total_pages,
|
||||
'prev_text' => '«',
|
||||
'next_text' => '»',
|
||||
)
|
||||
)
|
||||
);
|
||||
echo wp_kses_post( paginate_links( [
|
||||
'base' => add_query_arg( 'paged', '%#%' ),
|
||||
'format' => '',
|
||||
'current' => $paged,
|
||||
'total' => $total_pages,
|
||||
'prev_text' => '«',
|
||||
'next_text' => '»',
|
||||
] ) );
|
||||
?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -11,7 +11,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$meta = array(
|
||||
$meta = [
|
||||
'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 = array(
|
||||
'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'] ? $meta['generated_at'] : __( 'Unknown', 'prautoblogger' ) ); ?>
|
||||
<?php echo esc_html( $meta['generated_at'] ?: __( 'Unknown', 'prautoblogger' ) ); ?>
|
||||
</p>
|
||||
<p>
|
||||
<strong><?php esc_html_e( 'Topic:', 'prautoblogger' ); ?></strong>
|
||||
<?php echo esc_html( $meta['topic'] ? $meta['topic'] : '—' ); ?>
|
||||
<?php echo esc_html( $meta['topic'] ?: '—' ); ?>
|
||||
</p>
|
||||
<p>
|
||||
<strong><?php esc_html_e( 'Type:', 'prautoblogger' ); ?></strong>
|
||||
<?php echo esc_html( ucfirst( $meta['type'] ? $meta['type'] : 'article' ) ); ?>
|
||||
<?php echo esc_html( ucfirst( $meta['type'] ?: 'article' ) ); ?>
|
||||
</p>
|
||||
<p>
|
||||
<strong><?php esc_html_e( 'Model:', 'prautoblogger' ); ?></strong>
|
||||
<?php echo esc_html( $meta['model'] ? $meta['model'] : '—' ); ?>
|
||||
<?php echo esc_html( $meta['model'] ?: '—' ); ?>
|
||||
</p>
|
||||
<p>
|
||||
<strong><?php esc_html_e( 'Pipeline:', 'prautoblogger' ); ?></strong>
|
||||
@@ -47,8 +47,8 @@ $meta = array(
|
||||
</p>
|
||||
<p>
|
||||
<strong><?php esc_html_e( 'Editor Verdict:', 'prautoblogger' ); ?></strong>
|
||||
<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 class="prautoblogger-verdict-<?php echo esc_attr( $meta['verdict'] ?: 'unknown' ); ?>">
|
||||
<?php echo esc_html( ucfirst( $meta['verdict'] ?: __( 'Unknown', 'prautoblogger' ) ) ); ?>
|
||||
</span>
|
||||
</p>
|
||||
<?php if ( $meta['quality_score'] ) : ?>
|
||||
@@ -69,7 +69,7 @@ $meta = array(
|
||||
<?php if ( $meta['keywords'] ) : ?>
|
||||
<p>
|
||||
<strong><?php esc_html_e( 'Keywords:', 'prautoblogger' ); ?></strong>
|
||||
<?php echo esc_html( implode( ', ', ( json_decode( $meta['keywords'], true ) ? json_decode( $meta['keywords'], true ) : array() ) ) ); ?>
|
||||
<?php echo esc_html( implode( ', ', json_decode( $meta['keywords'], true ) ?: [] ) ); ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
@@ -141,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 ? $verdict : 'pending' );
|
||||
$verdict_class = 'prautoblogger-verdict-' . sanitize_html_class( $verdict ?: 'pending' );
|
||||
?>
|
||||
<tr data-post-id="<?php echo esc_attr( (string) $post_id ); ?>">
|
||||
<td class="check-column">
|
||||
@@ -158,9 +158,9 @@ $bulk_type = isset( $_GET['prautoblogger_bulk_type'] ) ? sanitize_text_field( wp
|
||||
<div class="prautoblogger-editor-notes"><?php echo esc_html( $editor_notes ); ?></div>
|
||||
<?php endif; ?>
|
||||
</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 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 '' !== $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">
|
||||
|
||||
@@ -209,11 +209,6 @@ if ( ! class_exists( 'WP_Post' ) ) {
|
||||
// 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/' );
|
||||
}
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
<?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' );
|
||||
}
|
||||
}
|
||||
@@ -54,15 +54,6 @@ class BoardHumanModifiedFlagTest extends BaseTestCase {
|
||||
);
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
<?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 +'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Sync-mode guard tests for PRAutoBlogger_Generation_Checkpoint_Runner (v0.22.1).
|
||||
*
|
||||
* Isolated from GenerationCheckpointRunnerTest to keep both files under 300 lines.
|
||||
*
|
||||
* Contract under test:
|
||||
* set_sync_mode(true) -> on_orchestrate_tick() must NOT schedule GENERATE_ACTION
|
||||
* or call spawn_cron/fire_cron_now (VPS loop drives ticks directly).
|
||||
* NON-VACUOUS: orchestrate_only() returns 1 idea so execution reaches the guard
|
||||
* at line ~181, not the empty-ideas early return at ~148.
|
||||
* set_sync_mode(true) -> on_generate_tick() with ideas remaining must NOT schedule
|
||||
* GENERATE_ACTION or call spawn_cron/fire_cron_now.
|
||||
* set_sync_mode(false) -> on_generate_tick() with ideas remaining DOES schedule
|
||||
* GENERATE_ACTION (regression guard -- async path unaffected by sync guard).
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class GenerationCheckpointRunnerSyncModeTest extends BaseTestCase {
|
||||
|
||||
/** @var string[] Cron hooks captured from wp_schedule_single_event(). */
|
||||
private array $scheduled = array();
|
||||
|
||||
/** @var array<string, mixed> Option writes captured from update_option(). */
|
||||
private array $options = array();
|
||||
|
||||
/** @var bool Tracks whether spawn_cron was called. */
|
||||
private bool $spawn_called = false;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->scheduled = array();
|
||||
$this->options = array();
|
||||
$this->spawn_called = false;
|
||||
|
||||
Functions\when( 'wp_schedule_single_event' )->alias(
|
||||
function ( $ts, $hook, $args = array() ) {
|
||||
$this->scheduled[] = $hook;
|
||||
return true;
|
||||
}
|
||||
);
|
||||
Functions\when( 'wp_next_scheduled' )->justReturn( false );
|
||||
Functions\when( 'spawn_cron' )->alias( function () {
|
||||
$this->spawn_called = true;
|
||||
return true;
|
||||
} );
|
||||
Functions\when( 'wp_remote_post' )->justReturn( array() );
|
||||
Functions\when( 'site_url' )->justReturn( 'https://example.com' );
|
||||
|
||||
Functions\when( 'set_transient' )->justReturn( true );
|
||||
Functions\when( 'update_option' )->alias(
|
||||
function ( $name, $value, $autoload = null ) {
|
||||
$this->options[ $name ] = $value;
|
||||
return true;
|
||||
}
|
||||
);
|
||||
Functions\when( 'delete_option' )->alias(
|
||||
function ( $name ) {
|
||||
unset( $this->options[ $name ] );
|
||||
return true;
|
||||
}
|
||||
);
|
||||
Functions\when( 'get_option' )->alias(
|
||||
function ( $name, $default = false ) {
|
||||
return $this->options[ $name ] ?? $default;
|
||||
}
|
||||
);
|
||||
Functions\when( 'wp_generate_uuid4' )->justReturn( 'sync-test-uuid-001' );
|
||||
$this->options['prautoblogger_monthly_budget_usd'] = '0';
|
||||
Functions\when( 'wp_cache_get' )->justReturn( false );
|
||||
Functions\when( 'wp_cache_set' )->justReturn( true );
|
||||
Functions\when( 'wp_cache_delete' )->justReturn( true );
|
||||
\PRAutoBlogger_Run_State::flush_cache();
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
\PRAutoBlogger_Generation_Checkpoint_Runner::set_sync_mode( false );
|
||||
\PRAutoBlogger_Run_State::flush_cache();
|
||||
unset( $GLOBALS['wpdb'] );
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/** Stub the generation lock to always succeed. */
|
||||
private function wire_wpdb_for_lock(): void {
|
||||
$wpdb = $this->create_mock_wpdb();
|
||||
$wpdb->options = 'wp_options';
|
||||
$GLOBALS['wpdb'] = $wpdb;
|
||||
$wpdb->method( 'query' )->willReturn( 1 );
|
||||
$wpdb->method( 'get_var' )->willReturn( null );
|
||||
}
|
||||
|
||||
/** Build a minimal Article_Idea-compatible array. */
|
||||
private function make_idea( string $topic = 'Sync Topic' ): array {
|
||||
return array(
|
||||
'topic' => $topic,
|
||||
'article_type' => 'guide',
|
||||
'suggested_title' => 'Guide to ' . $topic,
|
||||
'summary' => '',
|
||||
'score' => 0.9,
|
||||
'analysis_id' => 1,
|
||||
'source_ids' => array(),
|
||||
'key_points' => array(),
|
||||
'target_keywords' => array(),
|
||||
);
|
||||
}
|
||||
|
||||
// __ sync-mode guard: on_orchestrate_tick() ______________________________________________
|
||||
|
||||
/**
|
||||
* sync_mode=true + >=1 idea returned: on_orchestrate_tick() must not schedule
|
||||
* GENERATE_ACTION or call spawn_cron (VPS loop drives generate ticks directly).
|
||||
*
|
||||
* NON-VACUOUS: orchestrate_only() returns 1 idea so execution reaches the sync
|
||||
* guard at line ~181, not the empty-ideas early return at ~148.
|
||||
*/
|
||||
public function test_orchestrate_tick_in_sync_mode_does_not_schedule_background_event(): void {
|
||||
$this->wire_wpdb_for_lock();
|
||||
|
||||
// Provide Pipeline_Runner stub that returns 1 idea -- forces execution past
|
||||
// the empty-ideas early return so the sync guard at line ~181 is exercised.
|
||||
if ( ! class_exists( 'PRAutoBlogger_Pipeline_Runner', false ) ) {
|
||||
// phpcs:ignore Squiz.Strings.DoubleQuoteUsage.NotRequired
|
||||
eval( 'class PRAutoBlogger_Pipeline_Runner {
|
||||
public function set_skip_dedup($v){ return $this; }
|
||||
public function orchestrate_only($ct){
|
||||
$idea = new PRAutoBlogger_Article_Idea([
|
||||
"topic"=>"Sync Topic","article_type"=>"guide",
|
||||
"suggested_title"=>"Guide","summary"=>"","score"=>0.9,
|
||||
"analysis_id"=>1,"source_ids"=>[],"key_points"=>[],
|
||||
"target_keywords"=>[]
|
||||
]);
|
||||
return [$idea];
|
||||
}
|
||||
}' );
|
||||
}
|
||||
|
||||
\PRAutoBlogger_Generation_Checkpoint_Runner::set_sync_mode( true );
|
||||
\PRAutoBlogger_Generation_Checkpoint_Runner::on_orchestrate_tick();
|
||||
|
||||
$this->assertNotContains(
|
||||
\PRAutoBlogger_Generation_Checkpoint_Runner::GENERATE_ACTION,
|
||||
$this->scheduled,
|
||||
'sync mode: on_orchestrate_tick must not schedule GENERATE_ACTION'
|
||||
);
|
||||
$this->assertFalse(
|
||||
$this->spawn_called,
|
||||
'sync mode: on_orchestrate_tick must not call spawn_cron'
|
||||
);
|
||||
}
|
||||
|
||||
// __ sync-mode guard: on_generate_tick() _________________________________________________
|
||||
|
||||
/**
|
||||
* sync_mode=true + 2 ideas in queue: on_generate_tick() must not reschedule
|
||||
* GENERATE_ACTION or call spawn_cron (VPS loop is the sole driver of next tick).
|
||||
*
|
||||
* Uses 2 ideas so the code stays in the "more ideas remain" branch -- this
|
||||
* path calls Article_Worker->generate() and then the sync guard, never
|
||||
* Post_Assembler::amortize_research_costs() (that is in the last-idea branch).
|
||||
* Article_Worker is stubbed; Post_Assembler is not needed and must NOT be
|
||||
* redefined here to avoid clobbering the real class for downstream tests.
|
||||
*/
|
||||
public function test_generate_tick_sync_mode_suppresses_reschedule(): void {
|
||||
$run_id = 'sync-gen-tick-uuid';
|
||||
$this->options['prautoblogger_article_queue'] = array(
|
||||
'run_id' => $run_id,
|
||||
'ideas' => array( $this->make_idea( 'A' ), $this->make_idea( 'B' ) ),
|
||||
'results' => array( 'generated' => 0, 'published' => 0, 'rejected' => 0, 'cost' => 0.0 ),
|
||||
);
|
||||
|
||||
$wpdb = $this->create_mock_wpdb();
|
||||
$wpdb->options = 'wp_options';
|
||||
$GLOBALS['wpdb'] = $wpdb;
|
||||
$wpdb->method( 'get_row' )->willReturn( array( 'status' => 'running', 'ceiling_usd' => '0' ) );
|
||||
$wpdb->method( 'query' )->willReturn( 1 );
|
||||
$wpdb->method( 'get_var' )->willReturn( null );
|
||||
|
||||
// Stub Article_Worker only -- Post_Assembler is NOT stubbed here because
|
||||
// amortize_research_costs() is not called on the "more ideas remain" path,
|
||||
// and redefining it with an incomplete stub breaks Publisher tests that run
|
||||
// after this file (alphabetical: SyncModeTest < Test).
|
||||
if ( ! class_exists( 'PRAutoBlogger_Article_Worker', false ) ) {
|
||||
// phpcs:ignore Squiz.Strings.DoubleQuoteUsage.NotRequired
|
||||
eval( 'class PRAutoBlogger_Article_Worker {
|
||||
public function __construct($ct){}
|
||||
public function generate($idea){ return ["generated"=>1,"published"=>1,"rejected"=>0,"cost"=>0.01]; }
|
||||
}' );
|
||||
}
|
||||
|
||||
\PRAutoBlogger_Generation_Checkpoint_Runner::set_sync_mode( true );
|
||||
\PRAutoBlogger_Generation_Checkpoint_Runner::on_generate_tick();
|
||||
|
||||
$this->assertNotContains(
|
||||
\PRAutoBlogger_Generation_Checkpoint_Runner::GENERATE_ACTION,
|
||||
$this->scheduled,
|
||||
'sync mode: on_generate_tick must not schedule GENERATE_ACTION'
|
||||
);
|
||||
$this->assertFalse(
|
||||
$this->spawn_called,
|
||||
'sync mode: on_generate_tick must not call spawn_cron'
|
||||
);
|
||||
}
|
||||
|
||||
// __ async regression guard ______________________________________________________________
|
||||
|
||||
/**
|
||||
* sync_mode=false: on_generate_tick() with ideas remaining DOES schedule
|
||||
* GENERATE_ACTION (regression guard -- async path must be unaffected by sync guard).
|
||||
*/
|
||||
public function test_generate_tick_async_mode_schedules_generate_action(): void {
|
||||
$run_id = 'async-regression-uuid';
|
||||
$this->options['prautoblogger_article_queue'] = array(
|
||||
'run_id' => $run_id,
|
||||
'ideas' => array( $this->make_idea( 'A' ), $this->make_idea( 'B' ) ),
|
||||
'results' => array( 'generated' => 0, 'published' => 0, 'rejected' => 0, 'cost' => 0.0 ),
|
||||
);
|
||||
|
||||
$wpdb = $this->create_mock_wpdb();
|
||||
$wpdb->options = 'wp_options';
|
||||
$GLOBALS['wpdb'] = $wpdb;
|
||||
$wpdb->method( 'get_row' )->willReturn( array( 'status' => 'running', 'ceiling_usd' => '0' ) );
|
||||
$wpdb->method( 'query' )->willReturn( 1 );
|
||||
$wpdb->method( 'get_var' )->willReturn( null );
|
||||
|
||||
// Article_Worker stub only -- see note in test_generate_tick_sync_mode_suppresses_reschedule.
|
||||
if ( ! class_exists( 'PRAutoBlogger_Article_Worker', false ) ) {
|
||||
// phpcs:ignore Squiz.Strings.DoubleQuoteUsage.NotRequired
|
||||
eval( 'class PRAutoBlogger_Article_Worker {
|
||||
public function __construct($ct){}
|
||||
public function generate($idea){ return ["generated"=>1,"published"=>1,"rejected"=>0,"cost"=>0.01]; }
|
||||
}' );
|
||||
}
|
||||
|
||||
\PRAutoBlogger_Generation_Checkpoint_Runner::set_sync_mode( false );
|
||||
\PRAutoBlogger_Generation_Checkpoint_Runner::on_generate_tick();
|
||||
|
||||
$this->assertContains(
|
||||
\PRAutoBlogger_Generation_Checkpoint_Runner::GENERATE_ACTION,
|
||||
$this->scheduled,
|
||||
'async mode: on_generate_tick must schedule GENERATE_ACTION when ideas remain'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Generation_Checkpoint_Runner (v0.21.0, M4).
|
||||
*
|
||||
* Locks the chained-cron checkpoint contract:
|
||||
* kick_off() -- schedules cron + writes initial transient, no pipeline call.
|
||||
* on_generate_tick() -- pops ONE idea, reschedules or finalizes; halted run aborts early.
|
||||
*
|
||||
* Sync-mode guard tests live in GenerationCheckpointRunnerSyncModeTest.php
|
||||
* (split to comply with the 300-line rule).
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class GenerationCheckpointRunnerTest extends BaseTestCase {
|
||||
|
||||
/** @var string[] Cron hooks captured from wp_schedule_single_event(). */
|
||||
private array $scheduled = array();
|
||||
|
||||
/** @var array<string, mixed> Option writes captured from update_option(). */
|
||||
private array $options = array();
|
||||
|
||||
/** @var array<int, array> Transient writes captured from set_transient(). */
|
||||
private array $transients = array();
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->scheduled = array();
|
||||
$this->options = array();
|
||||
$this->transients = array();
|
||||
|
||||
Functions\when( 'wp_schedule_single_event' )->alias(
|
||||
function ( $ts, $hook, $args = array() ) {
|
||||
$this->scheduled[] = $hook;
|
||||
return true;
|
||||
}
|
||||
);
|
||||
Functions\when( 'wp_next_scheduled' )->justReturn( false );
|
||||
Functions\when( 'spawn_cron' )->justReturn( true );
|
||||
Functions\when( 'wp_remote_post' )->justReturn( array() );
|
||||
Functions\when( 'site_url' )->justReturn( 'https://example.com' );
|
||||
|
||||
Functions\when( 'set_transient' )->alias(
|
||||
function ( $key, $value, $ttl = 0 ) {
|
||||
$this->transients[] = array( 'key' => $key, 'value' => (array) $value );
|
||||
return true;
|
||||
}
|
||||
);
|
||||
Functions\when( 'update_option' )->alias(
|
||||
function ( $name, $value, $autoload = null ) {
|
||||
$this->options[ $name ] = $value;
|
||||
return true;
|
||||
}
|
||||
);
|
||||
Functions\when( 'delete_option' )->alias(
|
||||
function ( $name ) {
|
||||
unset( $this->options[ $name ] );
|
||||
return true;
|
||||
}
|
||||
);
|
||||
Functions\when( 'get_option' )->alias(
|
||||
function ( $name, $default = false ) {
|
||||
return $this->options[ $name ] ?? $default;
|
||||
}
|
||||
);
|
||||
Functions\when( 'wp_generate_uuid4' )->justReturn( 'test-run-uuid-001' );
|
||||
// Monthly budget 0 = no limit.
|
||||
$this->options['prautoblogger_monthly_budget_usd'] = '0';
|
||||
Functions\when( 'wp_cache_get' )->justReturn( false );
|
||||
Functions\when( 'wp_cache_set' )->justReturn( true );
|
||||
Functions\when( 'wp_cache_delete' )->justReturn( true );
|
||||
\PRAutoBlogger_Run_State::flush_cache();
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
\PRAutoBlogger_Run_State::flush_cache();
|
||||
unset( $GLOBALS['wpdb'] );
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/** Stub the generation lock to always succeed. */
|
||||
private function wire_wpdb_for_lock(): void {
|
||||
$wpdb = $this->create_mock_wpdb();
|
||||
$wpdb->options = 'wp_options';
|
||||
$GLOBALS['wpdb'] = $wpdb;
|
||||
$wpdb->method( 'query' )->willReturn( 1 );
|
||||
$wpdb->method( 'get_var' )->willReturn( null );
|
||||
}
|
||||
|
||||
/** Build a minimal Article_Idea-compatible array. */
|
||||
private function make_idea( string $topic = 'Test Topic' ): array {
|
||||
return array(
|
||||
'topic' => $topic,
|
||||
'article_type' => 'guide',
|
||||
'suggested_title' => 'Guide to ' . $topic,
|
||||
'summary' => '',
|
||||
'score' => 0.9,
|
||||
'analysis_id' => 1,
|
||||
'source_ids' => array(),
|
||||
'key_points' => array(),
|
||||
'target_keywords' => array(),
|
||||
);
|
||||
}
|
||||
|
||||
// __ kick_off() __________________________________________________________________________
|
||||
|
||||
/**
|
||||
* kick_off() schedules exactly the ORCHESTRATE cron action and writes
|
||||
* an initial 'running' status transient. The pipeline is NOT called.
|
||||
*/
|
||||
public function test_kick_off_schedules_orchestrate_cron_and_writes_initial_transient(): void {
|
||||
\PRAutoBlogger_Generation_Checkpoint_Runner::kick_off();
|
||||
|
||||
$this->assertContains(
|
||||
\PRAutoBlogger_Generation_Checkpoint_Runner::ORCHESTRATE_ACTION,
|
||||
$this->scheduled,
|
||||
'kick_off() must schedule ORCHESTRATE_ACTION'
|
||||
);
|
||||
|
||||
$found_running_transient = false;
|
||||
foreach ( $this->transients as $t ) {
|
||||
if ( isset( $t['value']['status'] ) && 'running' === $t['value']['status'] ) {
|
||||
$found_running_transient = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$this->assertTrue( $found_running_transient, 'kick_off() must write a running status transient' );
|
||||
}
|
||||
|
||||
// __ on_generate_tick() __________________________________________________________________
|
||||
|
||||
/**
|
||||
* An empty queue causes generate tick to finalize without scheduling
|
||||
* another GENERATE action (no Article_Worker call).
|
||||
*/
|
||||
public function test_generate_tick_finalizes_when_queue_is_empty(): void {
|
||||
// No queue option => get_option returns false (default from setUp alias).
|
||||
// wire_wpdb_for_lock() provides $wpdb so Helpers::finalize() ->
|
||||
// Generation_Lock::release() can run without "Call to member on null".
|
||||
$this->wire_wpdb_for_lock();
|
||||
\PRAutoBlogger_Generation_Checkpoint_Runner::on_generate_tick();
|
||||
|
||||
$this->assertNotContains(
|
||||
\PRAutoBlogger_Generation_Checkpoint_Runner::GENERATE_ACTION,
|
||||
$this->scheduled,
|
||||
'Empty queue must not reschedule GENERATE'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A halted run causes generate tick to abort: queue is cleaned up and
|
||||
* GENERATE is not rescheduled (Article_Worker would not be called again).
|
||||
*/
|
||||
public function test_generate_tick_aborts_on_halted_run_without_rescheduling(): void {
|
||||
$run_id = 'halted-run-uuid';
|
||||
$this->options['prautoblogger_article_queue'] = array(
|
||||
'run_id' => $run_id,
|
||||
'ideas' => array( $this->make_idea() ),
|
||||
'results' => array( 'generated' => 0, 'published' => 0, 'rejected' => 0, 'cost' => 0.0 ),
|
||||
);
|
||||
|
||||
$wpdb = $this->create_mock_wpdb();
|
||||
$wpdb->options = 'wp_options';
|
||||
$GLOBALS['wpdb'] = $wpdb;
|
||||
$wpdb->method( 'get_row' )->willReturn( array( 'status' => 'halted', 'ceiling_usd' => '0' ) );
|
||||
$wpdb->method( 'query' )->willReturn( 1 );
|
||||
$wpdb->method( 'get_var' )->willReturn( null );
|
||||
|
||||
\PRAutoBlogger_Generation_Checkpoint_Runner::on_generate_tick();
|
||||
|
||||
$this->assertArrayNotHasKey(
|
||||
'prautoblogger_article_queue',
|
||||
$this->options,
|
||||
'Halted run must trigger queue cleanup'
|
||||
);
|
||||
$this->assertNotContains(
|
||||
\PRAutoBlogger_Generation_Checkpoint_Runner::GENERATE_ACTION,
|
||||
$this->scheduled,
|
||||
'Halted run must not reschedule GENERATE'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* With 2 ideas in queue, first generate tick pops one, reschedules GENERATE,
|
||||
* and leaves 1 idea remaining in the persisted queue.
|
||||
*/
|
||||
public function test_generate_tick_pops_one_idea_and_reschedules_when_more_remain(): void {
|
||||
$run_id = 'multi-idea-run-uuid';
|
||||
$this->options['prautoblogger_article_queue'] = array(
|
||||
'run_id' => $run_id,
|
||||
'ideas' => array( $this->make_idea( 'Idea A' ), $this->make_idea( 'Idea B' ) ),
|
||||
'results' => array( 'generated' => 0, 'published' => 0, 'rejected' => 0, 'cost' => 0.0 ),
|
||||
);
|
||||
$this->options['prautoblogger_checkpoint_run_id'] = $run_id;
|
||||
|
||||
$wpdb = $this->create_mock_wpdb();
|
||||
$wpdb->options = 'wp_options';
|
||||
$GLOBALS['wpdb'] = $wpdb;
|
||||
$wpdb->method( 'get_row' )->willReturn( array( 'status' => 'running', 'ceiling_usd' => '0' ) );
|
||||
$wpdb->method( 'query' )->willReturn( 1 );
|
||||
$wpdb->method( 'get_var' )->willReturn( null );
|
||||
|
||||
// Provide stub Article_Worker so CI doesn't need real LLM dependencies.
|
||||
if ( ! class_exists( 'PRAutoBlogger_Article_Worker', false ) ) {
|
||||
// phpcs:ignore Squiz.Strings.DoubleQuoteUsage.NotRequired
|
||||
eval( 'class PRAutoBlogger_Article_Worker {
|
||||
public function __construct($ct){}
|
||||
public function generate($idea){ return ["generated"=>1,"published"=>1,"rejected"=>0,"cost"=>0.01]; }
|
||||
}' );
|
||||
}
|
||||
if ( ! class_exists( 'PRAutoBlogger_Post_Assembler', false ) ) {
|
||||
// phpcs:ignore Squiz.Strings.DoubleQuoteUsage.NotRequired
|
||||
eval( 'class PRAutoBlogger_Post_Assembler { public static function amortize_research_costs($r){} }' );
|
||||
}
|
||||
|
||||
\PRAutoBlogger_Generation_Checkpoint_Runner::on_generate_tick();
|
||||
|
||||
$this->assertContains(
|
||||
\PRAutoBlogger_Generation_Checkpoint_Runner::GENERATE_ACTION,
|
||||
$this->scheduled,
|
||||
'More ideas remaining: GENERATE must be rescheduled'
|
||||
);
|
||||
if ( isset( $this->options['prautoblogger_article_queue'] ) ) {
|
||||
$remaining = $this->options['prautoblogger_article_queue']['ideas'] ?? array();
|
||||
$this->assertCount( 1, $remaining, 'Queue must have exactly 1 idea left after first tick' );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,13 +46,6 @@ class RunReaperTest extends BaseTestCase {
|
||||
}
|
||||
);
|
||||
Functions\when( 'apply_filters' )->returnArg( 2 );
|
||||
// wp_date() is called by the reaper's stuck-sweep cutoff (v0.22.1 TZ fix).
|
||||
// Alias to gmdate() so tests remain timezone-agnostic.
|
||||
Functions\when( 'wp_date' )->alias(
|
||||
static function ( string $format, ?int $timestamp = null ): string {
|
||||
return gmdate( $format, null !== $timestamp ? $timestamp : time() );
|
||||
}
|
||||
);
|
||||
\PRAutoBlogger_Run_State::flush_cache();
|
||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||
}
|
||||
|
||||
@@ -97,9 +97,6 @@ $hooks = array(
|
||||
'prautoblogger_opik_dispatch',
|
||||
'prautoblogger_rerun_stage_replay',
|
||||
'prautoblogger_rerun_from_stage',
|
||||
// v0.21.0 (M4): chained-cron checkpoint ticks.
|
||||
'prautoblogger_gen_orchestrate',
|
||||
'prautoblogger_gen_tick',
|
||||
);
|
||||
|
||||
foreach ( $hooks as $hook ) {
|
||||
|
||||
Reference in New Issue
Block a user