Compare commits

..

5 Commits

Author SHA1 Message Date
peptiderepo
6727e073cc fix: restore plugin header Version line clobbered in rebase conflict resolution
QA P1 from 2026-06-12-6ed639d verdict: the docblock Version field was replaced
by stray define() lines during the CTO rebase of this branch over v0.19.4,
leaving get_plugin_data() with no version. Header restored; the real constants
at the Plugin Constants block are untouched.

Agent-Session: cto-m3-rebase-fix-20260612
2026-06-12 14:43:37 +00:00
peptiderepo
6ed639d1db feat(M3): dossier edit+rerun UI, F2/F3, run-list badges — v0.20.0
- Dossier_Actions (4 AJAX endpoints, manage_options+nonce): save fork /
  queue replay / queue rebuild / stage-status poll. Run identity always
  derived server-side from post meta; the AJAX layer validates + queues
  ONLY — all mutations happen in the cron handlers (chained-cron, CPO
  hard constraint). Fork saves lock message structure (count/roles) so
  every version stays replayable.
- Edit panel per editable stage: per-message textareas prefilled from
  the latest fork or the recorded request_json; explicit fork +
  downstream-stale + queued-execution copy; spend strip with ceiling
  warning (filterable fraction, default 0.8). Disabled stages always
  show WHY (policy reasons; missing-input retention copy).
- Stale / human-edited / attempt chips per stage + header badges (M2
  slot filled); dossier polls stage status while a re-run is active
  (board poll interval setting) and re-renders when the run settles.
- F2: sidebar Models & Prompts card (per-stage model/pv/role) + Run
  Spend card. F3: log-only stages (image_a/b, llm_research, ...) render
  from generation_log + the _prautoblogger_image_role attachment grid.
- M2 correctness: dossier is now item-scoped (idea-hash meta) and
  excludes gen_log rows linked to other posts of a multi-article run.
- Run-list visibility (product AC): board cards get a Human-modified
  chip; Review Queue rows get a stale-stages chip linking to the
  dossier. Both via single batched queries (no N+1).
- v0.20.0 + CHANGELOG + ARCHITECTURE #24 (+ file tree, schema tables,
  db-version note) in this commit per versioning discipline.

Agent-Session: engineer-prab-m3-20260612
2026-06-12 14:28:38 +00:00
peptiderepo
38500b2781 feat(M3): re-run engine — replay + rebuild as chained-cron jobs under CPO guardrails
Two operator primitives, both queued (never synchronous — CPO hard
constraint), all mutations in the cron handler after re-validating
eligibility UNDER the generation lock:

- Replay (Stage_Replay + on_replay_job): one governed chat call from the
  latest edited input fork. options_from_body() is the exact inverse of
  build_body()+apply_reasoning_budget() (headroom subtracted back out;
  reasoning pinned explicitly so global-setting drift can't reshape a
  replay). Goes through send_chat_completion -> cost governor reserves
  against the SAME run row (guardrail 4), retry/empty-guard machinery,
  B1 recorder. Fresh output lands in the stage snapshot; downstream
  stale-marked (guardrail 3); human_modified set at execution entry
  (guardrail 2).
- Rebuild (on_rebuild_job): demote target+downstream to pending, re-enter
  Article_Worker with the idea from the stored seed; upstream done stages
  reuse (never re-charged), demoted stages rebuild prompts from CURRENT
  snapshots — how an upstream edit propagates. Decisions recorded while
  rebuilding a human-modified item are flagged.
- Rerun_Eligibility: published/future/private = frozen (guardrail 5,
  server-side); executing runs blocked; editable stages = writer chat
  stages (outline/draft/polish); chain-order downstream sets.
- Run_State::reopen(): atomic terminal->running gate, ceiling
  re-snapshotted from the current setting, accumulated spend NEVER reset.
- Publisher: re-run output refreshes the existing UNPUBLISHED post
  (status preserved — re-runs never publish); published posts untouched.
- Article_Worker persists the exact idea payload as a seed (INSERT-once)
  so rebuilds can't mis-derive item_key and duplicate a post.
- Rerun_Job_Support: queue/lock/budget/status-transient plumbing; jobs
  ride the M1 status transient so the board shows queued->pickup->result.

Agent-Session: engineer-prab-m3-20260612
2026-06-12 14:28:10 +00:00
peptiderepo
aaf1b4274e feat(M3): edit+rerun schema substrate — stage_inputs store, stale/human_modified columns, db 1.3.0
- Pipeline_Schema_Installer: run_stages gains human_modified + stale
  (stale is a COLUMN not a status: rows stay 'done' so resume code can
  never silently re-run them — CPO guardrail 3 by construction);
  run_decisions gains human_modified; new INSERT-only stage_inputs table
  (edit forks as immutable versions + idea seeds for re-run-from-here).
  All dbDelta-idempotent; PRAUTOBLOGGER_DB_VERSION 1.1.0 -> 1.3.0 (the
  constant never carried the documented 1.2.0) so the existing
  admin_init/cron self-healing migration path fires everywhere.
- Run_Stage_State split (M2 pattern, was 313 lines): pipeline write
  bodies moved verbatim to Run_Stage_Writes (public API unchanged via
  thin proxies); done() now clears stale on fresh output with a
  half-migrated-schema retry (Cost_Tracker v0.18.0 pattern); new
  item-scoped read stages_for_item(). Operator-action mutations
  (restart/mark_stale/demote_to_pending) isolated in
  Run_Stage_Rerun_State — the only code allowed to demote a done row.
- Audit_Writer::flag_decisions_human_modified() (guardrail 2 on
  run_decisions). Run_Reaper retention prunes human fork bodies (seeds
  kept). Uninstall covers the new table + rerun cron hooks.

Agent-Session: engineer-prab-m3-20260612
2026-06-12 14:28:10 +00:00
peptiderepo
1b31ef6396 feat(B1): persist request_json on every chat LLM call via process-scoped recorder
The OpenRouter provider stashes the outgoing request BODY (never headers --
Authorization is built separately and build_body() copies only whitelisted
option keys) in the new PRAutoBlogger_Request_Recorder before dispatch;
Cost_Tracker::log_api_call() consumes it (consume-once) into
generation_log.request_json. All seven chat call sites covered with zero
call-site changes; error/timeout rows carry the request too. Retention
rides the existing prautoblogger_request_json_retention_days prune.

Unblocks M3 edit+rerun (stage inputs now exist) and makes the M2 dossier
raw-input trace real (QA seq 20 F1).

Agent-Session: engineer-prab-m3-20260612
2026-06-12 14:22:29 +00:00
58 changed files with 1467 additions and 3595 deletions

View File

@@ -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'] ?? '';
}

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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).*

View File

@@ -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)*

View File

@@ -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 ) );

View File

@@ -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 ) );

View File

@@ -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/"
}
}
}

View File

@@ -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.

View File

@@ -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 |

View File

@@ -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 );
}
}

View File

@@ -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,

View File

@@ -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
);

View File

@@ -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
*/

View File

@@ -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 );
}
}
}

View File

@@ -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;
}
}

View File

@@ -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' ),
),
);
}
}

View File

@@ -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' ),
),
);
}
}

View File

@@ -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 );
}
}

View File

@@ -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.
*

View File

@@ -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 ) {

View File

@@ -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' );
}
}
}

View File

@@ -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. */

View File

@@ -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

View File

@@ -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' ) );

View File

@@ -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.

View File

@@ -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,
)
);
}
}

View File

@@ -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() );
}
}
}

View File

@@ -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.
*

View File

@@ -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 ) );
}
}

View File

@@ -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 ─────────────────────────────────────────────────
/**

View File

@@ -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
);
}
}

View File

@@ -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(

View File

@@ -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 ) );
}
}

View File

@@ -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'];
}
}

View File

@@ -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,

View File

@@ -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.
*

View File

@@ -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.

View File

@@ -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.

View File

@@ -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' ),
);
}
}

View File

@@ -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__ ) );

View 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 );

View File

@@ -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' ); ?>">

View File

@@ -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' => '&laquo;',
'next_text' => '&raquo;',
)
)
);
echo wp_kses_post( paginate_links( [
'base' => add_query_arg( 'paged', '%#%' ),
'format' => '',
'current' => $paged,
'total' => $total_pages,
'prev_text' => '&laquo;',
'next_text' => '&raquo;',
] ) );
?>
</div>
<?php endif; ?>

View File

@@ -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' => '&laquo;',
'next_text' => '&raquo;',
)
)
);
echo wp_kses_post( paginate_links( [
'base' => add_query_arg( 'paged', '%#%' ),
'format' => '',
'current' => $paged,
'total' => $total_pages,
'prev_text' => '&laquo;',
'next_text' => '&raquo;',
] ) );
?>
</div>
<?php endif; ?>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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/' );
}

View File

@@ -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' );
}
}

View File

@@ -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();
}

View File

@@ -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 +'
);
}
}

View File

@@ -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'
);
}
}

View File

@@ -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' );
}
}
}

View File

@@ -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();
}

View File

@@ -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 ) {