Compare commits
177 Commits
refactor/s
...
claude/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10f95f709e | ||
|
|
18cd342634 | ||
|
|
110c681c57 | ||
|
|
d770e8a86a | ||
|
|
326a1af900 | ||
|
|
6ce2cbb94c | ||
|
|
2d641153c7 | ||
|
|
ac2f20de7e | ||
|
|
3b6a302f95 | ||
|
|
5c3328dee2 | ||
|
|
9c8e500244 | ||
|
|
2911c94f57 | ||
|
|
49f17c3d6f | ||
|
|
87020736ef | ||
|
|
dd1c38b6cf | ||
|
|
952344ff5f | ||
|
|
f0dc1c4621 | ||
|
|
eae0239b0c | ||
|
|
1afac09d0a | ||
|
|
5ea058d504 | ||
|
|
3af65bbe39 | ||
|
|
6b1908d628 | ||
|
|
b902216497 | ||
|
|
27ef37fe15 | ||
|
|
b7ffce8d88 | ||
|
|
da0250f3ba | ||
|
|
f5658f1c97 | ||
|
|
c5e2a73e42 | ||
|
|
50da840553 | ||
|
|
c6d051c87a | ||
|
|
c723af927b | ||
|
|
1704d7f3a6 | ||
|
|
6de8cf2031 | ||
|
|
d559fc58ab | ||
|
|
a18aa9f316 | ||
|
|
d00e746727 | ||
|
|
a4f3b1832e | ||
|
|
ac60c3d3a6 | ||
|
|
1955f92859 | ||
|
|
96f0248d81 | ||
|
|
19849a5eaf | ||
|
|
d9317ff567 | ||
|
|
30282035b5 | ||
|
|
6db6ffd7f5 | ||
|
|
1cdb41224b | ||
|
|
b2bc4be9c2 | ||
|
|
73ac6d12b4 | ||
|
|
61975d4551 | ||
|
|
ca82e395f4 | ||
|
|
3f60ecd0ff | ||
|
|
046d57877c | ||
|
|
c628fc8cbb | ||
|
|
6e9d2e3928 | ||
|
|
066e533f09 | ||
|
|
7924b47913 | ||
|
|
a5a370779a | ||
|
|
86f6801079 | ||
|
|
89c2bafeec | ||
|
|
1d46ace22f | ||
|
|
d465d6f1cf | ||
|
|
a8ba7a8ea9 | ||
|
|
c1d22fd275 | ||
|
|
b9dfc29436 | ||
|
|
58710a0263 | ||
|
|
fbbb387311 | ||
|
|
7401addbd9 | ||
|
|
e3e867353a | ||
|
|
79e92938ea | ||
|
|
996bd2fd44 | ||
|
|
9f8c47d752 | ||
|
|
74c4184084 | ||
|
|
26059e7f12 | ||
|
|
0e1551be42 | ||
|
|
b9934cdd46 | ||
|
|
81279eb5f5 | ||
|
|
dc86376e8b | ||
|
|
0edf5c2aa8 | ||
|
|
ab8f581340 | ||
|
|
5a5b00f735 | ||
|
|
0164286c1e | ||
|
|
550a382dfd | ||
|
|
f3eacd0d8d | ||
|
|
7cd65b145f | ||
|
|
ad51ab705f | ||
|
|
d3b40649c6 | ||
|
|
b399023291 | ||
|
|
382f2a0a6f | ||
|
|
ef85100501 | ||
|
|
97129ed457 | ||
|
|
21430ce3bf | ||
|
|
1c3d9b50c6 | ||
|
|
8bedecd2dd | ||
|
|
2cacaa3512 | ||
|
|
80a7e11a2b | ||
|
|
33d3ba4ea1 | ||
|
|
188e6603dd | ||
|
|
5c7736bf4e | ||
|
|
24a025c173 | ||
|
|
2a921e963f | ||
|
|
0198407e51 | ||
|
|
01ef135bf9 | ||
|
|
a37fb843a3 | ||
|
|
86ceea0da1 | ||
|
|
4791e54821 | ||
|
|
76235ef4d7 | ||
|
|
14f1cf0351 | ||
|
|
0cde08757b | ||
|
|
246f718630 | ||
|
|
264fcf0f84 | ||
|
|
327cdc08d9 | ||
|
|
c03bdfb69c | ||
|
|
39570e4586 | ||
|
|
10c812d191 | ||
|
|
36d45fd1ed | ||
|
|
7d61c8c76d | ||
|
|
ca8fe1bfba | ||
|
|
b0cc93669d | ||
|
|
65560ea0b5 | ||
|
|
3e1e5efe09 | ||
|
|
499dcc931d | ||
|
|
0a15e266cd | ||
|
|
9e74b596d1 | ||
|
|
281c8cd89c | ||
|
|
00cfb39253 | ||
|
|
cc6979b085 | ||
|
|
ef40ce91d4 | ||
|
|
c866637a95 | ||
|
|
2300fbf1c5 | ||
|
|
42d6d03cb3 | ||
|
|
d17c488ef3 | ||
|
|
c8595e05de | ||
|
|
650d8df325 | ||
|
|
214f57bff2 | ||
|
|
389cb4d380 | ||
|
|
abcc454dfe | ||
|
|
12654b3a45 | ||
|
|
41f1b13497 | ||
|
|
4c49e8593e | ||
|
|
b5bbc54f87 | ||
|
|
b17ed4719b | ||
|
|
1a2af595c6 | ||
|
|
dd406c5ba6 | ||
|
|
a4405a5d32 | ||
|
|
862d7e40e6 | ||
|
|
cf3d97c0c3 | ||
|
|
2c2a2acc7d | ||
|
|
58cb4ed508 | ||
|
|
358a1bbc7e | ||
|
|
1968864073 | ||
|
|
14e4270158 | ||
|
|
d886aee1d7 | ||
|
|
4cc9b39693 | ||
|
|
0a68741945 | ||
|
|
b5e91ba3a6 | ||
|
|
5b5286ca5b | ||
|
|
49aee0b446 | ||
|
|
51b385b938 | ||
|
|
1c1e861f2e | ||
|
|
31f303a831 | ||
|
|
dcc8b7b183 | ||
|
|
0e44c52ad8 | ||
|
|
2a734ba596 | ||
|
|
6cf8ff62fc | ||
|
|
b6363d8474 | ||
|
|
4663011c4c | ||
|
|
47ce4818f0 | ||
|
|
4629406cdc | ||
|
|
0caa2d503c | ||
|
|
dd10b43aab | ||
|
|
8de7b7f68a | ||
|
|
5344642978 | ||
|
|
4d09141550 | ||
|
|
7ef1a917e9 | ||
|
|
eccb336c94 | ||
|
|
8795f849f3 | ||
|
|
d31d378ec1 | ||
|
|
fcc61c0cb8 |
@@ -35,25 +35,33 @@ add_action( 'rest_api_init', 'prautoblogger_deploy_register_route' );
|
|||||||
* Register the deploy REST route.
|
* Register the deploy REST route.
|
||||||
*/
|
*/
|
||||||
function prautoblogger_deploy_register_route(): void {
|
function prautoblogger_deploy_register_route(): void {
|
||||||
register_rest_route( 'prautoblogger-deploy/v1', '/deploy', [
|
register_rest_route(
|
||||||
|
'prautoblogger-deploy/v1',
|
||||||
|
'/deploy',
|
||||||
|
array(
|
||||||
'methods' => 'POST',
|
'methods' => 'POST',
|
||||||
'callback' => 'prautoblogger_deploy_handle',
|
'callback' => 'prautoblogger_deploy_handle',
|
||||||
'permission_callback' => 'prautoblogger_deploy_check_auth',
|
'permission_callback' => 'prautoblogger_deploy_check_auth',
|
||||||
] );
|
)
|
||||||
|
);
|
||||||
|
|
||||||
// Health check endpoint (GET) — useful for monitoring.
|
// Health check endpoint (GET) — useful for monitoring.
|
||||||
register_rest_route( 'prautoblogger-deploy/v1', '/status', [
|
register_rest_route(
|
||||||
|
'prautoblogger-deploy/v1',
|
||||||
|
'/status',
|
||||||
|
array(
|
||||||
'methods' => 'GET',
|
'methods' => 'GET',
|
||||||
'callback' => function () {
|
'callback' => function () {
|
||||||
return [
|
return array(
|
||||||
'status' => 'ready',
|
'status' => 'ready',
|
||||||
'plugin' => is_dir( WP_PLUGIN_DIR . '/prautoblogger' ) ? 'installed' : 'not_installed',
|
'plugin' => is_dir( WP_PLUGIN_DIR . '/prautoblogger' ) ? 'installed' : 'not_installed',
|
||||||
'active' => is_plugin_active( 'prautoblogger/prautoblogger.php' ),
|
'active' => is_plugin_active( 'prautoblogger/prautoblogger.php' ),
|
||||||
'version' => prautoblogger_deploy_get_installed_version(),
|
'version' => prautoblogger_deploy_get_installed_version(),
|
||||||
];
|
);
|
||||||
},
|
},
|
||||||
'permission_callback' => 'prautoblogger_deploy_check_auth',
|
'permission_callback' => 'prautoblogger_deploy_check_auth',
|
||||||
] );
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,7 +86,7 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon
|
|||||||
return new WP_Error(
|
return new WP_Error(
|
||||||
'rate_limited',
|
'rate_limited',
|
||||||
'Deploy in progress or recently completed. Wait 60 seconds.',
|
'Deploy in progress or recently completed. Wait 60 seconds.',
|
||||||
[ 'status' => 429 ]
|
array( 'status' => 429 )
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
set_transient( 'prautoblogger_deploy_lock', time(), 60 );
|
set_transient( 'prautoblogger_deploy_lock', time(), 60 );
|
||||||
@@ -87,7 +95,7 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon
|
|||||||
$files = $request->get_file_params();
|
$files = $request->get_file_params();
|
||||||
if ( empty( $files['plugin']['tmp_name'] ) || ! is_uploaded_file( $files['plugin']['tmp_name'] ) ) {
|
if ( empty( $files['plugin']['tmp_name'] ) || ! is_uploaded_file( $files['plugin']['tmp_name'] ) ) {
|
||||||
delete_transient( 'prautoblogger_deploy_lock' );
|
delete_transient( 'prautoblogger_deploy_lock' );
|
||||||
return new WP_Error( 'no_file', 'No plugin zip uploaded.', [ 'status' => 400 ] );
|
return new WP_Error( 'no_file', 'No plugin zip uploaded.', array( 'status' => 400 ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
$tmp_file = $files['plugin']['tmp_name'];
|
$tmp_file = $files['plugin']['tmp_name'];
|
||||||
@@ -96,11 +104,11 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon
|
|||||||
$zip = new ZipArchive();
|
$zip = new ZipArchive();
|
||||||
if ( true !== $zip->open( $tmp_file ) ) {
|
if ( true !== $zip->open( $tmp_file ) ) {
|
||||||
delete_transient( 'prautoblogger_deploy_lock' );
|
delete_transient( 'prautoblogger_deploy_lock' );
|
||||||
return new WP_Error( 'invalid_zip', 'Uploaded file is not a valid zip.', [ 'status' => 400 ] );
|
return new WP_Error( 'invalid_zip', 'Uploaded file is not a valid zip.', array( 'status' => 400 ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
$has_main_file = false;
|
$has_main_file = false;
|
||||||
for ( $i = 0; $i < $zip->numFiles; $i++ ) {
|
for ( $i = 0; $i < $zip->numFiles; $i++ ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
|
||||||
if ( 'prautoblogger/prautoblogger.php' === $zip->getNameIndex( $i ) ) {
|
if ( 'prautoblogger/prautoblogger.php' === $zip->getNameIndex( $i ) ) {
|
||||||
$has_main_file = true;
|
$has_main_file = true;
|
||||||
break;
|
break;
|
||||||
@@ -113,7 +121,7 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon
|
|||||||
return new WP_Error(
|
return new WP_Error(
|
||||||
'invalid_plugin',
|
'invalid_plugin',
|
||||||
'Zip must contain prautoblogger/prautoblogger.php at root.',
|
'Zip must contain prautoblogger/prautoblogger.php at root.',
|
||||||
[ 'status' => 400 ]
|
array( 'status' => 400 )
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +151,7 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon
|
|||||||
activate_plugin( 'prautoblogger/prautoblogger.php' );
|
activate_plugin( 'prautoblogger/prautoblogger.php' );
|
||||||
}
|
}
|
||||||
delete_transient( 'prautoblogger_deploy_lock' );
|
delete_transient( 'prautoblogger_deploy_lock' );
|
||||||
return new WP_Error( 'backup_failed', 'Could not backup current plugin.', [ 'status' => 500 ] );
|
return new WP_Error( 'backup_failed', 'Could not backup current plugin.', array( 'status' => 500 ) );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +169,7 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon
|
|||||||
}
|
}
|
||||||
delete_transient( 'prautoblogger_deploy_lock' );
|
delete_transient( 'prautoblogger_deploy_lock' );
|
||||||
error_log( '[PRAutoBlogger Deploy] Extraction failed. Rolled back to previous version.' );
|
error_log( '[PRAutoBlogger Deploy] Extraction failed. Rolled back to previous version.' );
|
||||||
return new WP_Error( 'extract_failed', 'Zip extraction failed. Rolled back.', [ 'status' => 500 ] );
|
return new WP_Error( 'extract_failed', 'Zip extraction failed. Rolled back.', array( 'status' => 500 ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Activate plugin.
|
// 7. Activate plugin.
|
||||||
@@ -178,7 +186,7 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon
|
|||||||
return new WP_Error(
|
return new WP_Error(
|
||||||
'activation_failed',
|
'activation_failed',
|
||||||
'Plugin activation failed: ' . $activation_result->get_error_message() . '. Rolled back.',
|
'Plugin activation failed: ' . $activation_result->get_error_message() . '. Rolled back.',
|
||||||
[ 'status' => 500 ]
|
array( 'status' => 500 )
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,18 +196,23 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon
|
|||||||
}
|
}
|
||||||
|
|
||||||
$old_version = prautoblogger_deploy_get_installed_version();
|
$old_version = prautoblogger_deploy_get_installed_version();
|
||||||
error_log( sprintf(
|
error_log(
|
||||||
|
sprintf(
|
||||||
'[PRAutoBlogger Deploy] Successfully deployed v%s (was: %s) at %s',
|
'[PRAutoBlogger Deploy] Successfully deployed v%s (was: %s) at %s',
|
||||||
$incoming_version,
|
$incoming_version,
|
||||||
$old_version ?: 'fresh install',
|
$old_version ? $old_version : 'fresh install',
|
||||||
gmdate( 'Y-m-d H:i:s' )
|
gmdate( 'Y-m-d H:i:s' )
|
||||||
) );
|
)
|
||||||
|
);
|
||||||
|
|
||||||
return new WP_REST_Response( [
|
return new WP_REST_Response(
|
||||||
|
array(
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'version' => $incoming_version,
|
'version' => $incoming_version,
|
||||||
'message' => "PRAutoBlogger v{$incoming_version} deployed and activated.",
|
'message' => "PRAutoBlogger v{$incoming_version} deployed and activated.",
|
||||||
], 200 );
|
),
|
||||||
|
200
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -210,7 +223,7 @@ function prautoblogger_deploy_get_installed_version(): string {
|
|||||||
if ( ! file_exists( $main_file ) ) {
|
if ( ! file_exists( $main_file ) ) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
$data = get_file_data( $main_file, [ 'Version' => 'Version' ] );
|
$data = get_file_data( $main_file, array( 'Version' => 'Version' ) );
|
||||||
return $data['Version'] ?? '';
|
return $data['Version'] ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
113
.github/workflows/ci.yml
vendored
113
.github/workflows/ci.yml
vendored
@@ -1,94 +1,53 @@
|
|||||||
name: CI — Lint & Test
|
##
|
||||||
|
# CI caller — PRAutoBlogger
|
||||||
# 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
|
# Thin caller that delegates to the reusable estate CI workflow in peptide-e2e.
|
||||||
# run its own validate job as a belt-and-suspenders check, but this CI workflow
|
# workflow_call is required so deploy.yml can gate on this workflow via
|
||||||
# is what gives PRs an immediate signal so fixes happen before merge.
|
# `uses: ./.github/workflows/ci.yml` (the workflow_call trigger enables that).
|
||||||
|
#
|
||||||
|
# Permissions: contents: write is required at the caller level so the reusable
|
||||||
|
# workflow's phpcbf auto-commit job can push fixes back to the PR branch.
|
||||||
|
# (GitHub enforces that callers cannot be escalated beyond their declared scope.)
|
||||||
|
#
|
||||||
|
# tests: brain-monkey — no WP install or DB required; Brain Monkey mocking only.
|
||||||
|
# line_limit_paths: includes — estate hard 300-line rule applied to includes/.
|
||||||
|
##
|
||||||
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
pull_request: {}
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
pull_request:
|
workflow_call: {}
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
php-lint:
|
ci:
|
||||||
name: PHP Lint (${{ matrix.php }})
|
uses: peptide-repo/peptide-e2e/.github/workflows/ci.yml@main
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
php: ['7.4', '8.1', '8.3']
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Setup PHP ${{ matrix.php }}
|
|
||||||
uses: shivammathur/setup-php@v2
|
|
||||||
with:
|
with:
|
||||||
php-version: ${{ matrix.php }}
|
tests: brain-monkey
|
||||||
coverage: none
|
secrets: inherit
|
||||||
- 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 7.4 rejects.
|
|
||||||
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:
|
check-constants:
|
||||||
name: PHPCS (WordPress standards)
|
name: Check PRAUTOBLOGGER_* constants
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
continue-on-error: true # non-blocking for now — see CONVENTIONS.md
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Setup PHP
|
- name: Setup PHP 8.1
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
php-version: '8.1'
|
php-version: '8.1'
|
||||||
coverage: none
|
coverage: none
|
||||||
tools: composer:v2, phpcs
|
- name: Check PRAUTOBLOGGER_* constants
|
||||||
- name: Install WordPress Coding Standards
|
# Fails if any PRAUTOBLOGGER_* constant referenced in the shipped
|
||||||
run: |
|
# plugin PHP is neither defined in prautoblogger.php nor guarded
|
||||||
# Allow the PHPCS composer-installer plugin (auto-registers standards).
|
# by defined() at the reference site. Prevents the v0.19.0 incident
|
||||||
composer global config --no-plugins allow-plugins.dealerdirect/phpcodesniffer-composer-installer true
|
# class (constant deleted from bootstrap, test fallback masks the gap).
|
||||||
# WPCS 3.x requires PHPCSExtra (for the Universal.* and NormalizedArrays.* sniffs)
|
run: php scripts/check-constants.php
|
||||||
# AND phpcsutils. Installing the installer itself lets it auto-register the standards path.
|
- name: Self-test check-constants scanner
|
||||||
composer global require --no-interaction \
|
# Verifies the scanner itself can detect the failure cases it was
|
||||||
"dealerdirect/phpcodesniffer-composer-installer:^1.0" \
|
# designed to catch. If the scanner regresses, this step fails CI.
|
||||||
"wp-coding-standards/wpcs:^3.0" \
|
# Added v0.21.0; restored in v0.22.0 (was accidentally dropped in rewrite).
|
||||||
"phpcsstandards/phpcsutils:^1.0" \
|
run: php scripts/check-constants.php --self-test
|
||||||
"phpcsstandards/phpcsextra:^1.0"
|
|
||||||
- name: Run PHPCS
|
|
||||||
run: |
|
|
||||||
phpcs --standard=WordPress-Core \
|
|
||||||
--extensions=php \
|
|
||||||
--ignore=vendor/*,tests/*,node_modules/* \
|
|
||||||
--warning-severity=0 \
|
|
||||||
--report=summary \
|
|
||||||
includes/ prautoblogger.php uninstall.php
|
|
||||||
|
|
||||||
phpunit:
|
|
||||||
name: PHPUnit (PHP ${{ matrix.php }})
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
php: ['7.4', '8.1', '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
|
|
||||||
|
|||||||
261
.github/workflows/cto-review.yml
vendored
261
.github/workflows/cto-review.yml
vendored
@@ -1,20 +1,17 @@
|
|||||||
name: CTO Review
|
name: CTO Review
|
||||||
|
|
||||||
# Automated PR review powered by Claude + DeepSeek + Kimi (via OpenRouter)
|
# Automated PR review powered by Claude Haiku (via OpenRouter).
|
||||||
# plus Neurometric PR-Summarizer (a Qwen3-4B task-specialist SLM).
|
|
||||||
#
|
#
|
||||||
# During the A/B window (until further notice), every PR gets reviewed by FOUR models
|
# A/B panel decision 2026-06-12: panel dropped to Haiku-only.
|
||||||
# in parallel. The PR comment shows each model's verdict side-by-side so we can compare
|
# Evidence: convo/cto/threads/2026-06-qa-ab-review/02-analyst-findings.md
|
||||||
# quality/cost on real PRs before picking a permanent default.
|
# DeepSeek: high false-positive rate (absint()="code exec", PLUGIN_DIR="dir traversal").
|
||||||
|
# Kimi: ~60% model failure rate across 13 PRs sampled.
|
||||||
|
# Neurometric: summarizer only, zero unique-real findings, unreliable heuristic verdict.
|
||||||
|
# Haiku: 0 failures, lowest noise, equivalent real-signal catch rate vs 4-voice panel.
|
||||||
#
|
#
|
||||||
# Neurometric is a "prose voice": it does not support OpenAI tool-calling, so its
|
# Does NOT merge. Posts formal APPROVE/REQUEST_CHANGES/COMMENT reviews.
|
||||||
# contribution is raw markdown + a heuristic verdict. It is never the primary voice.
|
|
||||||
#
|
#
|
||||||
# Does NOT merge. Does NOT post formal APPROVE/REQUEST_CHANGES reviews — always COMMENT
|
# Requires repo secret: OPENROUTER_API_KEY
|
||||||
# (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).
|
# 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.
|
# Default cap is 1000 cents ($10/month). Auto-resets on the 1st of each month.
|
||||||
@@ -123,17 +120,18 @@ jobs:
|
|||||||
git fetch origin ${{ github.event.pull_request.base.ref }} --depth=50
|
git fetch origin ${{ github.event.pull_request.base.ref }} --depth=50
|
||||||
BASE_SHA="${{ github.event.pull_request.base.sha }}"
|
BASE_SHA="${{ github.event.pull_request.base.sha }}"
|
||||||
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
|
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
|
||||||
DIFF=$(git diff --no-color "$BASE_SHA" "$HEAD_SHA" | head -c 400000)
|
# 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.
|
||||||
|
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")
|
FILES=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")
|
||||||
SIZE=$(echo -n "$DIFF" | wc -c)
|
SIZE=$(wc -c < /tmp/cto-review-diff.txt)
|
||||||
{
|
{
|
||||||
echo "size=$SIZE"
|
echo "size=$SIZE"
|
||||||
echo "files<<EOF_FILES"
|
echo "files<<EOF_FILES"
|
||||||
echo "$FILES"
|
echo "$FILES"
|
||||||
echo "EOF_FILES"
|
echo "EOF_FILES"
|
||||||
echo "diff<<EOF_DIFF"
|
|
||||||
echo "$DIFF"
|
|
||||||
echo "EOF_DIFF"
|
|
||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Load repo conventions
|
- name: Load repo conventions
|
||||||
@@ -150,18 +148,17 @@ jobs:
|
|||||||
echo "EOF_CONV"
|
echo "EOF_CONV"
|
||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Call 4 models in parallel (OpenRouter + Neurometric)
|
- name: Call Haiku via OpenRouter
|
||||||
if: steps.budget.outputs.skip != 'true'
|
if: steps.budget.outputs.skip != 'true'
|
||||||
id: review
|
id: review
|
||||||
env:
|
env:
|
||||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||||
NEUROMETRIC_API_KEY: ${{ secrets.NEUROMETRIC_API_KEY }}
|
|
||||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||||
PR_BODY: ${{ github.event.pull_request.body }}
|
PR_BODY: ${{ github.event.pull_request.body }}
|
||||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
REPO_NAME: ${{ github.event.repository.name }}
|
REPO_NAME: ${{ github.event.repository.name }}
|
||||||
DIFF: ${{ steps.diff.outputs.diff }}
|
DIFF_PATH: /tmp/cto-review-diff.txt
|
||||||
DIFF_SIZE: ${{ steps.diff.outputs.size }}
|
DIFF_SIZE: ${{ steps.diff.outputs.size }}
|
||||||
CHANGED_FILES: ${{ steps.diff.outputs.files }}
|
CHANGED_FILES: ${{ steps.diff.outputs.files }}
|
||||||
CONVENTIONS: ${{ steps.conventions.outputs.content }}
|
CONVENTIONS: ${{ steps.conventions.outputs.content }}
|
||||||
@@ -183,24 +180,17 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
python3 - <<'PY'
|
python3 - <<'PY'
|
||||||
import json, os, sys, urllib.request, concurrent.futures
|
import json, os, sys, urllib.request
|
||||||
|
|
||||||
api_key = os.environ["OPENROUTER_API_KEY"]
|
api_key = os.environ["OPENROUTER_API_KEY"]
|
||||||
|
|
||||||
# A/B harness: four models. Each gets the same prompt.
|
# Haiku-only reviewer. A/B panel dropped 2026-06-12 per QA data:
|
||||||
# Primary model's verdict drives the heading. Others are labeled.
|
# DeepSeek high false-positive rate, Kimi ~60% failure rate,
|
||||||
# Edit this list to change the roster (or drop to 1 after A/B window closes).
|
# Neurometric zero unique-real findings. Haiku retained as sole voice.
|
||||||
#
|
MODEL_SLUG = "anthropic/claude-haiku-4.5"
|
||||||
# provider_type semantics:
|
MODEL_DISPLAY = "Claude Haiku 4.5"
|
||||||
# - "openrouter_tools": OpenAI tool-calling via OpenRouter; structured verdict.
|
INPUT_PER_M_USD = 1.00
|
||||||
# - "neurometric_prose": Neurometric marketplace SLM; prose output + heuristic verdict.
|
OUTPUT_PER_M_USD = 5.00
|
||||||
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.
|
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.
|
||||||
|
|
||||||
@@ -208,7 +198,8 @@ jobs:
|
|||||||
1. Assess whether the change is safe to merge and deploy to production (peptiderepo.com on Hostinger WordPress).
|
1. Assess whether the change is safe to merge and deploy to production (peptiderepo.com on Hostinger WordPress).
|
||||||
2. Flag security issues, cost traps (paid AI API usage), performance regressions, missing input sanitization, missing output escaping, hardcoded secrets, broken migrations, or violations of the repo's CONVENTIONS.md.
|
2. Flag security issues, cost traps (paid AI API usage), performance regressions, missing input sanitization, missing output escaping, hardcoded secrets, broken migrations, or violations of the repo's CONVENTIONS.md.
|
||||||
3. Check that the PR follows the project's git workflow: branch naming (claude/<scope>-<date>), Agent-Session trailer in commits, PR description covers what/why/risk/test-plan.
|
3. Check that the PR follows the project's git workflow: branch naming (claude/<scope>-<date>), Agent-Session trailer in commits, PR description covers what/why/risk/test-plan.
|
||||||
4. Be concise and direct. No pleasantries. Call out concrete line-level issues when possible.
|
4. CTO app-map freshness. If this PR modifies any of: (a) the admin settings surface (register_setting, add_submenu_page, add_options_page, settings field labels, option keys, types, defaults); (b) the file/class structure (any file added, renamed, split per the 300-line rule, or materially restructured); or (c) the data flow (new external API client, new hook/filter fired publicly, new scheduled job, or changed class responsibilities) — then the PR description MUST include a line referencing an accompanying update to `Peptide Repo CTO/app-maps/<repo-slug>.md`. The CTO workspace is a separate repo, so CI cannot verify the doc update mechanically — you must inspect the PR description for an explicit mention. If the description is silent despite a triggering change, raise this as a MAJOR concern (not blocking) so the author can add the mention or open a follow-up PR on the CTO workspace.
|
||||||
|
5. Be concise and direct. No pleasantries. Call out concrete line-level issues when possible.
|
||||||
|
|
||||||
You MUST respond by calling the submit_review function. Do not write prose outside the tool call.
|
You MUST respond by calling the submit_review function. Do not write prose outside the tool call.
|
||||||
|
|
||||||
@@ -228,7 +219,9 @@ jobs:
|
|||||||
user_blocks.append(f"**Changed files:**\n```\n{os.environ['CHANGED_FILES']}\n```\n")
|
user_blocks.append(f"**Changed files:**\n```\n{os.environ['CHANGED_FILES']}\n```\n")
|
||||||
if os.environ.get("CONVENTIONS", "").strip():
|
if os.environ.get("CONVENTIONS", "").strip():
|
||||||
user_blocks.append(f"# Repo CONVENTIONS.md\n{os.environ['CONVENTIONS']}\n")
|
user_blocks.append(f"# Repo CONVENTIONS.md\n{os.environ['CONVENTIONS']}\n")
|
||||||
user_blocks.append(f"# Diff\n```diff\n{os.environ['DIFF']}\n```\n")
|
with open(os.environ["DIFF_PATH"], encoding="utf-8", errors="replace") as _df:
|
||||||
|
_diff_text = _df.read()
|
||||||
|
user_blocks.append(f"# Diff\n```diff\n{_diff_text}\n```\n")
|
||||||
user_message = "\n\n".join(user_blocks)
|
user_message = "\n\n".join(user_blocks)
|
||||||
|
|
||||||
tools = [{
|
tools = [{
|
||||||
@@ -260,32 +253,8 @@ jobs:
|
|||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
|
|
||||||
PER_MODEL_TIMEOUT = 60 # seconds; one hung model should not block siblings
|
|
||||||
|
|
||||||
# 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",
|
|
||||||
]
|
|
||||||
|
|
||||||
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 = {
|
body = {
|
||||||
"model": slug,
|
"model": MODEL_SLUG,
|
||||||
"max_tokens": 2048,
|
"max_tokens": 2048,
|
||||||
"messages": [
|
"messages": [
|
||||||
{"role": "system", "content": system},
|
{"role": "system", "content": system},
|
||||||
@@ -301,177 +270,83 @@ jobs:
|
|||||||
"Authorization": f"Bearer {api_key}",
|
"Authorization": f"Bearer {api_key}",
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
"HTTP-Referer": "https://github.com/peptiderepo",
|
"HTTP-Referer": "https://github.com/peptiderepo",
|
||||||
"X-Title": f"peptiderepo CTO reviewer ({display})"
|
"X-Title": f"peptiderepo CTO reviewer ({MODEL_DISPLAY})"
|
||||||
},
|
},
|
||||||
method="POST"
|
method="POST"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
error = None
|
||||||
|
review = None
|
||||||
|
cost_cents = 0.0
|
||||||
|
ptok = 0
|
||||||
|
ctok = 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=PER_MODEL_TIMEOUT) as resp:
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
data = json.loads(resp.read())
|
data = json.loads(resp.read())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {
|
error = str(e)[:300]
|
||||||
"slug": slug, "display": display, "is_primary": is_primary,
|
data = {}
|
||||||
"provider_type": "openrouter_tools",
|
|
||||||
"error": str(e)[:300],
|
|
||||||
"cost_cents": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if not error:
|
||||||
choice = data.get("choices", [{}])[0].get("message", {})
|
choice = data.get("choices", [{}])[0].get("message", {})
|
||||||
tool_calls = choice.get("tool_calls", [])
|
tool_calls = choice.get("tool_calls", [])
|
||||||
if not tool_calls:
|
if not tool_calls:
|
||||||
return {
|
error = f"No tool_call in response. Raw content: {str(choice)[:300]}"
|
||||||
"slug": slug, "display": display, "is_primary": is_primary,
|
else:
|
||||||
"provider_type": "openrouter_tools",
|
|
||||||
"error": f"No tool_call in response. Raw content: {str(choice)[:300]}",
|
|
||||||
"cost_cents": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
review = json.loads(tool_calls[0]["function"]["arguments"])
|
review = json.loads(tool_calls[0]["function"]["arguments"])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {
|
error = f"Malformed tool args: {e}"
|
||||||
"slug": slug, "display": display, "is_primary": is_primary,
|
|
||||||
"provider_type": "openrouter_tools",
|
|
||||||
"error": f"Malformed tool args: {e}",
|
|
||||||
"cost_cents": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if not error:
|
||||||
usage = data.get("usage", {})
|
usage = data.get("usage", {})
|
||||||
ptok = usage.get("prompt_tokens", 0)
|
ptok = usage.get("prompt_tokens", 0)
|
||||||
ctok = usage.get("completion_tokens", 0)
|
ctok = usage.get("completion_tokens", 0)
|
||||||
cost_usd = (ptok * in_rate / 1_000_000) + (ctok * out_rate / 1_000_000)
|
cost_usd = (ptok * INPUT_PER_M_USD / 1_000_000) + (ctok * OUTPUT_PER_M_USD / 1_000_000)
|
||||||
cost_cents = cost_usd * 100
|
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"
|
|
||||||
|
|
||||||
|
# Build comment
|
||||||
|
if error:
|
||||||
|
heading_verdict = "ERROR"
|
||||||
lines = [f"## CTO Review — {heading_verdict}", ""]
|
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(f"Model failed: `{error}`")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
lines.append(f"_reviewer: {MODEL_DISPLAY}_")
|
||||||
total_cents = 0.0
|
else:
|
||||||
for r in results:
|
heading_verdict = review.get("verdict", "COMMENT")
|
||||||
lines.append(f"---")
|
lines = [f"## CTO Review — {heading_verdict}", ""]
|
||||||
primary_tag = " (primary)" if r.get("is_primary") else ""
|
lines.append(f"**Verdict:** {heading_verdict}")
|
||||||
lines.append(f"### {r['display']}{primary_tag}")
|
|
||||||
if "error" in r:
|
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append(f"⚠️ Model failed: `{r['error']}`")
|
lines.append(review.get("summary") or "_(model returned no summary)_")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
continue
|
if review.get("concerns"):
|
||||||
rev = r["review"]
|
|
||||||
lines.append("")
|
|
||||||
if r.get("provider_type") == "neurometric_prose":
|
|
||||||
lines.append(f"**Verdict:** {rev['verdict']} _(heuristic, inferred from prose)_")
|
|
||||||
lines.append("")
|
|
||||||
# Cap at 6KB to keep PR comments readable; 4B model output is rarely over this
|
|
||||||
prose = rev.get("prose", "")
|
|
||||||
if len(prose) > 6000:
|
|
||||||
prose = prose[:6000] + "\n\n_…output truncated for comment length…_"
|
|
||||||
lines.append(prose)
|
|
||||||
lines.append("")
|
|
||||||
lines.append("_free tier; per-call token usage not tracked_")
|
|
||||||
lines.append("")
|
|
||||||
continue
|
|
||||||
lines.append(f"**Verdict:** {rev.get('verdict', 'COMMENT')}")
|
|
||||||
lines.append("")
|
|
||||||
lines.append(rev.get("summary") or "_(model returned no summary)_")
|
|
||||||
lines.append("")
|
|
||||||
if rev.get("concerns"):
|
|
||||||
lines.append("**Concerns:**")
|
lines.append("**Concerns:**")
|
||||||
for c in rev["concerns"]:
|
for c in review["concerns"]:
|
||||||
sev = c["severity"].upper()
|
sev = c.get("severity", "UNKNOWN").upper()
|
||||||
f = f"`{c['file']}`" if c.get("file") else ""
|
f = f"`{c['file']}`" if c.get("file") else ""
|
||||||
lines.append(f"- **{sev}** {f} — {c['issue']}")
|
lines.append(f"- **{sev}** {f} — {c['issue']}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
if rev.get("positives"):
|
if review.get("positives"):
|
||||||
lines.append("**Positives:** " + "; ".join(rev["positives"]))
|
lines.append("**Positives:** " + "; ".join(review["positives"]))
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append(f"_tokens in/out: {r['prompt_tokens']}/{r['completion_tokens']} · cost: ${r['cost_cents']/100:.4f}_")
|
lines.append(f"_tokens in/out: {ptok}/{ctok} · cost: ${cost_cents/100:.4f}_")
|
||||||
total_cents += r["cost_cents"]
|
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
lines.append("---")
|
lines.append("---")
|
||||||
lines.append(f"_Total review cost: **${total_cents/100:.4f}**. A/B roster configured in `.github/workflows/cto-review.yml`._")
|
lines.append(f"_Total review cost: **${cost_cents/100:.4f}**. Reviewer: {MODEL_DISPLAY}. Roster configured in `.github/workflows/cto-review.yml`._")
|
||||||
|
|
||||||
with open("/tmp/review-body.md", "w") as f:
|
with open("/tmp/review-body.md", "w") as f:
|
||||||
f.write("\n".join(lines))
|
f.write("\n".join(lines))
|
||||||
with open(os.environ["GITHUB_OUTPUT"], "a") as f:
|
with open(os.environ["GITHUB_OUTPUT"], "a") as f:
|
||||||
f.write(f"added_cents={int(round(total_cents))}\n")
|
f.write(f"added_cents={int(round(cost_cents))}\n")
|
||||||
f.write(f"skipped=0\n")
|
f.write(f"skipped=0\n")
|
||||||
f.write(f"primary_verdict={heading_verdict}\n")
|
f.write(f"primary_verdict={heading_verdict}\n")
|
||||||
PY
|
PY
|
||||||
|
|
||||||
- name: Post PR review (formal APPROVE / REQUEST_CHANGES / COMMENT)
|
- name: Post PR review (formal APPROVE / REQUEST_CHANGES / COMMENT)
|
||||||
# Uses primary model's verdict as the formal event. Falls back to COMMENT
|
# Uses Haiku's verdict as the formal event. Falls back to COMMENT
|
||||||
# if the primary model failed or returned something unexpected.
|
# if the model failed or returned something unexpected.
|
||||||
if: steps.budget.outputs.skip != 'true' && steps.review.outputs.skipped != '1'
|
if: steps.budget.outputs.skip != 'true' && steps.review.outputs.skipped != '1'
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v7
|
||||||
env:
|
env:
|
||||||
|
|||||||
114
.github/workflows/deploy.yml
vendored
114
.github/workflows/deploy.yml
vendored
@@ -4,104 +4,22 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-prautoblogger-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
validate:
|
ci:
|
||||||
name: Validate Code Quality
|
name: CI Gate (reusable)
|
||||||
runs-on: ubuntu-latest
|
uses: ./.github/workflows/ci.yml
|
||||||
|
permissions:
|
||||||
steps:
|
contents: write
|
||||||
- name: Checkout code
|
secrets: inherit
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup PHP
|
|
||||||
uses: shivammathur/setup-php@v2
|
|
||||||
with:
|
|
||||||
php-version: '8.1'
|
|
||||||
tools: composer, phpcs
|
|
||||||
coverage: none
|
|
||||||
|
|
||||||
- name: PHP Lint Check
|
|
||||||
run: |
|
|
||||||
find . -name "*.php" -not -path "./vendor/*" -not -path "./node_modules/*" -exec php -l {} \; | (grep -v "No syntax errors" || true) | head -20
|
|
||||||
test ${PIPESTATUS[0]} -eq 0
|
|
||||||
|
|
||||||
- name: Install Composer dependencies
|
|
||||||
run: composer install --no-interaction --prefer-dist
|
|
||||||
|
|
||||||
- name: Install WordPress Coding Standards
|
|
||||||
run: |
|
|
||||||
# Composer 2.2+ blocks plugins by default. The WPCS installer needs
|
|
||||||
# dealerdirect/phpcodesniffer-composer-installer to wire standards
|
|
||||||
# into PHPCS. Allow it globally before requiring WPCS.
|
|
||||||
composer global config --no-plugins allow-plugins.dealerdirect/phpcodesniffer-composer-installer true
|
|
||||||
composer global require "wp-coding-standards/wpcs:^3.0" "phpcsstandards/phpcsutils:^1.0" --no-interaction
|
|
||||||
phpcs --config-set installed_paths "$(composer global config home)/vendor/wp-coding-standards/wpcs"
|
|
||||||
|
|
||||||
- name: Run PHPCS (WordPress standards)
|
|
||||||
run: |
|
|
||||||
phpcs --standard=WordPress-Core \
|
|
||||||
--extensions=php \
|
|
||||||
--ignore=vendor/*,tests/*,node_modules/* \
|
|
||||||
--warning-severity=0 \
|
|
||||||
--report=summary \
|
|
||||||
includes/ prautoblogger.php uninstall.php || true
|
|
||||||
|
|
||||||
- name: Run PHPUnit
|
|
||||||
run: vendor/bin/phpunit --testdox
|
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
needs: validate
|
name: Deploy (shared pipeline)
|
||||||
name: Deploy to Production
|
needs: ci
|
||||||
runs-on: ubuntu-latest
|
uses: peptide-repo/peptide-e2e/.github/workflows/deploy-app.yml@main
|
||||||
|
with:
|
||||||
steps:
|
target_path: wp-content/plugins/prautoblogger
|
||||||
- name: Checkout code
|
secrets: inherit
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup SSH key
|
|
||||||
run: |
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
|
|
||||||
chmod 600 ~/.ssh/deploy_key
|
|
||||||
ssh-keyscan -p ${{ secrets.SSH_PORT }} ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
|
||||||
|
|
||||||
- name: Deploy via rsync
|
|
||||||
run: |
|
|
||||||
rsync -avz --delete \
|
|
||||||
--exclude '.git' \
|
|
||||||
--exclude '.github' \
|
|
||||||
--exclude 'tests' \
|
|
||||||
--exclude 'phpunit.xml.dist' \
|
|
||||||
--exclude 'composer.json' \
|
|
||||||
--exclude 'composer.lock' \
|
|
||||||
--exclude 'vendor' \
|
|
||||||
--exclude '.distignore' \
|
|
||||||
--exclude 'ARCHITECTURE.md' \
|
|
||||||
--exclude 'CONVENTIONS.md' \
|
|
||||||
-e "ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }}" \
|
|
||||||
./ ${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_HOST }}:~/domains/peptiderepo.com/public_html/wp-content/plugins/prautoblogger/
|
|
||||||
|
|
||||||
- name: Purge LiteSpeed Cache
|
|
||||||
run: |
|
|
||||||
ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
|
|
||||||
${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_HOST }} \
|
|
||||||
"cd ~/domains/peptiderepo.com/public_html && find wp-content/litespeed/ -type f -delete 2>/dev/null; touch .lscache_purge 2>/dev/null || true"
|
|
||||||
|
|
||||||
- name: Health check
|
|
||||||
run: |
|
|
||||||
sleep 5
|
|
||||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://peptiderepo.com)
|
|
||||||
# 200 = normal healthy response.
|
|
||||||
# 403 = peptide-starter-theme v1.5.2 CF-peer validator rejecting this
|
|
||||||
# GHA-runner direct-to-origin request as non-Cloudflare traffic.
|
|
||||||
# That rejection is the validator working — itself a positive
|
|
||||||
# liveness signal. Real user traffic via Cloudflare gets 200.
|
|
||||||
if [ "$STATUS" != "200" ] && [ "$STATUS" != "403" ]; then
|
|
||||||
echo "ERROR: Site returned unexpected HTTP $STATUS after deploy"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Site is responsive (HTTP $STATUS)"
|
|
||||||
|
|
||||||
- name: Cleanup
|
|
||||||
if: always()
|
|
||||||
run: rm -f ~/.ssh/deploy_key
|
|
||||||
|
|||||||
12
.github/workflows/main-push-audit.yml
vendored
12
.github/workflows/main-push-audit.yml
vendored
@@ -29,12 +29,22 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const sha = context.sha;
|
const sha = context.sha;
|
||||||
|
// Squash-merge -> PR association is eventually-consistent; poll briefly
|
||||||
|
// before concluding this was a direct push (avoids false positives on merges).
|
||||||
|
async function findMergedPR() {
|
||||||
|
for (let attempt = 1; attempt <= 5; attempt++) {
|
||||||
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
commit_sha: sha,
|
commit_sha: sha,
|
||||||
});
|
});
|
||||||
const merged = prs.find(p => p.merged_at);
|
const m = prs.find(p => p.merged_at);
|
||||||
|
if (m) return m;
|
||||||
|
if (attempt < 5) await new Promise(r => setTimeout(r, 10000));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const merged = await findMergedPR();
|
||||||
if (merged) {
|
if (merged) {
|
||||||
core.info(`OK: commit ${sha.substring(0,7)} came from PR #${merged.number}`);
|
core.info(`OK: commit ${sha.substring(0,7)} came from PR #${merged.number}`);
|
||||||
return;
|
return;
|
||||||
|
|||||||
738
ARCHITECTURE.md
Executable file → Normal file
738
ARCHITECTURE.md
Executable file → Normal file
@@ -16,9 +16,10 @@ PRAutoBlogger is a WordPress plugin that monitors social media (starting with Re
|
|||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌─────────────────────────┐
|
┌─────────────────────────┐
|
||||||
│ 1. Source Collector │ Pulls raw posts/comments from social platforms
|
│ 1. Source Collector │ Pulls data from enabled sources:
|
||||||
│ (Reddit RSS primary │ Reddit RSS/Atom feeds (primary) / .json (fallback)
|
│ (Reddit + LLM Research)│ - Reddit RSS/Atom (primary) / .json (fallback)
|
||||||
│ / .json fallback) Stores raw data in `ab_source_data` table
|
│ │ - LLM Deep Research (reasoning model)
|
||||||
|
│ │ Stores raw data in `ab_source_data` table
|
||||||
└────────────┬────────────┘
|
└────────────┬────────────┘
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
@@ -65,10 +66,21 @@ PRAutoBlogger is a WordPress plugin that monitors social media (starting with Re
|
|||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌─────────────────────────┐
|
┌─────────────────────────┐
|
||||||
│ 6b. Image Pipeline │ (commit 1b) Generates two A/B images:
|
│ 6b. Image Pipeline │ Generates two A/B images in parallel:
|
||||||
│ (optional, if enabled) │ - Image A: article-driven (featured)
|
│ (optional, if enabled) │ - Image A: article-driven (featured)
|
||||||
│ │ - Image B: source-driven (post meta)
|
│ │ - Image B: source-driven (post meta)
|
||||||
│ │ Sideloads to media library, logs costs
|
│ │ Provider derived from the saved Image Model
|
||||||
|
│ │ via Image_Model_Registry::provider_for().
|
||||||
|
│ │ NSFW-blocked slots (CF error code 3030) are
|
||||||
|
│ │ retried once with a rule-based fallback
|
||||||
|
│ │ prompt via Image_NSFW_Retry; a second block
|
||||||
|
│ │ logs WARNING and publishes without that image.
|
||||||
|
│ │ v0.17.0: results then pass through the local
|
||||||
|
│ │ deterministic Image_Composer (corner mark +
|
||||||
|
│ │ branded OG/square variants for Image A; mark
|
||||||
|
│ │ only for Image B) with a capability ladder
|
||||||
|
│ │ (imagick → GD resize-only → pass-through).
|
||||||
|
│ │ Sideloads to media library, logs costs.
|
||||||
└────────────┬────────────┘
|
└────────────┬────────────┘
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
@@ -96,26 +108,60 @@ prautoblogger/
|
|||||||
├── CHANGELOG.md # Semantic versioning changelog
|
├── CHANGELOG.md # Semantic versioning changelog
|
||||||
│
|
│
|
||||||
├── assets/
|
├── assets/
|
||||||
|
│ ├── brand/ # Vendored brand PNGs rasterized from peptide-repo-brand SVGs (composer overlays)
|
||||||
|
│ │ ├── logo-mark-small-56.png # Corner mark, 1x
|
||||||
|
│ │ ├── logo-mark-small-112.png # Corner mark / OG band logo, 2x
|
||||||
|
│ │ ├── logo-horizontal-reverse-128.png # Square footer lockup, 1x
|
||||||
|
│ │ └── logo-horizontal-reverse-256.png # Square footer lockup, 2x
|
||||||
|
│ ├── fonts/ # Bundled OFL faces for deterministic text rendering
|
||||||
|
│ │ ├── Poppins-Bold.ttf # Square-card caption face
|
||||||
|
│ │ ├── Poppins-SemiBold.ttf # OG band caption face
|
||||||
|
│ │ └── OFL.txt # SIL Open Font License
|
||||||
│ ├── css/
|
│ ├── css/
|
||||||
│ │ ├── admin.css # Admin page styles (wp-admin conventions)
|
│ │ ├── admin.css # Admin page styles (wp-admin conventions)
|
||||||
|
│ │ ├── board.css # Kanban board styles -- warm editorial palette (v0.19.0)
|
||||||
|
│ │ ├── dossier.css # Article dossier page styles: verdict pills, cost receipt, trace toggle (v0.19.2)
|
||||||
|
│ │ ├── dossier-edit.css # M3 edit/re-run layer: chips, edit panel, image grid, spend warning (v0.20.0)
|
||||||
│ │ └── posts-widget.css # Frontend posts widget styles (uses theme CSS vars)
|
│ │ └── posts-widget.css # Frontend posts widget styles (uses theme CSS vars)
|
||||||
│ └── js/
|
│ └── js/
|
||||||
│ ├── admin.js # Admin page interactivity (vanilla JS / Alpine.js)
|
│ ├── admin.js # Admin page interactivity (vanilla JS / Alpine.js)
|
||||||
|
│ ├── board.js # Board poller: AJAX poll + DOM updates + backoff (v0.19.0)
|
||||||
|
│ ├── dossier.js # Per-stage raw-trace toggle: aria-expanded / aria-controls (v0.19.2)
|
||||||
|
│ ├── dossier-edit.js # M3 edit/re-run actions + stage-status polling (queued -> pickup -> result) (v0.20.0)
|
||||||
│ └── posts-widget.js # React component for frontend post cards (wp.element)
|
│ └── posts-widget.js # React component for frontend post cards (wp.element)
|
||||||
│
|
│
|
||||||
├── includes/
|
├── includes/
|
||||||
│ ├── class-prautoblogger.php # Main orchestrator — registers all hooks via loader
|
│ ├── class-prautoblogger.php # Main orchestrator — registers all hooks, delegates execution (split v0.19.2)
|
||||||
|
│ ├── class-db-migrations.php # DB migration methods extracted from class-prautoblogger.php (v0.19.2 split, binding 1)
|
||||||
|
│ ├── class-pipeline-schema-installer.php # Pipeline substrate tables (prompts, run_sources, run_decisions, runs, run_stages, stage_inputs)
|
||||||
|
│ ├── class-migrate-prompt-seed-v0180.php # One-shot v0.18.0 prompt-registry seed migration
|
||||||
|
│ ├── class-executor.php # Cron handlers, generation AJAX (delegates start + status to poller), model registry
|
||||||
|
│ ├── class-generation-status-poller.php # Generation AJAX handlers: status transient renewal, lock-age orphan detection (R2b/R3), abort_orphaned_run
|
||||||
|
│ ├── class-ajax-handlers.php # Non-generation AJAX: images, models, test connections
|
||||||
|
│ ├── class-generation-lock.php # DB-level atomic mutex for single-writer generation
|
||||||
│ ├── class-activator.php # Activation: create DB tables, set defaults, schedule cron
|
│ ├── class-activator.php # Activation: create DB tables, set defaults, schedule cron
|
||||||
│ ├── class-deactivator.php # Deactivation: clear cron, cleanup transients
|
│ ├── class-deactivator.php # Deactivation: clear cron, cleanup transients
|
||||||
│ ├── class-autoloader.php # PSR-4-style autoloader for plugin classes
|
│ ├── class-autoloader.php # PSR-4-style autoloader for plugin classes
|
||||||
│ │
|
│ │
|
||||||
│ ├── admin/
|
│ ├── admin/
|
||||||
|
│ │ ├── class-board-page.php # Kanban board -- primary landing screen (v0.19.0)
|
||||||
|
│ │ ├── class-board-data-provider.php # Board orchestrator: 4-column snapshot + dossier deep-links (v0.19.0, v0.19.2)
|
||||||
|
│ │ ├── class-board-gen-log-query.php # Board gen_log queries: Generating + Failed column raw DB reads (v0.19.0)
|
||||||
|
│ │ ├── class-dossier-page.php # Article dossier admin page: options.php-parent hidden-page, priority 12, enqueue, nonce (v0.19.3)
|
||||||
|
│ │ ├── class-dossier-data-assembler.php # 5-query view-model builder: runs+stages+gen_log+decisions+meta (v0.19.2)
|
||||||
|
│ │ ├── class-dossier-gen-log-query.php # Dossier gen_log queries: per-stage cost receipt + raw trace (v0.19.2)
|
||||||
|
│ │ ├── class-dossier-actions.php # M3 AJAX: save fork / queue replay / queue rebuild / stage-status poll (v0.20.0)
|
||||||
|
│ │ ├── class-dossier-rerun-panel-data.php # M3 per-stage affordance + spend view model (v0.20.0)
|
||||||
|
│ │ ├── class-dossier-image-data.php # F3: post's pipeline attachments via _prautoblogger_image_role (v0.20.0)
|
||||||
│ │ ├── class-admin-page.php # Main settings page (tabbed SaaS-style UI)
|
│ │ ├── class-admin-page.php # Main settings page (tabbed SaaS-style UI)
|
||||||
│ │ ├── class-settings-fields.php # Declarative settings definitions (sections + fields)
|
│ │ ├── class-settings-fields.php # Declarative settings: sections + core fields (API/Models/Content/Sources)
|
||||||
|
│ │ ├── class-settings-fields-extended.php # Operational fields: schedule, publishing, display, analytics, images
|
||||||
|
│ │ ├── class-image-model-registry.php # Static list of image generation models for the model picker
|
||||||
│ │ ├── class-admin-notices.php # Onboarding notices, error alerts, budget warnings
|
│ │ ├── class-admin-notices.php # Onboarding notices, error alerts, budget warnings
|
||||||
│ │ ├── class-dashboard-widget.php # WP Dashboard widget showing generation status
|
│ │ ├── class-dashboard-widget.php # WP Dashboard widget showing generation status
|
||||||
│ │ ├── class-post-metabox.php # Metabox on posts showing generation metadata
|
│ │ ├── class-post-metabox.php # Metabox on posts showing generation metadata
|
||||||
│ │ ├── class-metrics-page.php # Admin page for cost dashboard and content scores
|
│ │ ├── class-metrics-page.php # Admin page for cost dashboard and content scores
|
||||||
|
│ │ ├── class-ideas-browser.php # Browse all analysis results (article ideas)
|
||||||
│ │ ├── class-review-queue.php # Approve/edit/reject queue for generated drafts
|
│ │ ├── class-review-queue.php # Approve/edit/reject queue for generated drafts
|
||||||
│ │ └── class-log-viewer.php # Activity log viewer with level filtering
|
│ │ └── class-log-viewer.php # Activity log viewer with level filtering
|
||||||
│ │
|
│ │
|
||||||
@@ -123,40 +169,87 @@ prautoblogger/
|
|||||||
│ │ ├── class-scheduler.php # WP-Cron / Action Scheduler job management
|
│ │ ├── class-scheduler.php # WP-Cron / Action Scheduler job management
|
||||||
│ │ ├── class-source-collector.php # Orchestrates data collection from all sources
|
│ │ ├── class-source-collector.php # Orchestrates data collection from all sources
|
||||||
│ │ ├── class-content-analyzer.php # LLM-powered analysis of collected social data
|
│ │ ├── class-content-analyzer.php # LLM-powered analysis of collected social data
|
||||||
│ │ ├── class-idea-scorer.php # Ranks and deduplicates article ideas
|
│ │ ├── class-analysis-prompts.php # System/user prompt builders + performance context for analyzer
|
||||||
|
│ │ ├── class-idea-scorer.php # Ranks and deduplicates article ideas (delegates to Semantic_Dedup)
|
||||||
|
│ │ ├── class-semantic-dedup.php # Embedding-based cosine similarity dedup with keyword fallback
|
||||||
│ │ ├── class-content-generator.php# Writer agent — manages the generation pipeline
|
│ │ ├── class-content-generator.php# Writer agent — manages the generation pipeline
|
||||||
|
│ │ ├── class-content-prompts.php # Builds all LLM prompts for content generation (system, stage, linking rules)
|
||||||
|
│ │ ├── class-peptide-linker.php # Deterministic post-processor: hyperlinks peptide mentions to /peptides/ pages
|
||||||
│ │ ├── class-chief-editor.php # Editor agent — LLM-powered editorial review
|
│ │ ├── class-chief-editor.php # Editor agent — LLM-powered editorial review
|
||||||
│ │ ├── class-publisher.php # Creates WordPress posts from approved content
|
│ │ ├── class-publisher.php # Creates WordPress posts from approved content
|
||||||
│ │ ├── class-image-pipeline.php # Orchestrates A/B image generation (commit 1b)
|
│ │ ├── class-post-assembler.php # Post-creation helpers: taxonomy, log linking, images, sanitization
|
||||||
|
│ │ ├── class-image-pipeline.php # Orchestrates A/B image generation (parallel via batch)
|
||||||
│ │ ├── class-image-prompt-builder.php # Generates visual prompts from article/source data
|
│ │ ├── class-image-prompt-builder.php # Generates visual prompts from article/source data
|
||||||
|
│ │ ├── class-image-template-filler.php # Fills the editorial style template's {{ topic_summary }} slot (v0.16.0)
|
||||||
|
│ │ ├── interface-image-composer.php # Contract: provider bytes → featured-first variant list (v0.17.0)
|
||||||
|
│ │ ├── class-image-composer.php # Composer orchestrator: cached capability probe + degradation ladder
|
||||||
|
│ │ ├── class-image-composer-imagick.php # Imagick renderer: corner mark, OG band, square card (WP-free)
|
||||||
|
│ │ ├── class-image-composer-canvas.php # Imagick primitives: smoke test, cover-crop, logo, deterministic PNG (WP-free)
|
||||||
|
│ │ ├── class-image-composer-editor.php # Resize-only rung via wp_get_image_editor (GD path)
|
||||||
|
│ │ ├── class-image-composer-layout.php # Geometry defaults + caption clamp (pure, WP-free)
|
||||||
|
│ │ ├── class-image-attacher.php # Sideload + cost log + variant meta-linking + caption prepend
|
||||||
│ │ ├── class-image-media-sideloader.php # Imports images into WordPress media library
|
│ │ ├── class-image-media-sideloader.php # Imports images into WordPress media library
|
||||||
│ │ ├── class-cost-tracker.php # Logs all API costs, enforces budget limits
|
│ │ ├── class-cost-tracker.php # Logs all API costs, enforces budget limits
|
||||||
|
│ │ ├── class-stage-display-map.php # Stage vocabulary: labels + default roles + prompt keys (old + new + image stages)
|
||||||
|
│ │ ├── class-prompt-registry.php # Versioned prompt registry, read side (render, pins, self-healing fallback)
|
||||||
|
│ │ ├── class-prompt-registry-writer.php # Registry write side (create version, activate, seed) — versions immutable
|
||||||
|
│ │ ├── class-prompt-defaults.php # Canonical v1 bodies: content.* + analysis.* (seed + fallback single source)
|
||||||
|
│ │ ├── class-prompt-defaults-editorial.php # Canonical v1 bodies: editor.* / research.system / image.* (composer seam)
|
||||||
|
│ │ ├── class-run-context.php # Per-process active run id (set by Cost_Tracker::set_run_id)
|
||||||
|
│ │ ├── class-run-state.php # runs-table ledger + lifecycle row (ceiling/reserved/settled, pins, status)
|
||||||
|
│ │ ├── class-cost-governor.php # Per-run reserve-before-call enforcement (atomic conditional UPDATE)
|
||||||
|
│ │ ├── class-cost-ceiling-exception.php # Thrown on ceiling breach (run already halted)
|
||||||
|
│ │ ├── class-run-stage-state.php # Per-run per-stage state machine: read API + thin write proxies (split v0.20.0)
|
||||||
|
│ │ ├── class-run-stage-writes.php # Pipeline-path writes (start/done/fail; done clears stale) — extracted v0.20.0
|
||||||
|
│ │ ├── class-run-stage-rerun-state.php # M3 operator-action mutations: restart/mark_stale/demote (v0.20.0)
|
||||||
|
│ │ ├── class-stage-input-store.php # INSERT-only stage_inputs store: edit forks + idea seeds (v0.20.0)
|
||||||
|
│ │ ├── class-rerun-eligibility.php # M3 policy gates: frozen posts, editable stages, chain order (v0.20.0)
|
||||||
|
│ │ ├── class-stage-replay.php # M3 governed single-stage replay from a fork body (v0.20.0)
|
||||||
|
│ │ ├── class-rerun-executor.php # M3 cron handlers: replay + rebuild jobs (v0.20.0)
|
||||||
|
│ │ ├── class-rerun-job-support.php # M3 queue/lock/budget/status-transient plumbing (v0.20.0)
|
||||||
|
│ │ ├── class-request-recorder.php # B1: process-scoped outbound request-body stash -> generation_log.request_json (v0.20.0)
|
||||||
|
│ │ ├── class-run-reaper.php # Stuck-run sweep + audit-payload retention (rides the #19 cron)
|
||||||
|
│ │ ├── class-audit-writer.php # run_sources / run_decisions insert layer
|
||||||
|
│ │ ├── class-pipeline-status.php # Status-transient + summary helpers (extracted from runner/worker)
|
||||||
│ │ ├── class-logger.php # Structured logging singleton (error/warning/info/debug)
|
│ │ ├── class-logger.php # Structured logging singleton (error/warning/info/debug)
|
||||||
│ │ ├── class-pipeline-runner.php # Orchestrates the full generation pipeline
|
│ │ ├── class-article-worker.php # Single-article generation (content + edit + publish)
|
||||||
|
│ │ ├── class-pipeline-runner.php # Orchestrates pipeline; chains per-article cron jobs
|
||||||
│ │ ├── class-ga4-client.php # Google Analytics 4 API client (OAuth + Data API)
|
│ │ ├── class-ga4-client.php # Google Analytics 4 API client (OAuth + Data API)
|
||||||
│ │ └── class-metrics-collector.php# Collects post performance data (WP + GA4)
|
│ │ └── class-metrics-collector.php# Collects post performance data (WP + GA4)
|
||||||
│ │
|
│ │
|
||||||
│ ├── providers/
|
│ ├── providers/
|
||||||
│ │ ├── interface-llm-provider.php # Contract for any LLM provider
|
│ │ ├── interface-llm-provider.php # Contract for any LLM provider
|
||||||
│ │ ├── class-open-router-provider.php # OpenRouter API implementation
|
│ │ ├── class-open-router-provider.php # OpenRouter API implementation (chat completions)
|
||||||
|
│ │ ├── class-open-router-completion-guard.php # Empty-completion guard: warn on finish_reason=length, one reasoning-off retry, audited failure (v0.18.1)
|
||||||
|
│ │ ├── class-open-router-embedding-provider.php # Text embeddings for semantic dedup
|
||||||
│ │ ├── class-open-router-pricing.php # Model pricing lookup and cost estimation
|
│ │ ├── class-open-router-pricing.php # Model pricing lookup and cost estimation
|
||||||
│ │ ├── interface-source-provider.php # Contract for any social media source
|
│ │ ├── interface-source-provider.php # Contract for any social media source
|
||||||
│ │ ├── class-reddit-json-client.php # Reddit HTTP client — RSS (primary) + .json (fallback)
|
│ │ ├── class-reddit-json-client.php # Reddit HTTP client — RSS (primary) + .json (fallback)
|
||||||
|
│ │ ├── class-llm-research-provider.php # LLM deep research source (reasoning models)
|
||||||
│ │ ├── class-reddit-provider.php # Reddit data collection orchestrator (RSS primary)
|
│ │ ├── class-reddit-provider.php # Reddit data collection orchestrator (RSS primary)
|
||||||
│ │ ├── interface-image-provider.php # Contract for any image generation provider
|
│ │ ├── interface-image-provider.php # Contract for any image generation provider (incl. batch)
|
||||||
│ │ ├── class-cloudflare-image-provider.php # FLUX.1 via Cloudflare Workers AI
|
│ │ ├── class-open-router-image-provider.php # OpenRouter image gen (single + batch dispatch)
|
||||||
│ │ ├── class-cloudflare-image-pricing.php # Model alias + per-MP cost estimation
|
│ │ ├── class-open-router-image-batch.php # Parallel curl_multi execution for batch image gen
|
||||||
│ │ ├── class-cloudflare-image-validator.php # Non-destructive credential + connectivity check
|
│ │ ├── class-open-router-image-support.php # API key, response parsing, retry/backoff helpers
|
||||||
│ │ ├── class-cloudflare-image-support.php # Token, account, URL, and log helpers for the CF provider
|
│ │ ├── class-open-router-image-pricing.php # Model resolution + per-image cost estimation
|
||||||
|
│ │ ├── class-open-router-config.php # API base URL (direct vs AI Gateway)
|
||||||
|
│ │ ├── class-open-router-request-builder.php # Request body assembly + reasoning token cap/headroom (v0.18.1) + headers + Hostinger cURL auth workaround
|
||||||
|
│ │ ├── class-runware-image-provider.php # Runware FLUX.1 (default v0.9.0+)
|
||||||
|
│ │ ├── class-runware-image-pricing.php # FLUX schnell/dev cost table + resolver
|
||||||
|
│ │ ├── class-runware-image-support.php # Key, response parsing, retry, dimension snap
|
||||||
|
│ │ ├── class-runware-image-batch.php # True parallel curl_multi batch dispatcher
|
||||||
|
│ │ ├── class-runware-model-catalog.php # Live model catalog sync: modelSearch pagination, cache v2, failure cooldown
|
||||||
|
│ │ ├── class-runware-catalog-fetcher.php # HTTP fetch + normalize_models for Runware modelSearch (extracted for 300-line rule)
|
||||||
│ │ └── (new providers go here — see CONVENTIONS.md)
|
│ │ └── (new providers go here — see CONVENTIONS.md)
|
||||||
│ │
|
│ │
|
||||||
│ ├── services/
|
│ ├── services/
|
||||||
│ │ ├── interface-model-registry.php # Contract for provider-specific model registries
|
│ │ ├── interface-model-registry.php # Contract for provider-specific model registries
|
||||||
│ │ ├── class-openrouter-model-registry.php # Fetches, caches, queries the OpenRouter model list
|
│ │ ├── class-open-router-model-registry.php # Fetches, caches, queries the OpenRouter model list
|
||||||
│ │ ├── class-openrouter-model-normalizer.php # Maps raw OpenRouter data → standardized record shape
|
│ │ ├── class-open-router-model-normalizer.php # Maps raw OpenRouter data → standardized record shape
|
||||||
│ │ └── (Phase 3: class-cloudflare-workers-ai-model-registry.php)
|
│ │ └── (Phase 3: class-runware-model-registry.php)
|
||||||
│ │
|
│ │
|
||||||
│ ├── frontend/
|
│ ├── frontend/
|
||||||
|
│ │ ├── class-article-typography.php # Inline CSS for font, size, table borders on generated posts
|
||||||
│ │ └── class-posts-widget.php # [prautoblogger_posts] shortcode + REST endpoint
|
│ │ └── class-posts-widget.php # [prautoblogger_posts] shortcode + REST endpoint
|
||||||
│ │
|
│ │
|
||||||
│ └── models/
|
│ └── models/
|
||||||
@@ -170,11 +263,19 @@ prautoblogger/
|
|||||||
│
|
│
|
||||||
├── templates/
|
├── templates/
|
||||||
│ └── admin/
|
│ └── admin/
|
||||||
|
│ ├── board-page.php # Kanban board template (4-column editorial layout) (v0.19.0)
|
||||||
|
│ ├── dossier-page.php # Article dossier: two-column layout, sidebar, stage sections (v0.19.2)
|
||||||
|
│ ├── dossier-stage-section.php # Stage section partial: output, raw trace, M3 chips + rerun footer
|
||||||
|
│ ├── dossier-edit-panel.php # M3 per-stage edit panel: message textareas, fork info, spend strip (v0.20.0)
|
||||||
|
│ ├── dossier-log-stage-section.php # F3 log-only stage sections incl. image attachment grid (v0.20.0)
|
||||||
|
│ ├── dossier-sidebar-cards.php # M3 sidebar: run spend (guardrail 4) + models/pv consolidation (F2) (v0.20.0)
|
||||||
|
│ ├── dossier-stage-section.php # Per-stage block: rendered output + raw-trace toggle + cost receipt (v0.19.2)
|
||||||
|
│ ├── metabox-dossier-link.php # Slim post metabox: "View generation dossier →" link (v0.19.2)
|
||||||
│ ├── settings-page.php # Settings page template (tabbed sidebar layout)
|
│ ├── settings-page.php # Settings page template (tabbed sidebar layout)
|
||||||
│ ├── metrics-page.php # Metrics/cost dashboard template
|
│ ├── metrics-page.php # Metrics/cost dashboard template
|
||||||
│ ├── review-queue.php # Review queue table template
|
│ ├── review-queue.php # Review queue table template
|
||||||
│ ├── log-viewer.php # Activity log viewer template
|
│ ├── log-viewer.php # Activity log viewer template
|
||||||
│ └── metabox-generation-info.php# Post metabox template
|
│ └── metabox-generation-info.php# Post metabox template (superseded by metabox-dossier-link.php in v0.19.2)
|
||||||
│
|
│
|
||||||
├── languages/
|
├── languages/
|
||||||
│ └── prautoblogger.pot # i18n translation template
|
│ └── prautoblogger.pot # i18n translation template
|
||||||
@@ -232,6 +333,61 @@ The widget CSS (`posts-widget.css`) uses the Peptide Starter theme's CSS custom
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Background Generation Flow (v0.3.0)
|
||||||
|
|
||||||
|
The "Generate Now" button runs the pipeline in a background WP-Cron process to avoid
|
||||||
|
Hostinger's 120-second LiteSpeed connection timeout. The frontend polls for status.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ Admin clicks "Generate Now" │ admin.js sends AJAX to on_ajax_generate_now()
|
||||||
|
└────────────┬─────────────────┘
|
||||||
|
│ Handler returns immediately (< 1 second):
|
||||||
|
│ 1. Writes "running" transient with started timestamp
|
||||||
|
│ 2. Schedules one-shot cron: prautoblogger_manual_generation
|
||||||
|
│ 3. Fires non-blocking loopback to wp-cron.php
|
||||||
|
│ 4. Returns JSON success to browser
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ admin.js starts polling │ setInterval every 5 seconds
|
||||||
|
│ on_ajax_generation_status() │ Shows stage text + spinner
|
||||||
|
└────────────┬─────────────────┘
|
||||||
|
│
|
||||||
|
▼ (separate PHP process)
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ on_manual_generation() │ WP-Cron fires this in a new request
|
||||||
|
│ (class-executor.php) │ 1. ignore_user_abort(true) — survives HTTP kill
|
||||||
|
│ │ 2. set_time_limit(300)
|
||||||
|
│ │ 3. Acquires PRAutoBlogger_Generation_Lock
|
||||||
|
│ │ 4. Runs PRAutoBlogger_Pipeline_Runner::run()
|
||||||
|
│ │ (pipeline broadcasts stage updates to transient)
|
||||||
|
│ │ 5. Writes final result to transient
|
||||||
|
│ │ 6. Releases lock
|
||||||
|
└────────────┬─────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ Poller detects completion │ Reads transient: status = 'complete' or 'error'
|
||||||
|
│ Shows result message │ Resets button to idle state
|
||||||
|
└──────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fallback detection (v0.18.3 R2b/R3):** The status transient is renewed on every stage
|
||||||
|
transition via `update_generation_stage()` (`Pipeline_Status::broadcast()`), so its TTL
|
||||||
|
always counts from the last activity rather than run start. When a status poll finds the
|
||||||
|
transient absent:
|
||||||
|
|
||||||
|
- **Lock within TTL (`lock_age ≤ STATUS_TTL = 600s`):** the background process is still alive
|
||||||
|
but has not broadcast yet (or the transient was evicted). `handle_missing_transient()` returns
|
||||||
|
`status: running` (R3) — button stays in-progress, no state change.
|
||||||
|
- **Lock exceeded TTL (`lock_age > STATUS_TTL`):** background process died without releasing the
|
||||||
|
lock. `abort_orphaned_run()` marks the `runs` row `failed`, releases the generation lock, deletes
|
||||||
|
the status transient and queue option, and returns `status: error` with "infrastructure timeout"
|
||||||
|
(R2b). A daily `Run_Reaper::sweep_stuck_runs()` at 2× `EXPECTED_RUN_SECONDS` (~3600s) remains
|
||||||
|
the backstop for runs the poll path never sees.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Database Schema
|
## Database Schema
|
||||||
|
|
||||||
### Custom Tables
|
### Custom Tables
|
||||||
@@ -282,7 +438,7 @@ All tables use `$wpdb->prefix` + `prautoblogger_` prefix.
|
|||||||
| id | BIGINT UNSIGNED | Auto-increment PK |
|
| id | BIGINT UNSIGNED | Auto-increment PK |
|
||||||
| post_id | BIGINT UNSIGNED | WordPress post ID (nullable, set after publish)|
|
| post_id | BIGINT UNSIGNED | WordPress post ID (nullable, set after publish)|
|
||||||
| run_id | VARCHAR(36) | UUID linking all entries from one pipeline run |
|
| run_id | VARCHAR(36) | UUID linking all entries from one pipeline run |
|
||||||
| stage | VARCHAR(50) | 'analysis', 'outline', 'draft', 'edit', 'review' |
|
| stage | VARCHAR(50) | See "Stage vocabulary" below. Canonical edit-stage name is **'polish'** (this doc previously said 'edit'; the code always wrote 'polish') |
|
||||||
| provider | VARCHAR(50) | 'openrouter' |
|
| provider | VARCHAR(50) | 'openrouter' |
|
||||||
| model | VARCHAR(100) | Model identifier used |
|
| model | VARCHAR(100) | Model identifier used |
|
||||||
| prompt_tokens | INT | Input tokens consumed |
|
| prompt_tokens | INT | Input tokens consumed |
|
||||||
@@ -291,6 +447,8 @@ All tables use `$wpdb->prefix` + `prautoblogger_` prefix.
|
|||||||
| request_json | LONGTEXT | Full request payload (for debugging) |
|
| request_json | LONGTEXT | Full request payload (for debugging) |
|
||||||
| response_status | VARCHAR(20) | 'success', 'error', 'timeout' |
|
| response_status | VARCHAR(20) | 'success', 'error', 'timeout' |
|
||||||
| error_message | TEXT | Error details if failed (nullable) |
|
| error_message | TEXT | Error details if failed (nullable) |
|
||||||
|
| agent_role | VARCHAR(50) | v0.18.0, nullable. Role that made the call ('writer', 'editor', 'analyst', 'researcher', 'illustrator', …). Historical rows stay NULL |
|
||||||
|
| prompt_version | VARCHAR(20) | v0.18.0, nullable. Pinned prompt-registry version the call rendered with. Historical rows stay NULL |
|
||||||
| created_at | DATETIME | When the API call was made |
|
| created_at | DATETIME | When the API call was made |
|
||||||
|
|
||||||
- INDEX: `post_id`
|
- INDEX: `post_id`
|
||||||
@@ -298,6 +456,18 @@ All tables use `$wpdb->prefix` + `prautoblogger_` prefix.
|
|||||||
- INDEX: `created_at`
|
- INDEX: `created_at`
|
||||||
- INDEX: `stage`
|
- INDEX: `stage`
|
||||||
|
|
||||||
|
**Stage vocabulary.** `stage` is a VARCHAR, not a SQL enum; the vocabulary lives in
|
||||||
|
`PRAutoBlogger_Stage_Display_Map` (PHP), which renders historical and new values coherently
|
||||||
|
and maps each stage to its default agent role + primary prompt-registry key:
|
||||||
|
|
||||||
|
- **Historical values (exist on prod):** `analysis`, `outline`, `draft`, `polish`, `review`,
|
||||||
|
`llm_research`, `image_a`, `image_b`, `image_prompt_rewrite`, `opik_eval_judge`.
|
||||||
|
Note: single-pass generation logs its one call as `draft` (no `outline` row — that absence
|
||||||
|
is how single-pass and multi-step runs are told apart in cost reporting).
|
||||||
|
- **Pipeline v2 values (new runs may write):** `research`, `curate`, `draft`, `editorial`,
|
||||||
|
`seo`, `publish`.
|
||||||
|
- Unknown values render via a humanizing fallback — the audit view never renders blank.
|
||||||
|
|
||||||
#### `prab_event_log` — Structured application logging
|
#### `prab_event_log` — Structured application logging
|
||||||
|
|
||||||
| Column | Type | Description |
|
| Column | Type | Description |
|
||||||
@@ -330,6 +500,124 @@ All tables use `$wpdb->prefix` + `prautoblogger_` prefix.
|
|||||||
- INDEX: `measured_at`
|
- INDEX: `measured_at`
|
||||||
- INDEX: `composite_score`
|
- INDEX: `composite_score`
|
||||||
|
|
||||||
|
#### Pipeline v2 substrate tables (v0.18.0, db 1.2.0)
|
||||||
|
|
||||||
|
Real table names use the standard prefix, e.g. `wp_prautoblogger_prompts` (the plan-of-record's
|
||||||
|
`prab_*` names are shorthand). Created by `PRAutoBlogger_Pipeline_Schema_Installer`; all are
|
||||||
|
dropped on uninstall.
|
||||||
|
|
||||||
|
##### `prautoblogger_prompts` — versioned prompt registry
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|-------------|-----------------|----------------------------------------------------------|
|
||||||
|
| id | BIGINT UNSIGNED | Auto-increment PK |
|
||||||
|
| prompt_key | VARCHAR(64) | Registry key, e.g. 'content.single_pass' (`key` is reserved SQL) |
|
||||||
|
| version | INT UNSIGNED | Monotonic per key; **rows are immutable** — edits create new versions |
|
||||||
|
| body | LONGTEXT | Prompt template; `{{ token }}` placeholders filled at render time |
|
||||||
|
| model | VARCHAR(100) | Optional model hint (informational in Phase 1) |
|
||||||
|
| params_json | LONGTEXT | Optional params snapshot (temperature, max_tokens, …) |
|
||||||
|
| author | VARCHAR(100) | Who created the version ('seed:v0.18.0', wp user login, agent) |
|
||||||
|
| created_at | DATETIME | Version creation time |
|
||||||
|
| active | TINYINT(1) | Exactly one active row per key |
|
||||||
|
|
||||||
|
- UNIQUE: `prompt_key, version` · INDEX: `prompt_key, active`
|
||||||
|
|
||||||
|
##### `prautoblogger_run_sources` — per-run source audit
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|---------------|--------------|----------------------------------------------|
|
||||||
|
| id | BIGINT UNSIGNED | Auto-increment PK |
|
||||||
|
| run_id | VARCHAR(36) | Pipeline run UUID |
|
||||||
|
| agent_role | VARCHAR(50) | Role that surfaced the source |
|
||||||
|
| source_url | VARCHAR(500) | Source URL (nullable) |
|
||||||
|
| doi | VARCHAR(255) | DOI when known (nullable) |
|
||||||
|
| kept | TINYINT(1) | Keep/discard decision |
|
||||||
|
| reason | TEXT | Why kept or discarded (nullable) |
|
||||||
|
| quality_score | FLOAT | Source quality weighting (nullable) |
|
||||||
|
| created_at | DATETIME | Row creation time |
|
||||||
|
|
||||||
|
- INDEX: `run_id` — consolidates the old `source_ids_json` + `_prautoblogger_research_sources`
|
||||||
|
scatter for **new** runs; historical runs are not backfilled.
|
||||||
|
|
||||||
|
##### `prautoblogger_run_decisions` — per-stage decision audit
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|----------------|-------------|-----------------------------------------------|
|
||||||
|
| id | BIGINT UNSIGNED | Auto-increment PK |
|
||||||
|
| run_id | VARCHAR(36) | Pipeline run UUID |
|
||||||
|
| stage | VARCHAR(50) | Stage that decided (see stage vocabulary) |
|
||||||
|
| verdict | VARCHAR(50) | e.g. 'approved', 'revised', 'rejected', 'halted' |
|
||||||
|
| rationale | TEXT | Decision rationale (nullable) |
|
||||||
|
| citation_score | FLOAT | Nullable until the Phase-2 editorial loop computes it |
|
||||||
|
| human_modified | TINYINT(1) | v0.20.0 — decision derives from a human-edited input (set on replay of the stage, or on decisions recorded while rebuilding a human-modified item) |
|
||||||
|
| created_at | DATETIME | Row creation time |
|
||||||
|
|
||||||
|
- INDEX: `run_id`
|
||||||
|
|
||||||
|
##### `prautoblogger_runs` — run ledger + lifecycle
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|---------------------|---------------|---------------------------------------------|
|
||||||
|
| run_id | VARCHAR(36) | PK — pipeline run UUID |
|
||||||
|
| status | VARCHAR(20) | pending / running / done / failed / halted |
|
||||||
|
| ceiling_usd | DECIMAL(10,6) | Per-run cost ceiling snapshotted at run start |
|
||||||
|
| reserved_usd | DECIMAL(10,6) | Outstanding reserve-before-call holds |
|
||||||
|
| settled_usd | DECIMAL(10,6) | Actual settled spend |
|
||||||
|
| overage_usd | DECIMAL(10,6) | Amount the breaching reservation exceeded the ceiling by |
|
||||||
|
| pinned_prompts_json | LONGTEXT | Map of prompt_key → version frozen at run start |
|
||||||
|
| started_at | DATETIME | Run start |
|
||||||
|
| updated_at | DATETIME | Last ledger/state write |
|
||||||
|
| finished_at | DATETIME | Set on done/failed/halted (nullable) |
|
||||||
|
|
||||||
|
- INDEX: `status` — the Cost_Governor's reserve is a single conditional UPDATE against this
|
||||||
|
row (same atomic discipline as the #10 generation lock), so concurrent `curl_multi`
|
||||||
|
writers cannot slip past the ceiling between check and call.
|
||||||
|
|
||||||
|
##### `prautoblogger_run_stages` — per-run per-stage state machine
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|-------------|-----------------|----------------------------------------------------|
|
||||||
|
| id | BIGINT UNSIGNED | Auto-increment PK |
|
||||||
|
| run_id | VARCHAR(36) | Pipeline run UUID |
|
||||||
|
| stage | VARCHAR(50) | Stage name (see stage vocabulary) |
|
||||||
|
| agent_role | VARCHAR(50) | Fan-out dimension. Populated in Phase 1 from `Stage_Display_Map::default_agent_role()` (e.g. 'writer', 'editor', 'publisher'). Phase-2 quorum passes multiple roles for the same stage. |
|
||||||
|
| item_key | VARCHAR(64) | Scopes article-level stages within multi-article runs ('' for run-level) |
|
||||||
|
| status | VARCHAR(20) | pending / running / done / failed / halted |
|
||||||
|
| attempt | SMALLINT UNSIGNED | Re-entry counter |
|
||||||
|
| cost_usd | DECIMAL(10,6) | Cost attributed to the stage |
|
||||||
|
| meta_json | LONGTEXT | Stage output snapshot — lets resume reuse a done stage instead of re-charging it (payload pruned on the retention cron) |
|
||||||
|
| human_modified | TINYINT(1) | v0.20.0 — set (sticky, never cleared) when an edited input fork enters execution for this row (CPO guardrail 2) |
|
||||||
|
| stale | TINYINT(1) | v0.20.0 — upstream changed after this row completed; row STAYS 'done' so resume never silently re-runs it; cleared only by a fresh done() |
|
||||||
|
| started_at | DATETIME | First entry |
|
||||||
|
| updated_at | DATETIME | Last transition |
|
||||||
|
| finished_at | DATETIME | Set on done/failed (nullable) |
|
||||||
|
|
||||||
|
- UNIQUE: `run_id, stage, agent_role, item_key` (the idempotency key) · INDEX: `status, updated_at`
|
||||||
|
|
||||||
|
##### `prautoblogger_stage_inputs` — immutable input-version store (v0.20.0, db 1.3.0)
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------------|-----------------|--------------------------------------------------|
|
||||||
|
| id | BIGINT UNSIGNED | Auto-increment PK |
|
||||||
|
| run_id | VARCHAR(36) | Pipeline run UUID |
|
||||||
|
| stage | VARCHAR(50) | Stage name ('' for idea seeds) |
|
||||||
|
| agent_role | VARCHAR(50) | Stage row agent role |
|
||||||
|
| item_key | VARCHAR(64) | Article scope |
|
||||||
|
| version | INT UNSIGNED | Monotonic per scope; **rows are INSERT-only** — edits create the next version, originals are never overwritten |
|
||||||
|
| source | VARCHAR(20) | 'human' (edit fork) or 'seed' (Article_Idea payload persisted at worker start) |
|
||||||
|
| request_json | LONGTEXT | Fork body / idea JSON. Human fork bodies pruned by the retention cron after R days; seed rows (~1KB) kept |
|
||||||
|
| author | VARCHAR(100) | WP user login, or 'pipeline' for seeds |
|
||||||
|
| created_at | DATETIME | Row creation time |
|
||||||
|
|
||||||
|
- UNIQUE: `run_id, stage, agent_role, item_key, version` · INDEX: `run_id`
|
||||||
|
- The ORIGINAL executed input of a stage is `generation_log.request_json` (B1) — it is
|
||||||
|
never copied or modified here; forks are the edit history (CPO guardrail 1).
|
||||||
|
|
||||||
|
**db version note:** `PRAUTOBLOGGER_DB_VERSION` moved 1.1.0 → 1.3.0 in v0.20.0. The
|
||||||
|
constant never actually carried the "db 1.2.0" this document used as shorthand for the
|
||||||
|
v0.18.0 substrate; 1.3.0 leapfrogs the phantom value so the version-compare self-healing
|
||||||
|
migration fires exactly once everywhere.
|
||||||
|
|
||||||
### WordPress Options (`wp_options`)
|
### WordPress Options (`wp_options`)
|
||||||
|
|
||||||
All prefixed with `prautoblogger_`:
|
All prefixed with `prautoblogger_`:
|
||||||
@@ -344,11 +632,14 @@ All prefixed with `prautoblogger_`:
|
|||||||
| `prautoblogger_analysis_model` | OpenRouter model for analysis (default: cheap) |
|
| `prautoblogger_analysis_model` | OpenRouter model for analysis (default: cheap) |
|
||||||
| `prautoblogger_writing_model` | OpenRouter model for writing (default: quality) |
|
| `prautoblogger_writing_model` | OpenRouter model for writing (default: quality) |
|
||||||
| `prautoblogger_editor_model` | OpenRouter model for chief editor (default: quality) |
|
| `prautoblogger_editor_model` | OpenRouter model for chief editor (default: quality) |
|
||||||
|
| `prautoblogger_reasoning_max_tokens` | v0.18.1 — hard cap on thinking tokens per call when reasoning is enabled; sent as OpenRouter `reasoning.max_tokens` (replaces `effort` when active) and the request `max_tokens` is raised by the cap so reasoning can never consume the visible-content budget; 0 = legacy uncapped effort mode. Default `PRAUTOBLOGGER_DEFAULT_REASONING_MAX_TOKENS` (2048) |
|
||||||
| `prautoblogger_daily_article_target` | Number of articles per day (1-10, default: 1) |
|
| `prautoblogger_daily_article_target` | Number of articles per day (1-10, default: 1) |
|
||||||
| `prautoblogger_writing_pipeline` | 'single_pass' or 'multi_step' (default: multi_step) |
|
| `prautoblogger_writing_pipeline` | 'single_pass' or 'multi_step' (default: multi_step) |
|
||||||
| `prautoblogger_niche_description` | Text description of the site's niche |
|
| `prautoblogger_niche_description` | Text description of the site's niche |
|
||||||
| `prautoblogger_target_subreddits` | JSON array of subreddits to monitor |
|
| `prautoblogger_target_subreddits` | JSON array of subreddits to monitor |
|
||||||
| `prautoblogger_monthly_budget_usd` | Monthly API spend limit in USD |
|
| `prautoblogger_monthly_budget_usd` | Monthly API spend limit in USD |
|
||||||
|
| `prautoblogger_per_run_cost_ceiling_usd` | v0.18.0 — per-run hard cost ceiling (USD); reserve-before-call enforced; snapshotted onto the runs row at run start; 0 = disabled. Default `PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD` ($0.50) |
|
||||||
|
| `prautoblogger_request_json_retention_days` | v0.18.0 — days before heavy audit payloads (generation_log.request_json, run_stages.meta_json) are NULLed by the daily cleanup cron; 0 = keep forever. Default `PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS` (14) |
|
||||||
| `prautoblogger_tone` | Content tone (informational, conversational, etc.) |
|
| `prautoblogger_tone` | Content tone (informational, conversational, etc.) |
|
||||||
| `prautoblogger_min_word_count` | Minimum article word count (default: 800) |
|
| `prautoblogger_min_word_count` | Minimum article word count (default: 800) |
|
||||||
| `prautoblogger_max_word_count` | Maximum article word count (default: 2000) |
|
| `prautoblogger_max_word_count` | Maximum article word count (default: 2000) |
|
||||||
@@ -360,12 +651,24 @@ All prefixed with `prautoblogger_`:
|
|||||||
| `prautoblogger_log_level` | Logging threshold: error/warning/info/debug |
|
| `prautoblogger_log_level` | Logging threshold: error/warning/info/debug |
|
||||||
| `prautoblogger_db_version` | Schema version for migrations |
|
| `prautoblogger_db_version` | Schema version for migrations |
|
||||||
| `prautoblogger_schedule_time` | Daily generation time (HH:MM, default: '03:00') |
|
| `prautoblogger_schedule_time` | Daily generation time (HH:MM, default: '03:00') |
|
||||||
| `prautoblogger_cloudflare_ai_token` | Encrypted Cloudflare Workers AI API token |
|
| `prautoblogger_image_model` | Image model slug from `Image_Model_Registry::get_models()`; provider is derived from this on save |
|
||||||
| `prautoblogger_cloudflare_account_id` | Cloudflare account UUID (plaintext — identifier, not secret) |
|
| `prautoblogger_image_provider` | Derived from the chosen model on save (v0.8.0+); not editable in admin UI |
|
||||||
| `prautoblogger_image_model` | Image model alias: `flux-1-schnell` (default) or `flux-1-dev` |
|
| `prautoblogger_image_prompt_instructions` | System prompt given to the image rewriter LLM (v0.8.0+); falls back to `Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT` when empty. v0.16.0: the default now instructs the LLM to emit a 1-2 sentence editorial topic/mechanism summary as the SCENE (was a comic gag) |
|
||||||
| `prautoblogger_image_style_suffix` | Text appended to every image prompt (default: CEO-locked 90s infomercial prompt) |
|
| `prautoblogger_image_nsfw_retry` | Toggle: retry NSFW-blocked image slots with a rule-based fallback prompt (default: '1') |
|
||||||
|
| `prautoblogger_migrated_image_provider_v080` | One-shot migration flag — auto-heals mismatched provider/model pairs on first admin_init after v0.8.0 upgrade |
|
||||||
|
| `prautoblogger_article_font_family` | Font family key: 'default', 'inter', 'georgia', 'merriweather', 'lora', 'open_sans', 'roboto', 'system' |
|
||||||
|
| `prautoblogger_article_font_size` | Body font size in px (0 = theme default). Recommended: 16–18. |
|
||||||
|
| `prautoblogger_table_borders` | Toggle: add borders/padding/striping to tables (default: '1') |
|
||||||
|
| `prautoblogger_image_style_template` | Full image prompt template (v0.16.0+); contains exactly one `{{ topic_summary }}` token filled per-article with the rewriter scene. Default = `PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE` (editorial scientific illustration). Replaces the Style Suffix. Validated on save (single-token, brief A5) |
|
||||||
|
| `prautoblogger_image_style_suffix` | DEPRECATED (v0.16.0). Former comic style suffix appended to every image prompt; no longer read by the builder. Mirrored to `prautoblogger_image_style_suffix_deprecated` for one cycle and not deleted yet |
|
||||||
| `prautoblogger_openrouter_model_registry` | Normalized OpenRouter model list (JSON array, daily refresh, serves as durable cache) |
|
| `prautoblogger_openrouter_model_registry` | Normalized OpenRouter model list (JSON array, daily refresh, serves as durable cache) |
|
||||||
| `prautoblogger_openrouter_model_registry_fetched_at` | Unix timestamp of last successful model registry refresh |
|
| `prautoblogger_openrouter_model_registry_fetched_at` | Unix timestamp of last successful model registry refresh |
|
||||||
|
| `prautoblogger_image_compose_enabled` | Toggle: run the deterministic image composer (default '1'; auto-degrades, never blocks publishing) |
|
||||||
|
| `prautoblogger_image_compose_variants` | Comma list of composed variants for Image A (default 'og,square'; whitelisted at point of use) |
|
||||||
|
| `prautoblogger_image_featured_mark_enabled` | Toggle: subtle corner mark on featured images (default '1') |
|
||||||
|
| `prautoblogger_image_compose_capability` | Cached capability probe `{fingerprint, capability}`; fingerprint = PHP version + imagick/gd presence, so it auto-invalidates on host changes |
|
||||||
|
| `prautoblogger_board_poll_interval` | Kanban board poll interval in seconds (default 5, min 3). Localized into board.js. |
|
||||||
|
| `prautoblogger_board_published_window_days` | Days back to show in the Published column (default 7). |
|
||||||
|
|
||||||
### Post Meta
|
### Post Meta
|
||||||
|
|
||||||
@@ -384,6 +687,15 @@ Stored on every PRAutoBlogger-generated post:
|
|||||||
| `_prautoblogger_editor_notes` | Chief editor's review notes |
|
| `_prautoblogger_editor_notes` | Chief editor's review notes |
|
||||||
| `_prautoblogger_generated_at` | ISO 8601 timestamp of generation |
|
| `_prautoblogger_generated_at` | ISO 8601 timestamp of generation |
|
||||||
| `_prautoblogger_research_sources` | JSON array of source URLs used |
|
| `_prautoblogger_research_sources` | JSON array of source URLs used |
|
||||||
|
| `_prautoblogger_idea_hash` | v0.18.0 — stable hash of the idea (title|topic); with `_prautoblogger_run_id` forms the post-creation idempotency key |
|
||||||
|
| `_prautoblogger_og_image_id` | Attachment ID of the composed 1200×630 OG variant (v0.17.0; the rebuilt SEO stage emits `og:image` from this — key name frozen) |
|
||||||
|
| `_prautoblogger_square_image_id` | Attachment ID of the composed 1080×1080 square variant (v0.17.0; stored now, no consumer yet) |
|
||||||
|
|
||||||
|
Composed-variant **attachment** meta (v0.17.0): every pipeline attachment gets
|
||||||
|
`_prautoblogger_image_role` (`featured`/`og`/`square`); OG/square variants also
|
||||||
|
get `_prautoblogger_base_attachment_id` pointing at the featured attachment
|
||||||
|
they derive from. All keys keep the `_prautoblogger_` prefix, so the uninstall
|
||||||
|
prefix-sweep purges them.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -396,7 +708,51 @@ Stored on every PRAutoBlogger-generated post:
|
|||||||
| Reddit RSS | Primary Reddit data source — Atom feeds for subreddit hot posts | None (unauthenticated) | No known rate limit; reliable from datacenter IPs | `providers/class-reddit-json-client.php` |
|
| Reddit RSS | Primary Reddit data source — Atom feeds for subreddit hot posts | None (unauthenticated) | No known rate limit; reliable from datacenter IPs | `providers/class-reddit-json-client.php` |
|
||||||
| Reddit .json | Fallback for posts + only source for comments | None (unauthenticated) | ~10 req/min (datacenter IPs often blocked) | `providers/class-reddit-json-client.php` |
|
| Reddit .json | Fallback for posts + only source for comments | None (unauthenticated) | ~10 req/min (datacenter IPs often blocked) | `providers/class-reddit-json-client.php` |
|
||||||
| Google Analytics 4 | Post performance metrics | OAuth2 service account | Standard GA4 limits | `core/class-ga4-client.php`, `core/class-metrics-collector.php` |
|
| Google Analytics 4 | Post performance metrics | OAuth2 service account | Standard GA4 limits | `core/class-ga4-client.php`, `core/class-metrics-collector.php` |
|
||||||
| Cloudflare Workers AI | Image generation (FLUX.1 schnell / dev) for article hero, thumbnail, and IG placements | API token (encrypted in wp_options) + account ID | Workers AI per-account quotas | `providers/class-cloudflare-image-provider.php`, `providers/class-cloudflare-image-pricing.php`, `providers/class-cloudflare-image-validator.php` |
|
| Runware (FLUX.1 via runware.ai) | **Default** image backend (v0.9.0+): schnell ~$0.0006/image, dev ~$0.02/image. True parallel generation via curl_multi. **v0.15.0: live model catalog sync** from `/v1/models` endpoint (task-based API); normalized to Image_Model_Registry shape; cached with 24h TTL; on-demand refresh via AJAX. | API key (encrypted in wp_options) | Account-level quotas | `providers/class-runware-image-provider.php`, `providers/class-runware-image-pricing.php`, `providers/class-runware-image-support.php`, `providers/class-runware-image-batch.php`, `providers/class-runware-model-catalog.php` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Opik LLM Observability (Optional)
|
||||||
|
|
||||||
|
**Status:** Wave 1 (v0.12.0) — tracing skeleton. Wave 2 (future) — eval harness with frozen-dataset regression testing.
|
||||||
|
|
||||||
|
Opik is a cloud-hosted LLM observability platform (Comet) providing:
|
||||||
|
- **Per-call tracing:** Every OpenRouter call generates a span with model, tokens, latency metadata.
|
||||||
|
- **Trace grouping:** One trace per article generation bundles all spans (draft, QA, image prompt).
|
||||||
|
- **Prompt versioning:** Foundation for Wave 2 — compare new prompt revisions against a frozen reference dataset using LLM-as-judge scoring on rubric axes (factual accuracy, style match, hallucination risk).
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
- **Feature flag:** `prautoblogger_opik_enabled` (WP option, default off). When false, zero network traffic to Opik.
|
||||||
|
- **Credentials:** API key and workspace from PHP constants `PRAUTOBLOGGER_OPIK_API_KEY`, `PRAUTOBLOGGER_OPIK_WORKSPACE` (defined in wp-config.php). Never stored in DB.
|
||||||
|
- **Span lifecycle:**
|
||||||
|
1. `Opik_Trace_Context::init_trace()` generates UUID at article-worker start.
|
||||||
|
2. Each LLM call wraps with `start_span()` → LLM request → `end_span()` with tokens + response.
|
||||||
|
3. `finalize_trace()` + `Opik_Span_Queue::enqueue()` accumulates trace + spans for async dispatch.
|
||||||
|
4. `prautoblogger_opik_dispatch` cron action batches spans/traces (max 100 per POST) to `/v1/private/spans/batch`, `/v1/private/traces/batch`.
|
||||||
|
- **Retry logic:** Exponential backoff (2s, 4s, 8s) on 5xx errors; permanent abort on 4xx; dropped after 3 attempts per item.
|
||||||
|
- **Queue:** WP options-backed; max 1000 items; 12-hour TTL; persists across page reloads for truly async dispatch.
|
||||||
|
|
||||||
|
**Call sites instrumented (Wave 1):**
|
||||||
|
- Draft generation (single-pass or multi-step outline/draft/polish).
|
||||||
|
- Editorial review (chief editor LLM call).
|
||||||
|
- Image prompt generation (deferred; file at 314 LOC, over limit).
|
||||||
|
|
||||||
|
**Cost & Performance:**
|
||||||
|
- Opik free tier: 25k spans/month. Estimated current usage ~4.2k/month (20 posts/day × 7 calls × 30 days). Headroom to 5× growth.
|
||||||
|
- Impact on article generation: async dispatch, zero blocking latency overhead.
|
||||||
|
- Wave 2 (eval harness): adds LLM-as-judge calls (~$14/month at Haiku pricing, assuming 2 eval runs/week).
|
||||||
|
|
||||||
|
**Code structure:**
|
||||||
|
```
|
||||||
|
includes/services/opik/
|
||||||
|
class-opik-client.php — REST client (auth, retry, batch POSTs)
|
||||||
|
class-opik-trace-context.php — per-request trace state singleton
|
||||||
|
class-opik-span-queue.php — WP options-backed async queue
|
||||||
|
class-opik-dispatcher.php — cron handler, batch dispatch
|
||||||
|
|
||||||
|
includes/admin/
|
||||||
|
class-opik-settings.php — admin UI (toggle, project name, status)
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -439,23 +795,283 @@ The posts widget uses WordPress-bundled React (`wp.element`) with raw `createEle
|
|||||||
The widget fetches posts asynchronously from a dedicated REST endpoint rather than rendering server-side. This keeps initial page load fast and allows the endpoint to be cached independently (5-minute Cache-Control). The permission callback uses a filter (`prautoblogger_rest_posts_public`) so sites can restrict access if needed.
|
The widget fetches posts asynchronously from a dedicated REST endpoint rather than rendering server-side. This keeps initial page load fast and allows the endpoint to be cached independently (5-minute Cache-Control). The permission callback uses a filter (`prautoblogger_rest_posts_public`) so sites can restrict access if needed.
|
||||||
|
|
||||||
### #13: Run-ID based log linking
|
### #13: Run-ID based log linking
|
||||||
Each pipeline execution generates a UUID (`run_id`) that tags every `prab_generation_log` entry. When a post is published, `link_generation_logs()` uses `UPDATE WHERE run_id = X` to associate entries with the `post_id`. This is more reliable than the previous timestamp-window approach, especially in batch runs where multiple posts are generated in quick succession.
|
Each pipeline execution generates a UUID (`run_id`) that tags every `prab_generation_log` entry. When a post is published, `link_generation_logs()` uses `UPDATE WHERE run_id = X` to associate entries with the `post_id`. This is more reliable than the previous timestamp-window approach, especially in batch runs where multiple posts are generated in quick succession. **v0.8.1+**: `run_id` is also persisted on each post as `_prautoblogger_run_id` post_meta, giving the orphan-research reaper a direct path from run to siblings without walking the gen_log table (see #19).
|
||||||
|
|
||||||
|
### #19: Daily orphan-research-row reaper (v0.8.1)
|
||||||
|
When a pipeline dies between post creation and the final `Post_Assembler::amortize_research_costs()` call (Hostinger exec kill, OOM, LiteSpeed cut, fatal), the shared `llm_research` cost row stays in `prab_generation_log` with `post_id = NULL` and sibling articles understate their true cost in the breakdown popover. A new daily WP-Cron event `prautoblogger_reap_orphan_research_rows` (hooked to `PRAutoBlogger_Research_Reaper::on_cron`, 03:15 server time — 15 min after the primary generation cron) scans for orphan rows older than a 1-hour grace window, looks up sibling posts by `_prautoblogger_run_id` post_meta (fallback: distinct `post_id`s in gen_log for the same run_id), and delegates to the existing idempotent `amortize_research_costs()` on match. Orphans that still have no siblings after 7 days are deleted outright as sunk cost. A one-time activator migration (`prautoblogger_migrated_run_id_meta_v081`) back-populates `_prautoblogger_run_id` on existing posts from gen_log so the meta-primary path works on upgrade. Manual ops trigger: `wp prautoblogger reap-research`.
|
||||||
|
|
||||||
### #14: Reddit RSS replaces PullPush.io (and earlier Reddit OAuth)
|
### #14: Reddit RSS replaces PullPush.io (and earlier Reddit OAuth)
|
||||||
Reddit rejected our OAuth API application (April 2026). We initially switched to PullPush.io, but its index was frequently stale or unavailable. Reddit's RSS/Atom feeds (`/r/{sub}/hot.rss`) proved the most reliable option — they work from datacenter IPs where .json gets 403, require no auth, and have no apparent rate limit. The .json endpoints are kept as a fallback for posts and as the sole source for comment data. Each collected item's metadata includes a `data_source` field (`reddit_rss` or `reddit_json`) for auditability.
|
Reddit rejected our OAuth API application (April 2026). We initially switched to PullPush.io, but its index was frequently stale or unavailable. Reddit's RSS/Atom feeds (`/r/{sub}/hot.rss`) proved the most reliable option — they work from datacenter IPs where .json gets 403, require no auth, and have no apparent rate limit. The .json endpoints are kept as a fallback for posts and as the sole source for comment data. Each collected item's metadata includes a `data_source` field (`reddit_rss` or `reddit_json`) for auditability.
|
||||||
|
|
||||||
### #18: OpenRouter model registry — daily refresh, WP option + transient cache, zero-coupling
|
### #18: OpenRouter model registry — daily refresh, WP option + transient cache, zero-coupling
|
||||||
|
|
||||||
The admin model picker (v1) needs to list OpenRouter models with pricing and capabilities. We fetch `https://openrouter.ai/api/v1/models` (free, unauthenticated) once daily and store the normalized payload in a WP option fronted by a 24h transient. On stale-and-fetch-fails, we serve last-good + surface a warning. The registry class (`class-openrouter-model-registry.php`) takes all config via constructor (option name, transient name, endpoint URL) — no PRAUTOBLOGGER_* constants inside the class body. Phase 2 lifts it into a shared Composer package with zero internal edits. Phase 3 adds a parallel `Cloudflare_WorkersAI_Model_Registry` behind the same interface. Capability vocabulary: `text→text`, `text+image→text`, `text+audio→text`, `text→image`, `text→audio`, `text→video`, `text→embedding`. Cost: $0/month.
|
The admin model picker (v1) needs to list OpenRouter models with pricing and capabilities. We fetch `https://openrouter.ai/api/v1/models` (free, unauthenticated) once daily and store the normalized payload in a WP option fronted by a 24h transient. On stale-and-fetch-fails, we serve last-good + surface a warning. The registry class (`class-open-router-model-registry.php`) takes all config via constructor (option name, transient name, endpoint URL) — no PRAUTOBLOGGER_* constants inside the class body. Phase 2 lifts it into a shared Composer package with zero internal edits. Phase 3 adds a parallel `Cloudflare_WorkersAI_Model_Registry` behind the same interface. Capability vocabulary: `text→text`, `text+image→text`, `text+audio→text`, `text→image`, `text→audio`, `text→video`, `text→embedding`. Cost: $0/month.
|
||||||
|
|
||||||
|
### #20: Editorial illustration prompt + style-template setting (v0.16.0, May 2026)
|
||||||
|
|
||||||
|
Commit 1 of the in-plugin editorial image pipeline (brief `docs/proposals/2026-05-29-image-pipeline-in-plugin-brief.md`). The image style pivots from a single-panel newspaper comic to a **text-free editorial scientific illustration**. Two changes ship here; the deterministic PHP composer is a later commit.
|
||||||
|
|
||||||
|
1. **Prompt structure.** `Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT` now asks the rewriter LLM for a concise 1-2 sentence topic/mechanism summary (one concrete centered focal subject, no text/people-as-gag/logos) as the SCENE, keeping the short CAPTION line for the unchanged HTML-caption-below-image path. The scene/caption parsing contract (`Image_Scene_Parser`) is unchanged.
|
||||||
|
2. **Template substitution.** The old `trim($scene . ' ' . $style_suffix)` concat is replaced by `PRAutoBlogger_Image_Template_Filler::fill($scene)` in all three entry points (`build_article_prompt`, `build_source_prompt`, `build_fallback_prompt`). The filler resolves the active template (admin override → default), strips control chars and clamps the summary length (brief A5), then substitutes the single `{{ topic_summary }}` token. Degradation (brief A5/A6): if the template lacks exactly one token it appends the summary and logs a warning; if the summary is empty it emits the style-only prompt — a blank or token-only prompt is never sent to the provider.
|
||||||
|
3. **Setting.** New textarea `prautoblogger_image_style_template` (default `PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE`) replaces the Style Suffix field. On save it is sanitised with `sanitize_textarea_field` and validated to contain exactly one token (`Image_Template_Filler::sanitize_for_save`). The old `prautoblogger_image_style_suffix` is mirrored to `..._deprecated` for one cycle and not deleted (one-time migration `prautoblogger_migrated_style_template_v0160`).
|
||||||
|
|
||||||
|
Provider/model are unchanged — only the prompt text changes, so production keeps emitting via Runware FLUX.1 schnell but now produces text-free editorial base images.
|
||||||
|
|
||||||
|
### #21: Deterministic PHP image composer with capability ladder (v0.17.0, Jun 2026)
|
||||||
|
|
||||||
|
Commit 2 (and final scope) of the in-plugin editorial image pipeline (#20). Small diffusion
|
||||||
|
models cannot render clean text (2026-04-21 A/B finding), so base images are text-free —
|
||||||
|
but the one image that travels *without* its HTML caption is the social/OG share image.
|
||||||
|
A **deterministic local composer** (`PRAutoBlogger_Image_Composer` behind
|
||||||
|
`PRAutoBlogger_Image_Composer_Interface`) now runs between provider bytes and sideload:
|
||||||
|
|
||||||
|
1. **Variants.** Image A → corner-marked featured (1200×632) + branded OG (1200×630, teal
|
||||||
|
band, brand mark, baked caption in Poppins SemiBold) + square card (1080×1080: full base
|
||||||
|
downscaled into a 1080×569 top slice — no upscaling — cream caption panel in Poppins
|
||||||
|
Bold, teal footer with the horizontal reverse lockup). Image B → corner mark only. The
|
||||||
|
variant set is config-driven (`prautoblogger_image_compose_variants`, default `og,square`;
|
||||||
|
square has no consumer yet — CEO wants it rendered + stored now). Variants are
|
||||||
|
meta-linked via `_prautoblogger_image_role`, `_prautoblogger_base_attachment_id`,
|
||||||
|
`_prautoblogger_og_image_id`, `_prautoblogger_square_image_id`. The og key is **frozen**:
|
||||||
|
the rebuilt SEO stage (pipeline-redesign §4.8) emits `og:image` from it.
|
||||||
|
2. **Capability ladder, never fatal.** Imagick is probed once (PNG format + TTF annotate
|
||||||
|
smoke test) and cached in `prautoblogger_image_compose_capability`, fingerprinted by
|
||||||
|
PHP version + imagick/gd presence so host flips (hPanel/cagefs — it has happened)
|
||||||
|
auto-invalidate the cache. Rungs: imagick → `wp_get_image_editor()` resize-only
|
||||||
|
(unbranded, square skipped because it would upscale) → pass-through. Every render is
|
||||||
|
try/caught; degradation logs one WARNING per run; the publish always proceeds with the
|
||||||
|
editable HTML caption intact. Test/ops override: `prautoblogger_image_compose_capability`
|
||||||
|
filter.
|
||||||
|
3. **Determinism.** Bundled OFL fonts (no system-font queries), vendored brand PNGs (no
|
||||||
|
runtime SVG delegate variance), `stripImage()` + PNG date/time chunks excluded + fixed
|
||||||
|
compression → byte-stable output **per environment** (not across ImageMagick versions).
|
||||||
|
Composition is local CPU, $0, logged as a duration-only stage on the `image_composer`
|
||||||
|
channel — no cost-tracker rows.
|
||||||
|
4. **Layout tuning.** Geometry/opacity are constants in
|
||||||
|
`PRAutoBlogger_Image_Composer_Layout::defaults()` behind the
|
||||||
|
`prautoblogger_image_compose_layout` filter — deliberately not settings; promote only if
|
||||||
|
the CEO asks to tune them. The caption is hard-clamped (word-boundary wrap + ellipsis,
|
||||||
|
multibyte-safe) even though the rewriter LLM is already constrained to <15 words.
|
||||||
|
|
||||||
|
### #17: Runware as default image backend (v0.9.0, Apr 2026)
|
||||||
|
|
||||||
|
After a comic-style A/B round in Apr 2026, FLUX.1 schnell via Runware was chosen as the default image backend. Cost is ~$0.0006/image vs ~$0.039/image for Gemini 2.5 Flash Image via OpenRouter (≈65× cheaper), and the looser schnell fidelity reads as editorial-cartoon style — a feature, not a bug, for our single-panel comic aesthetic. FLUX.1 dev (~$0.02/image, 28 steps) remains available as an opt-in for higher-fidelity runs. The Runware layer mirrors the OpenRouter split (interface + provider + support + pricing + batch), all under the 300-line cap. The batch class dispatches `imageInference` POSTs via `curl_multi_init/exec/select` — wall-clock time for the A/B pair drops to ≈ the slowest single image. A v0.9.0 one-time migration (`prautoblogger_migrated_default_image_v090`) flips sites still on the legacy default; explicit user selections are preserved. Cloudflare Workers AI was removed as an image provider in v0.10.0 (see ADR #16 note below).
|
||||||
|
|
||||||
|
### #16: Image generation via Cloudflare Workers AI (FLUX.1) — SUPERSEDED v0.10.0
|
||||||
|
Kept here for history. Until v0.8.2 the default image backend was FLUX.1 schnell on Cloudflare Workers AI, routed via the Cloudflare AI Gateway for caching + unified telemetry. In v0.9.0 (2026-04-21) Runware FLUX.1 schnell became the default (65× cheaper, see ADR #17). In v0.10.0 (2026-04-21) the Cloudflare Workers AI provider was removed entirely — classes, settings, tests, and registry entries are gone; any CF user is migrated to `runware:100@1` on upgrade via `PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run()`. The Cloudflare AI Gateway in front of OpenRouter (ADR #15) is unaffected — it is a distinct integration.
|
||||||
|
|
||||||
### #16: Image generation via Cloudflare Workers AI (FLUX.1), direct (not via AI Gateway)
|
|
||||||
The image-generation layer (started 2026-04-15, tracked in `convo/prautoblogger/threads/2026-04-image-pipeline/`) uses FLUX.1 [schnell] on Cloudflare Workers AI as its default model, chosen for its ~$0.0011/MP cost and 2–3 sec latency. FLUX.1 [dev] is a ~4× cost upgrade exposed as a dropdown for specific posts that warrant it. Calls go directly to `https://api.cloudflare.com/client/v4/accounts/{id}/ai/run/@cf/black-forest-labs/...`, *not* through the Cloudflare AI Gateway that we use for OpenRouter — the gateway route to Workers AI currently 403s (pre-existing open issue). The provider lives behind `PRAutoBlogger_Image_Provider_Interface`, so the decision is trivially reversible: switching to a different image API (DALL-E, Replicate, a future AI Gateway route) is a new class implementation plus a one-line swap in the pipeline wiring. Trade-off: we run two different Cloudflare integration paths (gateway for OpenRouter LLMs, direct for Workers AI images) until the gateway route stabilizes — minor cognitive overhead, zero functional downside.
|
|
||||||
|
|
||||||
### #15: Optional Cloudflare AI Gateway in front of OpenRouter
|
### #15: Optional Cloudflare AI Gateway in front of OpenRouter
|
||||||
We already use Cloudflare for DNS/CDN on peptiderepo.com, so layering AI Gateway in front of OpenRouter is zero marginal infrastructure. It gives us response caching (meaningful for repeated classification/scoring calls), a unified cost/latency dashboard, rate limiting, and provider fallback — all of which we would otherwise have to build ourselves to satisfy the CTO cost-tracking rules. Kept as an opt-in URL setting (`prautoblogger_ai_gateway_base_url`) so the plugin still works unchanged out of the box and can be bypassed instantly if the gateway misbehaves. The gateway is a transparent OpenRouter-compatible proxy; no new provider class is needed, and the response parsing path (`usage`, `choices[0].message.content`) is unchanged.
|
We already use Cloudflare for DNS/CDN on peptiderepo.com, so layering AI Gateway in front of OpenRouter is zero marginal infrastructure. It gives us response caching (meaningful for repeated classification/scoring calls), a unified cost/latency dashboard, rate limiting, and provider fallback — all of which we would otherwise have to build ourselves to satisfy the CTO cost-tracking rules. Kept as an opt-in URL setting (`prautoblogger_ai_gateway_base_url`) so the plugin still works unchanged out of the box and can be bypassed instantly if the gateway misbehaves. The gateway is a transparent OpenRouter-compatible proxy; no new provider class is needed, and the response parsing path (`usage`, `choices[0].message.content`) is unchanged.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### #22: Pipeline v2 Phase 1 substrate (v0.18.0, db 1.2.0, June 2026)
|
||||||
|
|
||||||
|
Phase 1 of the CEO-approved pipeline rebuild (plan of record:
|
||||||
|
`convo/cpo/threads/2026-06-prautoblogger-pipeline-redesign/03-cto-revised-brief.md`), built
|
||||||
|
**on the current pipeline with no behavior change** to the Economy (single-pass) and
|
||||||
|
multi-step publish paths. Four subsystems:
|
||||||
|
|
||||||
|
1. **Versioned prompt registry** (`prautoblogger_prompts`). All pipeline prompt copy
|
||||||
|
(content/analysis/editor/research + the illustration rewriter prompt and the image Style
|
||||||
|
Template) lives as immutable versioned rows; the hardcoded v0.16.0 texts are seeded as v1
|
||||||
|
and remain the in-code fallback, so a missing/empty table can never fatal — call sites
|
||||||
|
render through `PRAutoBlogger_Prompt_Registry` and fall back to the same constants the
|
||||||
|
seed used (byte-identical output). A run pins the active version of every key at start
|
||||||
|
(`runs.pinned_prompts_json`) and every generation_log row is stamped with the pinned
|
||||||
|
`prompt_version`. The image-composer PR **consumes** the `image.*` keys — it must not
|
||||||
|
grow its own prompt storage. No admin UI in Phase 1; the API supports list/activate/
|
||||||
|
create-version (never mutate) for the Phase-2 Prompts screen.
|
||||||
|
2. **Additive audit schema.** `agent_role` + `prompt_version` columns on generation_log;
|
||||||
|
`run_sources` + `run_decisions` child tables; `PRAutoBlogger_Stage_Display_Map` renders
|
||||||
|
historical + image + v2 stage values. `request_json` (and stage output payloads in
|
||||||
|
`run_stages.meta_json`) are nulled after R days — R from the
|
||||||
|
`prautoblogger_request_json_retention_days` setting (default 14), pruned on the existing
|
||||||
|
daily reaper cron.
|
||||||
|
3. **Per-run cost governor** (`PRAutoBlogger_Cost_Governor`). Reserve-before-call against the
|
||||||
|
per-run ledger row using a single conditional UPDATE (the #10 atomic-write discipline);
|
||||||
|
estimates come from the #18 pricing chain; `curl_multi` image batches reserve their summed
|
||||||
|
estimate before dispatch; reservations settle to actuals after each response. Breach →
|
||||||
|
run `halted`, overage recorded, un-dispatched work aborted, surfaced on the Review Queue.
|
||||||
|
Ceiling = `prautoblogger_per_run_cost_ceiling_usd` setting (default $0.50, 0 = disabled).
|
||||||
|
The monthly `Cost_Tracker` cap and the Cloudflare AI Gateway (#15) path are unchanged.
|
||||||
|
4. **Run state machine + idempotency** (`PRAutoBlogger_Run_State`). Per-run per-stage state
|
||||||
|
(pending/running/done/failed/halted) keyed `run_id + stage + agent_role + item_key`
|
||||||
|
(item_key scopes article-level stages because one run_id spans all N articles of a batch);
|
||||||
|
done stages persist output and are reused on re-entry, never re-charged; post creation is
|
||||||
|
keyed by `_prautoblogger_run_id` + `_prautoblogger_idea_hash` (check-before-insert) so a
|
||||||
|
retried run cannot duplicate a post. `PRAutoBlogger_Run_Reaper` (same daily cron as #19)
|
||||||
|
marks runs stuck `running` > 2× expected stage wall-clock as `failed`; such runs render
|
||||||
|
as **"incomplete"** in audit queries. Quorum logic (multiple roles per stage) itself is Phase 2. In Phase 1,
|
||||||
|
`agent_role` is populated on every row from `Stage_Display_Map::default_agent_role()`
|
||||||
|
so the run timeline is self-describing and the idempotency key is fully populated.
|
||||||
|
|
||||||
|
### #20: PHPUnit test infrastructure + WordPress-Core PHPCS compliance (v0.10.1)
|
||||||
|
|
||||||
|
Tests use Brain\Monkey for WordPress function mocking (no database required). The BaseTestCase singleton handles setup/teardown of Monkey stubs and provides fixture helpers for common data shapes (SourceData, ArticleIdea, GenerationLog, etc.). All WordPress functions used by the plugin are stubbed in BaseTestCase::setUp() so individual test classes only override what they need. In v0.10.1, missing stubs for `post_type_exists()` were added to support PeptideLinker's PR-Core guards. All 1,362 WordPress-Core coding-standards violations (short array syntax, line length, spacing) were auto-fixed via phpcbf and the CI gate was flipped to strict mode — PHPCS failures now block CI. This prevents future regressions on the code style front.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### #22b: Kanban board as primary admin landing screen (v0.19.0 / v0.19.1)
|
||||||
|
|
||||||
|
**Hidden admin page convention (v0.19.3):** See CONVENTIONS.md §Hidden Admin Pages for the full rule. Summary: hidden admin pages (link-accessed only, not visible in nav) MUST use `options.php` as the `add_submenu_page()` parent. NEVER unset/remove registered pages from `$submenu` after registration -- doing so causes `get_admin_page_parent()` to fail at request time, which makes WP recompute the hookname in the `admin_page_*` orphan namespace, find no registered handler, and call `wp_die(403)`. This is the dossier 403 incident (v0.19.2→v0.19.3). Same hookname-mismatch class as the board 404 (v0.19.0→v0.19.1); the only difference is that the board bug was a priority-ordering issue at registration time while the dossier bug was a post-registration $submenu mutation.
|
||||||
|
|
||||||
|
The primary admin screen is now a kanban board (Direction C "Editorial Record") with four
|
||||||
|
columns: Generating | In Review | Published | Failed. A separate `class-board-page.php`
|
||||||
|
registers the `Board` submenu and a `$submenu` reorder makes it the first (primary) entry.
|
||||||
|
`class-board-data-provider.php` orchestrates the four columns and handles WP_Query columns
|
||||||
|
(In Review, Published). Raw `prab_generation_log` queries (Generating, Failed) are in the
|
||||||
|
extracted `class-board-gen-log-query.php` (split for 300-line compliance) -- no new schema
|
||||||
|
required for M1. The data provider uses the `prautoblogger_generation_status` transient
|
||||||
|
(written by the Executor for manual/daily runs) as the primary active-run signal, with a
|
||||||
|
secondary query on recent `prab_generation_log` run_ids that have no linked post as a
|
||||||
|
fallback. M2 will rename Runs & Audit -> Articles and wire board cards to the Article Dossier.
|
||||||
|
Poll interval and published window are settings-backed, localized into board.js, never
|
||||||
|
hardcoded.
|
||||||
|
|
||||||
|
**Menu-ordering constraint (v0.19.1 hotfix):** `PRAutoBlogger_Board_Page::on_register_menu`
|
||||||
|
MUST be hooked at `admin_menu` priority 11 (or higher), AFTER
|
||||||
|
`PRAutoBlogger_Admin_Page::on_register_menu` which runs at priority 10. Reason: WordPress
|
||||||
|
computes the page hookname via `get_plugin_page_hookname()` at `add_submenu_page()` call
|
||||||
|
time. If `add_menu_page()` has not yet run, `$admin_page_hooks['prautoblogger-settings']`
|
||||||
|
is unset and WordPress falls back to the `admin_page_*` hookname. At HTTP request time WP
|
||||||
|
recomputes the hookname as `prautoblogger_page_prautoblogger-board` and finds no registered
|
||||||
|
callback -> `wp_die("Invalid plugin page")` 404. The enqueue-gate in `on_enqueue_assets()`
|
||||||
|
checks `prautoblogger_page_prautoblogger-board` and would similarly never fire with the
|
||||||
|
wrong hookname (board.css/board.js would silently not load). Regression test:
|
||||||
|
`tests/unit/Admin/BoardMenuRegistrationTest.php`.
|
||||||
|
|
||||||
|
|
||||||
|
### #23: Article Dossier page — per-article inspection (v0.19.2)
|
||||||
|
|
||||||
|
The dossier is a read-only per-article admin page that exposes the full substrate view for
|
||||||
|
a single post: generation run, stage I/O, cost receipts, raw-trace toggles, editorial
|
||||||
|
decisions, and image A/B pairs. It is link-accessed only (hidden submenu) — no navigation
|
||||||
|
entry appears in the WP sidebar.
|
||||||
|
|
||||||
|
**Page registration** (`class-dossier-page.php`): uses the canonical hidden-admin-page
|
||||||
|
pattern -- registered under parent **`options.php`** at `admin_menu` priority **12**. The
|
||||||
|
hookname is `admin_page_prautoblogger-dossier` at BOTH registration time and request time,
|
||||||
|
so there is no hookname mismatch and no need to manipulate `$submenu`. The page does not
|
||||||
|
appear in the WP admin sidebar (options.php-parent pages are hidden by construction).
|
||||||
|
URL shape: `admin.php?page=prautoblogger-dossier&post_id=<int>` (unchanged from v0.19.2 --
|
||||||
|
deep-link URLs are parent-agnostic). Secured by `manage_options` capability. Static
|
||||||
|
`url_for_post(int $post_id): string` provides the canonical deep-link.
|
||||||
|
|
||||||
|
v0.19.2 used `prautoblogger-settings` as parent with post-registration `unset($submenu[...])` to
|
||||||
|
hide the page. This caused `get_admin_page_parent()` to fail at request time -> orphan
|
||||||
|
`admin_page_*` hookname -> no handler registered -> `wp_die(403)`. Fixed in v0.19.3 by switching
|
||||||
|
to `options.php` parent and removing all `$submenu` mutation. See CONVENTIONS.md §Hidden Admin
|
||||||
|
Pages for the full rule and ARCHITECTURE.md §22b for both incident records.
|
||||||
|
|
||||||
|
Regression test: `tests/unit/Admin/DossierMenuRegistrationTest.php` -- the
|
||||||
|
`test_request_time_hookname_matches_registration_hookname` test replicates the request-time
|
||||||
|
resolution algorithm (simulate `get_admin_page_parent()`) so it FAILS on hide-by-unset and
|
||||||
|
PASSES on options.php-parent.
|
||||||
|
|
||||||
|
**5-query data assembly** (`class-dossier-data-assembler.php`):
|
||||||
|
|
||||||
|
```
|
||||||
|
1. wp_prautoblogger_runs WHERE run_id = %s -- 1 row (run metadata)
|
||||||
|
2. wp_prautoblogger_run_stages WHERE run_id = %s -- N rows (per-stage status/cost)
|
||||||
|
3. wp_prautoblogger_generation_log WHERE run_id = %s -- N rows (model/pv/tokens/request)
|
||||||
|
4. wp_prautoblogger_run_decisions WHERE run_id = %s -- N rows (editorial rationale)
|
||||||
|
5. get_post_meta($post_id) -- WP object cache hit
|
||||||
|
```
|
||||||
|
|
||||||
|
All queries are keyed on `run_id` (indexed). No N+1. Stage output is reconstructed from
|
||||||
|
`run_stages.meta_json → json_decode → meta['output']` (same path as `Run_Stage_State::
|
||||||
|
get_output()`). The dossier assembler does NOT call `Run_Stage_State` directly; it performs
|
||||||
|
its own raw queries so the view is not coupled to the cache layer.
|
||||||
|
|
||||||
|
**Legacy graceful state (binding 5):** Posts published before v0.18.0 (no runs/run_stages
|
||||||
|
rows) and posts where `_prautoblogger_run_id` is absent or blank return `['has_run' => false]`.
|
||||||
|
The template renders a clean "No generation record for this article" notice. No PHP notices
|
||||||
|
or fatals.
|
||||||
|
|
||||||
|
**Amortized research rows:** `generation_log` rows with `prompt_version = NULL` and
|
||||||
|
`agent_role = ''` (from the `llm_research` stage orphan-reaper path) are rendered with
|
||||||
|
model shown as "—" and no raw-trace toggle (request_json may be NULL). No fatal/notice.
|
||||||
|
|
||||||
|
**Raw-trace security (binding 4):** `request_json` stores the OpenRouter request body —
|
||||||
|
model identifier, messages array, temperature. Authorization headers are never logged (the
|
||||||
|
outbound HTTP client strips them before logging, per the original architecture decision on
|
||||||
|
`wp_prautoblogger_generation_log`). The dossier template renders `request_json` via
|
||||||
|
`esc_html()` inside a `<pre>` block. All model-generated text rendered via `wp_kses_post()`
|
||||||
|
(rendered view) or `esc_html()` (raw trace). Both paths treat content as untrusted HTML.
|
||||||
|
|
||||||
|
**File naming note:** `PRAutoBlogger_DB_Migrations` is loaded via explicit `require_once`
|
||||||
|
in `prautoblogger.php` (not the autoloader). The autoloader converts `DB_Migrations` to the
|
||||||
|
kebab segment `d-b-migrations`, producing `class-d-b-migrations.php` — which does not match
|
||||||
|
the file `class-db-migrations.php`. Explicit loading is the canonical pattern for classes
|
||||||
|
whose names do not round-trip cleanly through the kebab converter. `class-prautoblogger.php`
|
||||||
|
uses the same pattern (also explicitly required in `prautoblogger.php`).
|
||||||
|
|
||||||
|
### #24: Edit + single-step re-run under CPO guardrails (v0.20.0, db 1.3.0, June 2026)
|
||||||
|
|
||||||
|
Phase 2 admin M3 (design contract: cpo seq 8 Proposal C + the five guardrails of the
|
||||||
|
edit+rerun delta ruling; decision `2026-06-12-admin-redesign-direction-c.md`).
|
||||||
|
|
||||||
|
**B1 — request_json persistence.** The OpenRouter provider stashes every outgoing chat
|
||||||
|
request BODY in the process-scoped `PRAutoBlogger_Request_Recorder` (the Run_Context
|
||||||
|
pattern) right after `build_body()`; `Cost_Tracker::log_api_call()` consumes it into
|
||||||
|
`generation_log.request_json`. Consume-once + overwrite-on-record means a stale body can
|
||||||
|
never attach to an unrelated row; recording happens pre-dispatch so error rows carry the
|
||||||
|
request too. Authorization can never be recorded: headers are built separately
|
||||||
|
(`build_headers()`) and `build_body()` copies only whitelisted option keys. Retention
|
||||||
|
rides the existing `prautoblogger_request_json_retention_days` prune unchanged.
|
||||||
|
|
||||||
|
**Two re-run primitives, both chained-cron (never synchronous):** the dossier AJAX layer
|
||||||
|
(`Dossier_Actions`) only validates + queues; the cron handlers (`Rerun_Executor`)
|
||||||
|
re-validate eligibility UNDER the generation lock before mutating anything.
|
||||||
|
|
||||||
|
1. **Replay** — one governed chat call from the latest edited input fork
|
||||||
|
(`stage_inputs`, INSERT-only versions). `Stage_Replay::options_from_body()` is the
|
||||||
|
exact inverse of `build_body()`+`apply_reasoning_budget()` (reasoning headroom
|
||||||
|
subtracted back out; reasoning pinned explicitly so global-setting drift cannot
|
||||||
|
reshape a replay). The call goes through `send_chat_completion`, so the cost
|
||||||
|
governor reserves against the SAME run ledger row (guardrail 4), with the normal
|
||||||
|
retry/empty-completion machinery. Output lands in the stage's run_stages snapshot —
|
||||||
|
the same place downstream rebuilds read from.
|
||||||
|
2. **Rebuild ("re-run from here")** — demotes the target + downstream chain stages to
|
||||||
|
`pending` and re-enters `Article_Worker` with the idea reconstructed from the stored
|
||||||
|
seed (`stage_inputs`, source='seed', persisted at worker start because re-deriving
|
||||||
|
the idea from post fields could break the item_key hash and duplicate a post).
|
||||||
|
Upstream done stages are reused (never re-charged); demoted stages rebuild their
|
||||||
|
prompts from CURRENT upstream snapshots — this is how an edit propagates downstream.
|
||||||
|
|
||||||
|
**Stale is a column, not a status.** A stale stage stays `done`, so
|
||||||
|
resume-without-recharge logic can never silently re-run it — guardrail 3 (no silent
|
||||||
|
auto-rerun; each downstream re-run is a deliberate operator action) holds by
|
||||||
|
construction. Only `Run_Stage_Writes::done()` clears `stale`; only
|
||||||
|
`Run_Stage_Rerun_State` (operator-action paths) may demote a done row. `human_modified`
|
||||||
|
is sticky for the life of the run (set when a fork enters execution, surfaced as header/
|
||||||
|
stage chips in the dossier, a board-card chip, and a Review Queue link-chip — the
|
||||||
|
run-list-level visibility the product AC requires).
|
||||||
|
|
||||||
|
**Run reopening.** `Run_State::reopen()` is the atomic terminal→running gate for re-run
|
||||||
|
spend; it RE-SNAPSHOTS `ceiling_usd` from the current setting (a deliberate re-run
|
||||||
|
adopts the operator's current per-run policy, exactly like a new run) and never resets
|
||||||
|
reserved/settled — re-runs are new spend on the same run. Handlers always restore a
|
||||||
|
terminal status (or record failed/halted) so the run-reaper's stuck-running sweep stays
|
||||||
|
meaningful; jobs hold the global generation lock and ride the M1 status transient, so
|
||||||
|
board/dossier polling shows queued → pickup → result and the existing R2/R3 orphan
|
||||||
|
recovery covers dead re-run processes unchanged.
|
||||||
|
|
||||||
|
**Eligibility (guardrail 5).** Posts with status publish/future/private freeze their
|
||||||
|
run server-side (and `Publisher` refuses to touch them: re-run output refreshes only
|
||||||
|
UNPUBLISHED posts, preserving their status — re-runs never publish). Editable stages
|
||||||
|
are the item-scoped writer chat stages (outline/draft/polish); review/publish re-run
|
||||||
|
via rebuild (their inputs are derived), run-level and image stages are excluded with
|
||||||
|
visible in-UI reasons. Phase-2b stages inherit the mechanism when they exist as
|
||||||
|
item-scoped chat calls.
|
||||||
|
|
||||||
|
**F2/F3 (QA M2):** the sidebar consolidates per-stage model/pv/role; image (and other
|
||||||
|
log-only) stages render from generation_log + the `_prautoblogger_image_role`
|
||||||
|
attachment set — wiring the dossier to real data was deliberately chosen over teaching
|
||||||
|
the live image pipeline to write run_stages (that belongs to the Phase-2b restructure).
|
||||||
|
|
||||||
## Cross-System LLM Budget Coordination
|
## Cross-System LLM Budget Coordination
|
||||||
|
|
||||||
PRAutoBlogger and Peptide News both call OpenRouter and may share a single API key / billing account. Their combined spend should be considered when setting per-plugin budgets.
|
PRAutoBlogger and Peptide News both call OpenRouter and may share a single API key / billing account. Their combined spend should be considered when setting per-plugin budgets.
|
||||||
@@ -468,3 +1084,65 @@ PRAutoBlogger and Peptide News both call OpenRouter and may share a single API k
|
|||||||
**Important:** If your OpenRouter account has a global spending limit, set each plugin's budget to less than half the total. PRAutoBlogger will hard-stop when its budget is exhausted, but Peptide News currently has no budget enforcement — a spike in news fetches could consume shared quota.
|
**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.
|
**Future improvement:** A shared `wp_options` key (e.g., `ecosystem_monthly_llm_budget`) that both plugins read, with each plugin reserving its allocation on startup. This requires coordination at the ecosystem level and is tracked as a medium-term goal.
|
||||||
|
|
||||||
|
## §25 Chained-Cron Checkpoint Generation Path (v0.21.0, M4)
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
|
||||||
|
Hostinger shared hosting kills PHP background processes after ~120 s. The former
|
||||||
|
`on_manual_generation()` path in `Executor` ran the complete orchestrate +
|
||||||
|
generate pipeline in a single PHP process — meaning a host kill mid-run left the
|
||||||
|
generation lock held, the run row stuck in `running`, and partially-generated
|
||||||
|
articles orphaned with no status broadcast.
|
||||||
|
|
||||||
|
### Solution: Two-Tick Checkpoint Machine
|
||||||
|
|
||||||
|
`PRAutoBlogger_Generation_Checkpoint_Runner` (v0.21.0) splits the pipeline into
|
||||||
|
independent cron ticks, each of which completes well within Hostinger's limit:
|
||||||
|
|
||||||
|
| Tick | WP-Cron action | What happens |
|
||||||
|
|------|---------------|-------------|
|
||||||
|
| 1 | `prautoblogger_gen_orchestrate` | Collect sources → analyze → score ideas. Serialize ideas to `prautoblogger_article_queue` option. Schedule Tick 2. |
|
||||||
|
| 2..N | `prautoblogger_gen_tick` | Pop ONE idea from queue. Call `Article_Worker::generate()`. Persist updated queue. Reschedule next Tick, or finalize. |
|
||||||
|
|
||||||
|
If the host kills any tick:
|
||||||
|
- The idea queue is persisted (option survived the crash).
|
||||||
|
- The next CRON run picks up naturally (no orphan).
|
||||||
|
- `Run_Reaper` reclaims truly stuck runs via its daily sweep.
|
||||||
|
|
||||||
|
### Entry Points
|
||||||
|
|
||||||
|
| Caller | Path |
|
||||||
|
|--------|------|
|
||||||
|
| Board "New Article" button | AJAX `prautoblogger_generate_now` → `Generation_Status_Poller::on_ajax_generate_now()` → schedules `prautoblogger_manual_generation` → `Executor::on_manual_generation()` → `kick_off()` |
|
||||||
|
| WP-CLI | `wp prautoblogger generate` → `WP_CLI_Commands::generate_command()` → `kick_off()` |
|
||||||
|
| Old cron hook (retained) | `prautoblogger_manual_generation` → `Executor::on_manual_generation()` → `kick_off()` |
|
||||||
|
|
||||||
|
### SSH Workaround Retirement
|
||||||
|
|
||||||
|
The former `wp eval 'do_action("prautoblogger_manual_generation")'` workaround
|
||||||
|
fired `on_manual_generation()` synchronously in the CLI PHP process, bypassing:
|
||||||
|
- Nonce/capability checks (running as CLI user, not WP user)
|
||||||
|
- Cron-based loopback spawning
|
||||||
|
- Per-process time limits
|
||||||
|
|
||||||
|
This path is retired. `Executor::on_manual_generation()` now delegates immediately
|
||||||
|
to `Generation_Checkpoint_Runner::on_orchestrate_tick()` (the Tick 1 handler),
|
||||||
|
which acquires the lock and runs orchestration within its own process. The
|
||||||
|
`wp prautoblogger generate` WP-CLI command provides the supported CLI path.
|
||||||
|
|
||||||
|
### Key State
|
||||||
|
|
||||||
|
| Store | Key | Contents |
|
||||||
|
|-------|-----|---------|
|
||||||
|
| DB option | `prautoblogger_article_queue` | `{run_id, ideas[], results{}}` — persisted across ticks |
|
||||||
|
| DB option | `prautoblogger_checkpoint_run_id` | Current run UUID (for finalize/cleanup paths) |
|
||||||
|
| Transient | `prautoblogger_generation_status` | Status broadcast (polling by board/dossier JS) |
|
||||||
|
| DB | `prautoblogger_runs` | Per-run ledger with status, cost ceiling |
|
||||||
|
|
||||||
|
### Budget / Halt Contract
|
||||||
|
|
||||||
|
`Cost_Tracker::is_budget_exceeded()` is checked at the START of Tick 1 and each
|
||||||
|
generate tick. A halted run (`Run_State::get_status() = 'halted'`) causes the
|
||||||
|
generate tick to abort without calling `Article_Worker`, clean up the queue, and
|
||||||
|
release the lock — same guarantee as `Pipeline_Runner`'s queued-article path.
|
||||||
|
|||||||
1178
CHANGELOG.md
Executable file → Normal file
1178
CHANGELOG.md
Executable file → Normal file
File diff suppressed because it is too large
Load Diff
305
CONVENTIONS.md
Executable file → Normal file
305
CONVENTIONS.md
Executable file → Normal file
@@ -150,6 +150,38 @@ interface PRAutoBlogger_Image_Provider_Interface {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## How To: Tune the Image Composer (v0.17.0+)
|
||||||
|
|
||||||
|
The deterministic composer (ARCHITECTURE.md #21) renders branded variants between
|
||||||
|
provider bytes and sideload. Three rules when touching it:
|
||||||
|
|
||||||
|
1. **Geometry lives in code, not settings.** All sizes/colors/opacities come from
|
||||||
|
`PRAutoBlogger_Image_Composer_Layout::defaults()` and can be overridden via the
|
||||||
|
`prautoblogger_image_compose_layout` filter:
|
||||||
|
```php
|
||||||
|
add_filter( 'prautoblogger_image_compose_layout', function ( array $layout ): array {
|
||||||
|
$layout['featured']['mark_opacity'] = 0.40;
|
||||||
|
return $layout;
|
||||||
|
} );
|
||||||
|
```
|
||||||
|
Promote a value to a settings field only on explicit CEO request.
|
||||||
|
2. **Keep renderer classes WP-free.** `Image_Composer_Imagick`, `Image_Composer_Canvas`,
|
||||||
|
and `Image_Composer_Layout` must not call WordPress functions — they are exercised by
|
||||||
|
a standalone render harness (and the determinism test) without a WP bootstrap. Only
|
||||||
|
the orchestrator (`Image_Composer`) and the GD rung (`Image_Composer_Editor`) may
|
||||||
|
touch WP APIs.
|
||||||
|
3. **Never fatal a publish.** Any new render path needs a try/catch that degrades to
|
||||||
|
pass-through and logs at most one WARNING per run (`warn_once()`). Capability checks
|
||||||
|
go through the cached probe (`prautoblogger_image_compose_capability` option +
|
||||||
|
same-named override filter) — never re-probe per image.
|
||||||
|
|
||||||
|
Adding a new variant role: extend `Layout::defaults()` + the renderer, whitelist the
|
||||||
|
role in `Image_Composer::configured_variants()` and in
|
||||||
|
`Image_Attacher::ROLE_POST_META` (new `_prautoblogger_{role}_image_id` post meta keeps
|
||||||
|
the uninstall prefix-sweep effective), then cover it in the unit ladder tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## How To: Add a New Admin Setting
|
## How To: Add a New Admin Setting
|
||||||
|
|
||||||
1. Define the option key in the settings array in `admin/class-admin-page.php` → `get_settings_fields()` method
|
1. Define the option key in the settings array in `admin/class-admin-page.php` → `get_settings_fields()` method
|
||||||
@@ -172,13 +204,18 @@ interface PRAutoBlogger_Image_Provider_Interface {
|
|||||||
|
|
||||||
## How To: Add a New Pipeline Stage
|
## How To: Add a New Pipeline Stage
|
||||||
|
|
||||||
The writing pipeline is an ordered array of stages. Each stage is a class method on `Content_Generator`.
|
Writing stages are methods on `Content_Generator` that delegate to its `execute_stage()`
|
||||||
|
helper (Opik span + LLM dispatch + cost log + run-stage state in one place).
|
||||||
|
|
||||||
1. Add the stage method to `class-content-generator.php`: `protected function stage_{name}(PRAutoBlogger_Content_Request $request): string`
|
1. Add the stage method to `class-content-generator.php` and call
|
||||||
2. Register the stage in the `get_pipeline_stages()` method for the appropriate mode (single_pass or multi_step)
|
`$this->execute_stage( $stage, $span_name, $user_prompt, $request, $model, $options )`
|
||||||
3. Each stage receives the accumulated context and returns its output
|
2. Wire it into `generate_multi_step()` (or the relevant mode path)
|
||||||
4. Each stage logs its cost via `Cost_Tracker::log_api_call()`
|
3. Add the stage to `PRAutoBlogger_Stage_Display_Map` (label, default agent role, prompt key)
|
||||||
5. If a stage fails after retries, the entire pipeline fails (no partial publishes)
|
and — if it has registry-managed copy — a prompt key in the defaults classes
|
||||||
|
4. Cost logging and per-run stage state (idempotent resume, output snapshot) come free from
|
||||||
|
`execute_stage()`; the per-run cost governor guards the call inside the provider
|
||||||
|
5. If a stage fails after retries, the entire pipeline fails (no partial publishes); the
|
||||||
|
stage row is failed by the worker's catch-all and the run is swept by Run_Reaper
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -234,6 +271,92 @@ Frontend components use `wp.element` (WordPress-bundled React) — no JSX, no bu
|
|||||||
9. **No `echo` of raw data** — always escape with `esc_html()`, `esc_attr()`, etc.
|
9. **No `echo` of raw data** — always escape with `esc_html()`, `esc_attr()`, etc.
|
||||||
10. **Nonce on every form/AJAX** — `wp_nonce_field()` / `check_ajax_referer()`
|
10. **Nonce on every form/AJAX** — `wp_nonce_field()` / `check_ajax_referer()`
|
||||||
|
|
||||||
|
|
||||||
|
## How To: Add an OpenRouter model picker field (v0.11.0+)
|
||||||
|
|
||||||
|
Use the `model_select` field type to let admins choose OpenRouter models with a searchable dropdown, capability filtering, and estimated cost preview based on usage history.
|
||||||
|
|
||||||
|
### 1. Declare the field in `class-settings-fields.php`
|
||||||
|
|
||||||
|
```php
|
||||||
|
[
|
||||||
|
'id' => 'my_plugin_model',
|
||||||
|
'label' => __( 'My Model Setting', 'my-plugin' ),
|
||||||
|
'type' => 'model_select',
|
||||||
|
'section' => 'my_plugin_settings',
|
||||||
|
'default' => 'anthropic/claude-3.5-haiku',
|
||||||
|
'capability' => 'text→text', // see ARCHITECTURE.md §18 for all capability strings
|
||||||
|
'description' => __( 'Choose a model...', 'my-plugin' ),
|
||||||
|
'badge' => __( 'Quality', 'my-plugin' ),
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Map the setting to pipeline stages (for cost preview)
|
||||||
|
|
||||||
|
If you want cost preview to work, add your setting ID and stage list to the `get_stages_for_setting()` method in `includes/admin/fields/class-open-router-model-field.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
private static function get_stages_for_setting( string $field_id ): array {
|
||||||
|
$map = [
|
||||||
|
'prautoblogger_analysis_model' => [ 'analysis' ],
|
||||||
|
'prautoblogger_writing_model' => [ 'outline', 'draft', 'polish' ],
|
||||||
|
'prautoblogger_editor_model' => [ 'review' ],
|
||||||
|
'my_new_model_setting' => [ 'my_stage' ], // ADD HERE
|
||||||
|
];
|
||||||
|
return $map[ $field_id ] ?? [];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The cost preview queries `get_avg_tokens_for_stages()` on the Cost Tracker to show estimated cost per generation based on the last 30 days of usage.
|
||||||
|
|
||||||
|
### 3. That's it
|
||||||
|
|
||||||
|
The field renderer, AJAX refresh, model picker JS, and capability filtering are already wired. The admin will see a "Refresh Model List" button on the AI Models tab (or whichever tab contains the picker field).
|
||||||
|
|
||||||
|
### Capability strings (locked in v0.11.0)
|
||||||
|
|
||||||
|
- `text→text` — text input, text output (default for analysis/writing/editor)
|
||||||
|
- `text+image→text` — vision models
|
||||||
|
- `text+audio→text` — audio input models
|
||||||
|
- `text→image` — image generation
|
||||||
|
- `text→audio` — voice synthesis
|
||||||
|
- `text→video` — video generation
|
||||||
|
- `text→embedding` — embeddings
|
||||||
|
|
||||||
|
Phase 3 will add provider selection; v0.11.0 is OpenRouter-only.
|
||||||
|
|
||||||
|
## How To: Change a Pipeline Prompt (v0.18.0+)
|
||||||
|
|
||||||
|
Prompt copy is **versioned data**, not code. The bodies live in
|
||||||
|
`wp_prautoblogger_prompts` (one ACTIVE version per key); the hardcoded texts in
|
||||||
|
`class-prompt-defaults.php` / `class-prompt-defaults-editorial.php` are the canonical v1 seed
|
||||||
|
AND the fallback when the table is unavailable — never edit a stored version in place.
|
||||||
|
|
||||||
|
1. **Never `UPDATE` a body.** Create a new version and activate it:
|
||||||
|
```php
|
||||||
|
$v = PRAutoBlogger_Prompt_Registry_Writer::create_version(
|
||||||
|
'content.polish', $new_body, $model, $params, 'your-name', true
|
||||||
|
);
|
||||||
|
```
|
||||||
|
2. **Keys** are dot-namespaced: `content.system`, `content.single_pass`, `content.outline`,
|
||||||
|
`content.draft`, `content.polish`, `analysis.system`, `analysis.user`, `editor.system`,
|
||||||
|
`editor.review`, `research.system`, `image.rewriter_system`, `image.style_template`.
|
||||||
|
The two `image.*` keys are the image-composer seam — coordinate on the convo thread
|
||||||
|
before touching them.
|
||||||
|
3. **Tokens** use the `{{ name }}` syntax (exactly one space each side — same convention as
|
||||||
|
the Style Template's `{{ topic_summary }}`). Token VALUES are computed by code
|
||||||
|
(`Content_Prompts`, `Analysis_Prompts`, `Chief_Editor`); a new prompt version may reorder
|
||||||
|
or drop tokens but must not invent new ones without adding the value at the call site.
|
||||||
|
4. **Pinning.** A run pins the active version of every key at start
|
||||||
|
(`runs.pinned_prompts_json`, written by `Cost_Tracker::set_run_id()`); every
|
||||||
|
generation_log row is stamped with the pinned `prompt_version`. Activating a new version
|
||||||
|
affects the NEXT run, never a run in flight.
|
||||||
|
5. **Call sites** render via `PRAutoBlogger_Prompt_Registry::render( $key, $tokens )` and
|
||||||
|
must keep working when the table is absent (the registry falls back to the defaults
|
||||||
|
automatically — do not add your own fallback).
|
||||||
|
6. If you add a key: define the body in the appropriate defaults class, add it to `defs()`,
|
||||||
|
map its stage in `Stage_Display_Map`, and it will seed on the next migration pass.
|
||||||
|
|
||||||
## Git Workflow — PR-Gated, Soft-Enforced
|
## Git Workflow — PR-Gated, Soft-Enforced
|
||||||
|
|
||||||
This repo is private on GitHub's free plan, which does not support branch protection or rulesets. The review gate is enforced at the agent layer, not server-side. Every agent and human contributor follows these rules; the `.github/workflows/main-push-audit.yml` tripwire opens an audit issue on any direct push to `main` that did not come from a merged PR.
|
This repo is private on GitHub's free plan, which does not support branch protection or rulesets. The review gate is enforced at the agent layer, not server-side. Every agent and human contributor follows these rules; the `.github/workflows/main-push-audit.yml` tripwire opens an audit issue on any direct push to `main` that did not come from a merged PR.
|
||||||
@@ -279,3 +402,173 @@ curl -s -X POST -H "Authorization: token $GH_TOKEN" \
|
|||||||
### When this changes
|
### When this changes
|
||||||
|
|
||||||
If the peptiderepo GitHub account is ever upgraded to Pro (or the repo goes public), replace this soft-enforcement section with a note pointing to the real branch protection rules in repo settings.
|
If the peptiderepo GitHub account is ever upgraded to Pro (or the repo goes public), replace this soft-enforcement section with a note pointing to the real branch protection rules in repo settings.
|
||||||
|
## Instrumenting a New LLM Call Site for Opik (v0.12.0+)
|
||||||
|
|
||||||
|
When you add a new LLM call (via `$llm->send_chat_completion()`), follow this pattern to capture it in Opik:
|
||||||
|
|
||||||
|
### 1. Get the trace context
|
||||||
|
|
||||||
|
At the start of your method, grab the per-request singleton:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$ctx = PRAutoBlogger_Opik_Trace_Context::current();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Start the span
|
||||||
|
|
||||||
|
Before the LLM call, declare a span with metadata:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$span_id = $ctx->start_span(
|
||||||
|
array(
|
||||||
|
'name' => 'my_llm_operation', // kebab-case, descriptive
|
||||||
|
'type' => 'llm',
|
||||||
|
'model' => $model, // e.g. 'gpt-4', from get_option()
|
||||||
|
'provider' => 'openrouter', // or the actual provider name
|
||||||
|
'input' => array( 'key' => 'value' ), // optional metadata (no full prompts!)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Make the LLM call (unchanged)
|
||||||
|
|
||||||
|
```php
|
||||||
|
$response = $this->llm->send_chat_completion(
|
||||||
|
array(
|
||||||
|
array( 'role' => 'system', 'content' => $system_prompt ),
|
||||||
|
array( 'role' => 'user', 'content' => $user_prompt ),
|
||||||
|
),
|
||||||
|
$model,
|
||||||
|
array( 'temperature' => 0.7, 'max_tokens' => 4000 )
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. End the span with output
|
||||||
|
|
||||||
|
After the response, record the outcome:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$ctx->end_span(
|
||||||
|
$span_id,
|
||||||
|
array(
|
||||||
|
'output' => array( 'response_length' => strlen( $response['content'] ?? '' ) ),
|
||||||
|
'usage' => array(
|
||||||
|
'prompt_tokens' => $response['prompt_tokens'],
|
||||||
|
'completion_tokens' => $response['completion_tokens'],
|
||||||
|
'total_tokens' => $response['prompt_tokens'] + $response['completion_tokens'],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key rules
|
||||||
|
|
||||||
|
- **Never log full prompts.** Use prompt hashes or version tags in `input`. Full text blows payload size and leaks content policy.
|
||||||
|
- **Check the feature flag at call-site level?** No. The singleton is always available; Opik_Dispatcher checks the flag before dispatch.
|
||||||
|
- **Async dispatch?** Yes. Spans are queued; dispatch happens via `prautoblogger_opik_dispatch` cron (no blocking).
|
||||||
|
- **Cost logging?** Unchanged. Your `cost_tracker->log_api_call()` remains separate from Opik; Opik is observability-only.
|
||||||
|
|
||||||
|
### Example: minimal instrumentation
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function my_operation( $input ) {
|
||||||
|
$model = get_option( 'my_model', 'default-model' );
|
||||||
|
$ctx = PRAutoBlogger_Opik_Trace_Context::current();
|
||||||
|
|
||||||
|
$span_id = $ctx->start_span(
|
||||||
|
array(
|
||||||
|
'name' => 'my_operation',
|
||||||
|
'type' => 'llm',
|
||||||
|
'model' => $model,
|
||||||
|
'provider' => 'openrouter',
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->llm->send_chat_completion( /* ... */ );
|
||||||
|
|
||||||
|
$ctx->end_span(
|
||||||
|
$span_id,
|
||||||
|
array(
|
||||||
|
'usage' => array(
|
||||||
|
'prompt_tokens' => $response['prompt_tokens'],
|
||||||
|
'completion_tokens' => $response['completion_tokens'],
|
||||||
|
'total_tokens' => $response['prompt_tokens'] + $response['completion_tokens'],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return $response['content'];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
That's it. No config, no feature-flag checks, no try/catch needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hidden Admin Pages (v0.19.3 convention, codified after two incidents)
|
||||||
|
|
||||||
|
### Rule: use options.php as parent; NEVER unset/remove registered pages from $submenu
|
||||||
|
|
||||||
|
When a WordPress admin page must be accessible by URL but hidden from the nav menu, use the
|
||||||
|
**options.php-parent pattern** exclusively:
|
||||||
|
|
||||||
|
```php
|
||||||
|
add_submenu_page(
|
||||||
|
'options.php', // parent: built-in WP page; pages hidden by construction
|
||||||
|
__( 'My Page', 'prautoblogger' ), // page title
|
||||||
|
__( 'My Page', 'prautoblogger' ), // menu title (never shown; options.php is hidden)
|
||||||
|
'manage_options', // capability
|
||||||
|
'my-page-slug', // page slug
|
||||||
|
array( $this, 'render_page' ), // callback
|
||||||
|
);
|
||||||
|
// No $submenu manipulation. options.php-parent pages are hidden from nav by construction.
|
||||||
|
```
|
||||||
|
|
||||||
|
The resulting hookname is `admin_page_my-page-slug` at BOTH registration time and request time.
|
||||||
|
|
||||||
|
### Why the hide-by-unset pattern causes 403 (the incident class)
|
||||||
|
|
||||||
|
WordPress computes the page hookname at two distinct moments:
|
||||||
|
|
||||||
|
**Registration time** (inside `add_submenu_page`):
|
||||||
|
WP resolves the parent slug against `$admin_page_hooks`. If found, hookname = `{parent_key}_page_{slug}`. WP registers the render callback and `$_registered_pages` entry under that name.
|
||||||
|
|
||||||
|
**Request time** (inside `wp-admin/admin.php`):
|
||||||
|
WP calls `get_admin_page_parent()` which scans `$submenu` for the page slug. If found, it derives the hookname the same way as (1). If **not found** (e.g., because the entry was unset), it falls back to `admin_page_{slug}`.
|
||||||
|
|
||||||
|
When these two hooknames diverge, WP dispatches the request to a hookname with no registered callback → `wp_die(403)`.
|
||||||
|
|
||||||
|
**Why options.php avoids this:** `options.php` is a built-in page that WordPress always keeps intact in `$admin_page_hooks` and `$submenu`. For its children, `get_admin_page_parent()` returns `options.php` consistently at both moments, so both produce `admin_page_{slug}`. No mutation required.
|
||||||
|
|
||||||
|
### Incident history
|
||||||
|
|
||||||
|
| Version | Symptom | Root cause | Fix |
|
||||||
|
|---------|---------|------------|-----|
|
||||||
|
| v0.19.1 | Board page 404 | Board submenu registered at same priority as parent (both 10) — add_submenu_page fired before add_menu_page → wrong hookname at registration time | Board moved to priority 11, after parent at 10 |
|
||||||
|
| v0.19.3 | Dossier page 403 | Dossier registered under `prautoblogger-settings` parent (priority 12, after parent ✓) but post-registration `unset($submenu[...])` removed the slug → get_admin_page_parent() returned false at request time → orphan `admin_page_*` hookname → no handler → 403 | Switched to `options.php` parent; removed all $submenu mutation |
|
||||||
|
|
||||||
|
Both incidents are in the same **hookname-mismatch class**: registration-time hookname ≠ request-time hookname.
|
||||||
|
|
||||||
|
### Test pattern
|
||||||
|
|
||||||
|
The `DossierMenuRegistrationTest::test_request_time_hookname_matches_registration_hookname` test
|
||||||
|
replicates the request-time resolution algorithm (scan `$submenu` for the slug; if found derive
|
||||||
|
hookname; if absent return `admin_page_*`) and asserts it equals the registration-time hookname.
|
||||||
|
This test FAILS on hide-by-unset implementations and passes on the options.php-parent pattern.
|
||||||
|
Apply the same test strategy to any new hidden admin page.
|
||||||
|
|
||||||
|
### Asset enqueue gate
|
||||||
|
|
||||||
|
For `options.php`-parent pages, the hook suffix passed to `admin_enqueue_scripts` is
|
||||||
|
`admin_page_{slug}`. For plugin-parent pages it would be `{parent_key}_page_{slug}`. Always
|
||||||
|
match the asset enqueue gate to the actual hookname for the chosen parent pattern:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// options.php parent → 'admin_page_{slug}'
|
||||||
|
public function on_enqueue_assets( string $hook_suffix ): void {
|
||||||
|
if ( $hook_suffix !== 'admin_page_' . self::PAGE_SLUG ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ... enqueue
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
84
INFORMATION-ARCHITECTURE.md
Normal file
84
INFORMATION-ARCHITECTURE.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# Information Architecture — PRAutoBlogger
|
||||||
|
|
||||||
|
Auto-generated index of all admin surfaces, data flows, and integrations.
|
||||||
|
Maintained per DoD v1.1.0 (same-PR update rule).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin Pages
|
||||||
|
|
||||||
|
| Slug | Class | Template | Purpose |
|
||||||
|
|------|-------|----------|---------|
|
||||||
|
| `prautoblogger-settings` | `PRAutoBlogger_Admin_Page` | `admin-page.php` | Main settings (schedule, models, budget, board limits) |
|
||||||
|
| `prautoblogger-board` | `PRAutoBlogger_Board_Page` | `board-page.php` | Kanban board: Generating / Failed / In Review / Published columns. New Article button (v0.21.0). |
|
||||||
|
| `prautoblogger-articles` | `PRAutoBlogger_Articles_Page` | `articles-page.php` | Paginated list of all articles/runs |
|
||||||
|
| `prautoblogger-dossier` | `PRAutoBlogger_Dossier_Page` | `dossier-page.php` | Per-article generation log + stage edit/re-run (M3) |
|
||||||
|
| `prautoblogger-ideas` | `PRAutoBlogger_Ideas_Browser` | `ideas-browser.php` | Analysis results browser with per-idea generation |
|
||||||
|
| `prautoblogger-activity` | `PRAutoBlogger_Activity_Page` | `activity-page.php` | Cost + generation activity log |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Generation Entry Points (v0.21.0)
|
||||||
|
|
||||||
|
```
|
||||||
|
Board "New Article" button
|
||||||
|
└─ AJAX prautoblogger_generate_now
|
||||||
|
└─ Generation_Status_Poller::on_ajax_generate_now()
|
||||||
|
└─ schedules prautoblogger_manual_generation cron
|
||||||
|
└─ Executor::on_manual_generation()
|
||||||
|
└─ Generation_Checkpoint_Runner::on_orchestrate_tick() ← Tick 1
|
||||||
|
└─ schedules prautoblogger_gen_tick
|
||||||
|
└─ Generation_Checkpoint_Runner::on_generate_tick() ← Tick 2..N
|
||||||
|
|
||||||
|
WP-CLI: wp prautoblogger generate
|
||||||
|
└─ WP_CLI_Commands::generate_command()
|
||||||
|
└─ Generation_Checkpoint_Runner::kick_off()
|
||||||
|
└─ schedules prautoblogger_gen_orchestrate
|
||||||
|
└─ Generation_Checkpoint_Runner::on_orchestrate_tick() ← Tick 1
|
||||||
|
|
||||||
|
Daily auto-generation cron
|
||||||
|
└─ prautoblogger_daily_generation
|
||||||
|
└─ Executor::on_cron_run()
|
||||||
|
└─ Pipeline_Runner::run() (legacy synchronous path — runs in a single PHP process)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Tables
|
||||||
|
|
||||||
|
| Table | DB Version | Purpose |
|
||||||
|
|-------|-----------|---------|
|
||||||
|
| `prautoblogger_sources` | 1.0.0 | Collected Reddit/RSS source content |
|
||||||
|
| `prautoblogger_analysis_results` | 1.0.0 | Scored article ideas |
|
||||||
|
| `prautoblogger_generation_log` | 1.0.0 | Per-LLM-call log (cost, tokens, status) |
|
||||||
|
| `prautoblogger_runs` | 1.0.0 | Per-run ledger (status, ceiling_usd) |
|
||||||
|
| `prautoblogger_run_stages` | 1.2.0 | Per-stage state for M3 re-run |
|
||||||
|
| `prautoblogger_stage_inputs` | 1.3.0 | Immutable input versions for stage edits (M3) |
|
||||||
|
| `prautoblogger_run_decisions` | 1.2.0 | Editorial decisions per run |
|
||||||
|
|
||||||
|
DB version history: 1.0→1.1 (schema normalisation), 1.1→1.2 (M2 dossier), 1.2→1.3 (M3 stage inputs), 1.3→1.4 (M4 indexes).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Options
|
||||||
|
|
||||||
|
| Option | Type | Default | Purpose |
|
||||||
|
|--------|------|---------|---------|
|
||||||
|
| `prautoblogger_board_column_limit` | int | 20 | Max cards per board column |
|
||||||
|
| `prautoblogger_ideas_per_page` | int | 30 | Ideas browser page size |
|
||||||
|
| `prautoblogger_article_queue` | array | — | Checkpoint queue (run_id + ideas + results) |
|
||||||
|
| `prautoblogger_checkpoint_run_id` | string | — | Current run UUID for Tick 2..N finalize path |
|
||||||
|
| `prautoblogger_db_version` | string | — | Installed schema version (triggers dbDelta on mismatch) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WP-CLI Commands
|
||||||
|
|
||||||
|
| Command | Since | Description |
|
||||||
|
|---------|-------|-------------|
|
||||||
|
| `wp prautoblogger generate` | 0.21.0 | Queue article generation via chained-cron checkpoints |
|
||||||
|
| `wp prautoblogger opik:eval` | 0.12.0 | Trigger Opik eval run |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Last updated: v0.21.0 (2026-06-14)*
|
||||||
6
assets/admin/peptiderepo-model-picker.css
Normal file
6
assets/admin/peptiderepo-model-picker.css
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Styling for model registry picker refresh functionality.
|
||||||
|
*
|
||||||
|
* Button animations and toast notifications are defined in admin.css.
|
||||||
|
* This file is reserved for model-picker-specific styles (future extensibility).
|
||||||
|
*/
|
||||||
58
assets/admin/peptiderepo-model-picker.js
Normal file
58
assets/admin/peptiderepo-model-picker.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Model registry refresh handler for PRAutoBlogger admin settings.
|
||||||
|
*
|
||||||
|
* Handles the "Refresh Model List" button click in the AI Models admin tab.
|
||||||
|
* Sends AJAX request to refresh the OpenRouter model registry cache and shows
|
||||||
|
* success/error toast notifications.
|
||||||
|
*
|
||||||
|
* @see admin/class-admin-page.php — Renders the refresh button + nonce.
|
||||||
|
* @see ajax/class-model-registry-refresh.php — Server-side handler.
|
||||||
|
*/
|
||||||
|
(function ($) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Refresh model registry button.
|
||||||
|
$(document).on('click', '#prautoblogger-refresh-models', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var $btn = $(this);
|
||||||
|
var nonce = $btn.data('nonce');
|
||||||
|
var originalText = $btn.html();
|
||||||
|
|
||||||
|
$btn.prop('disabled', true).addClass('ab-btn-loading');
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: ajaxurl,
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
action: 'prautoblogger_refresh_models',
|
||||||
|
nonce: nonce
|
||||||
|
},
|
||||||
|
timeout: 30000
|
||||||
|
})
|
||||||
|
.done(function (response) {
|
||||||
|
if (response.success && response.data && response.data.message) {
|
||||||
|
// Show success toast
|
||||||
|
var $notice = $('<div class="ab-save-notice" role="status"></div>')
|
||||||
|
.html('<span class="dashicons dashicons-yes"></span> ' + response.data.message)
|
||||||
|
.insertBefore('.ab-layout')
|
||||||
|
.delay(4000)
|
||||||
|
.fadeOut(function () {
|
||||||
|
$(this).remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.fail(function () {
|
||||||
|
var $notice = $('<div class="ab-save-notice notice-error" role="alert"></div>')
|
||||||
|
.html('<span class="dashicons dashicons-warning"></span> ' + 'Failed to refresh model list. Please try again.')
|
||||||
|
.insertBefore('.ab-layout')
|
||||||
|
.delay(5000)
|
||||||
|
.fadeOut(function () {
|
||||||
|
$(this).remove();
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.always(function () {
|
||||||
|
$btn.prop('disabled', false).removeClass('ab-btn-loading').html(originalText);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
})(jQuery);
|
||||||
BIN
assets/brand/logo-horizontal-reverse-128.png
Normal file
BIN
assets/brand/logo-horizontal-reverse-128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
BIN
assets/brand/logo-horizontal-reverse-256.png
Normal file
BIN
assets/brand/logo-horizontal-reverse-256.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/brand/logo-mark-small-112.png
Normal file
BIN
assets/brand/logo-mark-small-112.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
BIN
assets/brand/logo-mark-small-56.png
Normal file
BIN
assets/brand/logo-mark-small-56.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
249
assets/css/board.css
Normal file
249
assets/css/board.css
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* PRAutoBlogger — Kanban Board Styles
|
||||||
|
*
|
||||||
|
* Warm editorial style (Proposal C / Direction C).
|
||||||
|
* Inside wp-admin chrome; uses WP admin vars where available.
|
||||||
|
* No build step — plain CSS, no preprocessor.
|
||||||
|
*
|
||||||
|
* Palette:
|
||||||
|
* Teal #1B8A92 (brand primary — Generating pill, spinner)
|
||||||
|
* Cream #FAF6EE (card background)
|
||||||
|
* Ink #0D0D0D (primary text)
|
||||||
|
* Orange #FF8A3D (In Review pill)
|
||||||
|
* Lime #7FD600 (Published pill — success)
|
||||||
|
* Red #D94F4F (Failed pill)
|
||||||
|
* Mid-grey #8A8A8A (meta text, border)
|
||||||
|
*
|
||||||
|
* @see templates/admin/board-page.php — Rendered HTML structure.
|
||||||
|
* @see assets/js/board.js — DOM updates that match these selectors.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Page wrapper ──────────────────────────────────────────────────────── */
|
||||||
|
.prab-board-wrap {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-board-heading {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0d0d0d;
|
||||||
|
margin: 20px 0 18px 0;
|
||||||
|
padding: 0 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-board-heading-sub {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #8a8a8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Four-column board grid ────────────────────────────────────────────── */
|
||||||
|
.prab-board {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
padding: 0 20px 24px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.prab-board {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.prab-board {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Column ────────────────────────────────────────────────────────────── */
|
||||||
|
.prab-board-column {
|
||||||
|
background: #f8f7f5;
|
||||||
|
border: 1px solid #e2ddd6;
|
||||||
|
border-radius: 8px;
|
||||||
|
min-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-col-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 14px 10px;
|
||||||
|
border-bottom: 1px solid #e2ddd6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-col-title {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: #4a4540;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-col-cards {
|
||||||
|
padding: 10px 10px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-col-empty {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #b0a898;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status pills ──────────────────────────────────────────────────────── */
|
||||||
|
.prab-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #fff;
|
||||||
|
min-width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-pill--generating {
|
||||||
|
background: #1b8a92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-pill--review {
|
||||||
|
background: #ff8a3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-pill--published {
|
||||||
|
background: #5a9e28;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-pill--failed {
|
||||||
|
background: #d94f4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Card ──────────────────────────────────────────────────────────────── */
|
||||||
|
.prab-board-card {
|
||||||
|
background: #faf6ee;
|
||||||
|
border: 1px solid #e0d8cc;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 11px 12px 10px;
|
||||||
|
position: relative;
|
||||||
|
transition: box-shadow 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-board-card:hover {
|
||||||
|
box-shadow: 0 2px 8px rgba(27, 138, 146, 0.12);
|
||||||
|
border-color: #b8cfd2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-board-card--generating {
|
||||||
|
border-left: 3px solid #1b8a92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-card-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #0d0d0d;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding-right: 20px; /* room for spinner */
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-card-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-card-cost {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #5a8a50;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-card-stage {
|
||||||
|
font-size: 0.74rem;
|
||||||
|
color: #1b8a92;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-card-error {
|
||||||
|
font-size: 0.74rem;
|
||||||
|
color: #d94f4f;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-card-actions {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-card-link {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #1b8a92;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1px solid #b8d8db;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
transition: background 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-card-link:hover {
|
||||||
|
background: #e8f4f5;
|
||||||
|
color: #136870;
|
||||||
|
border-color: #8ac4c9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Generating spinner ────────────────────────────────────────────────── */
|
||||||
|
.prab-card-spinner {
|
||||||
|
position: absolute;
|
||||||
|
top: 11px;
|
||||||
|
right: 11px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid #c8e6e8;
|
||||||
|
border-top-color: #1b8a92;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: prab-spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes prab-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Board footer ──────────────────────────────────────────────────────── */
|
||||||
|
.prab-board-footer {
|
||||||
|
padding: 0 20px 16px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #a0998f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Poll-error banner ─────────────────────────────────────────────────── */
|
||||||
|
.prab-board-error {
|
||||||
|
background: #fff4f4;
|
||||||
|
border: 1px solid #f5c4c4;
|
||||||
|
color: #8b2020;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
margin: 0 20px 12px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-board-error.is-visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
240
assets/css/dossier-edit.css
Normal file
240
assets/css/dossier-edit.css
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* PRAutoBlogger dossier — edit + re-run styles (M3, v0.20.0).
|
||||||
|
* Extends dossier.css (kept separate: dossier.css predates the
|
||||||
|
* 300-line discipline and is not grown further).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Audit chips (stale / human-edited / attempt) ─────────────────── */
|
||||||
|
.prab-chip {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.6;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-chip--stale {
|
||||||
|
background: #fcf0e4;
|
||||||
|
color: #8a4a0b;
|
||||||
|
border: 1px solid #e8c9a8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-chip--human {
|
||||||
|
background: #e9f2f4;
|
||||||
|
color: #1b5e6b;
|
||||||
|
border: 1px solid #b8d8de;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-chip--attempt {
|
||||||
|
background: #f0f0f1;
|
||||||
|
color: #50575e;
|
||||||
|
border: 1px solid #dcdcde;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-chip--chosen {
|
||||||
|
background: #edf7ed;
|
||||||
|
color: #1e6b2e;
|
||||||
|
border: 1px solid #b9dfc0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-chip--header {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-stage--stale {
|
||||||
|
border-left: 3px solid #d98c2b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stage footer actions ─────────────────────────────────────────── */
|
||||||
|
.prab-stage-rerun-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px dashed #e2dcd2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-edit-unavailable {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #777;
|
||||||
|
font-style: italic;
|
||||||
|
max-width: 46em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-rerun-from--stale {
|
||||||
|
border-color: #d98c2b;
|
||||||
|
color: #8a4a0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Edit panel ───────────────────────────────────────────────────── */
|
||||||
|
.prab-edit-panel {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: #fbf8f2;
|
||||||
|
border: 1px solid #e2dcd2;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-edit-copy {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #50575e;
|
||||||
|
max-width: 60em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-edit-spend {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #50575e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-edit-spend--warn,
|
||||||
|
.prab-spend-warning {
|
||||||
|
color: #8a2a0b;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-run-spend--warn {
|
||||||
|
border-left: 3px solid #c9501f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-edit-model {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #777;
|
||||||
|
font-family: Consolas, Monaco, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-edit-message {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-edit-role {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: #8c8f94;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-edit-textarea {
|
||||||
|
width: 100%;
|
||||||
|
font-family: Consolas, Monaco, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-edit-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-edit-forkinfo {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-edit-feedback {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #1e6b2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-edit-feedback--error {
|
||||||
|
color: #b32d2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Image grid (F3) ──────────────────────────────────────────────── */
|
||||||
|
.prab-image-grid {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-image-card {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-image-card img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #e2dcd2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-image-card--chosen img {
|
||||||
|
border: 2px solid #1b8a92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-image-card figcaption {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #50575e;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sidebar models card (F2) ─────────────────────────────────────── */
|
||||||
|
.prab-models-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-models-table th {
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 4px 6px 4px 0;
|
||||||
|
vertical-align: top;
|
||||||
|
color: #50575e;
|
||||||
|
width: 38%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-models-table td {
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-models-model {
|
||||||
|
display: block;
|
||||||
|
font-family: Consolas, Monaco, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-models-meta {
|
||||||
|
display: block;
|
||||||
|
color: #8c8f94;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Board card human-modified dot ────────────────────────────────── */
|
||||||
|
.prab-card-human-chip {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 9px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #e9f2f4;
|
||||||
|
color: #1b5e6b;
|
||||||
|
border: 1px solid #b8d8de;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Review queue stale warning ───────────────────────────────────── */
|
||||||
|
.prab-review-stale-chip {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 9px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #fcf0e4;
|
||||||
|
color: #8a4a0b;
|
||||||
|
border: 1px solid #e8c9a8;
|
||||||
|
}
|
||||||
441
assets/css/dossier.css
Normal file
441
assets/css/dossier.css
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
/**
|
||||||
|
* PRAutoBlogger Article Dossier styles (M2).
|
||||||
|
*
|
||||||
|
* Design contract: Proposal C "Editorial Record" -- warm editorial,
|
||||||
|
* generous whitespace, typographic hierarchy, verdict pills, receipt
|
||||||
|
* sidebar. Scoped to .prab-dossier-wrap to avoid WP admin conflicts.
|
||||||
|
*
|
||||||
|
* @see admin/class-dossier-page.php
|
||||||
|
* @see templates/admin/dossier-page.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Layout ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.prab-dossier-wrap {
|
||||||
|
max-width: 1400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-dossier-back {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-dossier-back a {
|
||||||
|
color: #1B8A92;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-dossier-back a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-dossier--empty {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-dossier-empty-notice h2 {
|
||||||
|
font-size: 1.3em;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-dossier {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.prab-dossier-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 24px 28px 20px;
|
||||||
|
border-bottom: 1px solid #e8e0d8;
|
||||||
|
background: #FAF6EE;
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-dossier-title {
|
||||||
|
font-size: 1.6em;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: #1a1a1a;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-dossier-header-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Body: two-column ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.prab-dossier-body {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-dossier-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 24px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-dossier-sidebar {
|
||||||
|
width: 260px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 24px 20px;
|
||||||
|
border-left: 1px solid #e8e0d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Verdict pills ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.prab-verdict-pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-verdict-pill--approved { background: #e6f5ee; color: #1a7a45; }
|
||||||
|
.prab-verdict-pill--revised { background: #fff4e0; color: #a06000; }
|
||||||
|
.prab-verdict-pill--rejected { background: #fde8e8; color: #b91c1c; }
|
||||||
|
.prab-verdict-pill--halted { background: #f0f0f0; color: #555; }
|
||||||
|
.prab-verdict-pill--pending { background: #e8f0fe; color: #1a55a0; }
|
||||||
|
|
||||||
|
/* ── Status pills ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.prab-dossier-tier {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-dossier-status {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stage sections ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.prab-dossier-section {
|
||||||
|
margin-bottom: 28px;
|
||||||
|
border: 1px solid #e5ddd5;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-dossier-section--stage {
|
||||||
|
border-left: 4px solid #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-stage-status--done { border-left-color: #1B8A92; }
|
||||||
|
.prab-stage-status--running { border-left-color: #FF8A3D; }
|
||||||
|
.prab-stage-status--failed { border-left-color: #e53e3e; }
|
||||||
|
.prab-stage-status--pending { border-left-color: #a0aec0; }
|
||||||
|
|
||||||
|
.prab-stage-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #faf8f5;
|
||||||
|
border-bottom: 1px solid #ede6dd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-section-title {
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
color: #2d2d2d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-stage-role {
|
||||||
|
font-weight: 400;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9em;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-stage-header-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-stage-status-pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-stage-status-pill--done { background: #e6f5ee; color: #1a7a45; }
|
||||||
|
.prab-stage-status-pill--running { background: #fff4e0; color: #a06000; }
|
||||||
|
.prab-stage-status-pill--failed { background: #fde8e8; color: #b91c1c; }
|
||||||
|
.prab-stage-status-pill--pending { background: #f0f0f0; color: #555; }
|
||||||
|
|
||||||
|
.prab-stage-cost {
|
||||||
|
color: #1B8A92;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-stage-pv {
|
||||||
|
background: #f0f0f0;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stage output ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.prab-stage-output {
|
||||||
|
padding: 16px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-stage-output-rendered {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
max-height: 480px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-output-pruned,
|
||||||
|
.prab-output-absent {
|
||||||
|
color: #888;
|
||||||
|
font-style: italic;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Raw trace toggle ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.prab-stage-trace {
|
||||||
|
padding: 0 16px 12px;
|
||||||
|
border-top: 1px solid #f0ece6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-trace-toggle {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #1B8A92;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-trace-toggle:hover {
|
||||||
|
color: #0d6066;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-trace-detail {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-trace-entry {
|
||||||
|
background: #f7f4ef;
|
||||||
|
border: 1px solid #e5ddd5;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-trace-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-trace-status--success { color: #1a7a45; }
|
||||||
|
.prab-trace-status--error { color: #b91c1c; }
|
||||||
|
|
||||||
|
.prab-trace-request summary {
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #888;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-trace-pre {
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
overflow-x: auto;
|
||||||
|
max-height: 300px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-trace-error {
|
||||||
|
color: #b91c1c;
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stage timestamps ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.prab-stage-timestamps {
|
||||||
|
padding: 4px 16px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-ts-sep {
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Decisions section ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.prab-dossier-section--decisions {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-dossier-section--decisions .prab-section-title {
|
||||||
|
font-size: 1.1em;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-decision-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid #f0ece6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-decision-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-decision-stage {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-decision-rationale {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
width: 100%;
|
||||||
|
color: #555;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sidebar ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.prab-sidebar-card {
|
||||||
|
background: #faf8f5;
|
||||||
|
border: 1px solid #e5ddd5;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-sidebar-heading {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: #888;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-sidebar-dl {
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 4px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-sidebar-dl dt {
|
||||||
|
color: #888;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-sidebar-dl dd {
|
||||||
|
margin: 0;
|
||||||
|
color: #333;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-run-id {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cost receipt ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.prab-receipt-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-receipt-table td {
|
||||||
|
padding: 3px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-receipt-stage {
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-receipt-cost {
|
||||||
|
text-align: right;
|
||||||
|
color: #1B8A92;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-receipt-total {
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-receipt-total td {
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Run status ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.prab-run-status {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-run-status--done { color: #1a7a45; }
|
||||||
|
.prab-run-status--running { color: #a06000; }
|
||||||
|
.prab-run-status--failed { color: #b91c1c; }
|
||||||
|
|
||||||
|
/* ── No stages ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.prab-dossier-no-stages {
|
||||||
|
padding: 24px 0;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Metabox ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.prab-metabox-dossier .prab-run-id-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
264
assets/css/model-picker.css
Normal file
264
assets/css/model-picker.css
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
/**
|
||||||
|
* Model picker popup styles for PRAutoBlogger admin.
|
||||||
|
*
|
||||||
|
* Renders a modal overlay with a searchable list of AI models.
|
||||||
|
* Uses the same design tokens as admin.css (--ab-* CSS variables).
|
||||||
|
*
|
||||||
|
* @see assets/js/model-picker.js — JS that builds and manages the popup DOM.
|
||||||
|
* @see assets/css/admin.css — Parent design system variables.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Overlay ────────────────────────────────────────────────────────── */
|
||||||
|
.ab-mp-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 100100;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
animation: ab-mp-fade-in 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ab-mp-fade-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Popup container ────────────────────────────────────────────────── */
|
||||||
|
.ab-mp-popup {
|
||||||
|
background: var(--ab-card-bg, #fff);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
|
||||||
|
width: 640px; max-width: 90vw;
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
animation: ab-mp-slide-up 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ab-mp-slide-up {
|
||||||
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ─────────────────────────────────────────────────────────── */
|
||||||
|
.ab-mp-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 16px 20px; border-bottom: 1px solid var(--ab-border, #e2e8f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-mp-title {
|
||||||
|
font-size: 16px; font-weight: 700;
|
||||||
|
color: var(--ab-text, #1e293b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-mp-close {
|
||||||
|
background: none; border: none; cursor: pointer;
|
||||||
|
font-size: 22px; line-height: 1; color: var(--ab-text-muted, #64748b);
|
||||||
|
padding: 4px 8px; border-radius: 4px;
|
||||||
|
}
|
||||||
|
.ab-mp-close:hover {
|
||||||
|
background: var(--ab-bg, #f8fafc);
|
||||||
|
color: var(--ab-text, #1e293b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Search ─────────────────────────────────────────────────────────── */
|
||||||
|
.ab-mp-search-wrap {
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-bottom: 1px solid var(--ab-border, #e2e8f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-mp-search {
|
||||||
|
width: 100%; box-sizing: border-box;
|
||||||
|
padding: 10px 14px; font-size: 14px;
|
||||||
|
border: 1px solid var(--ab-border, #e2e8f0);
|
||||||
|
border-radius: 8px; outline: none;
|
||||||
|
color: var(--ab-text, #1e293b);
|
||||||
|
background: var(--ab-bg, #f8fafc);
|
||||||
|
}
|
||||||
|
.ab-mp-search:focus {
|
||||||
|
border-color: var(--ab-primary, #4f46e5);
|
||||||
|
box-shadow: 0 0 0 3px var(--ab-primary-light, #eef2ff);
|
||||||
|
background: var(--ab-card-bg, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Model list ─────────────────────────────────────────────────────── */
|
||||||
|
.ab-mp-list {
|
||||||
|
flex: 1; overflow-y: auto;
|
||||||
|
padding: 4px 0;
|
||||||
|
min-height: 200px; max-height: 50vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-mp-loading, .ab-mp-empty {
|
||||||
|
padding: 40px 20px; text-align: center;
|
||||||
|
color: var(--ab-text-muted, #64748b); font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Model row ──────────────────────────────────────────────────────── */
|
||||||
|
.ab-mp-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 10px 20px; cursor: pointer;
|
||||||
|
transition: background 0.1s ease;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.ab-mp-row:hover {
|
||||||
|
background: var(--ab-primary-light, #eef2ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-mp-row-main {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
flex: 1; min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-mp-name {
|
||||||
|
font-size: 13px; font-weight: 600;
|
||||||
|
color: var(--ab-text, #1e293b);
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-mp-provider {
|
||||||
|
font-size: 11px; font-weight: 500;
|
||||||
|
color: var(--ab-primary, #4f46e5);
|
||||||
|
background: var(--ab-primary-light, #eef2ff);
|
||||||
|
padding: 1px 6px; border-radius: 4px;
|
||||||
|
white-space: nowrap; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-mp-dep-badge {
|
||||||
|
font-size: 10px; font-weight: 600;
|
||||||
|
color: var(--ab-warning, #d97706);
|
||||||
|
background: #fffbeb;
|
||||||
|
padding: 1px 6px; border-radius: 4px;
|
||||||
|
white-space: nowrap; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-mp-deprecated {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-mp-row-meta {
|
||||||
|
display: flex; align-items: center; gap: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-mp-price {
|
||||||
|
font-size: 12px; font-weight: 500;
|
||||||
|
color: var(--ab-text-muted, #64748b);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 90px; text-align: right; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-mp-ctx {
|
||||||
|
font-size: 11px; font-weight: 600;
|
||||||
|
color: var(--ab-text-muted, #64748b);
|
||||||
|
background: var(--ab-bg, #f8fafc);
|
||||||
|
padding: 2px 6px; border-radius: 4px;
|
||||||
|
white-space: nowrap; min-width: 36px; text-align: center;
|
||||||
|
max-width: 200px; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Footer ─────────────────────────────────────────────────────────── */
|
||||||
|
.ab-mp-footer {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-top: 1px solid var(--ab-border, #e2e8f0);
|
||||||
|
font-size: 12px; color: var(--ab-text-muted, #64748b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Trigger button (replaces text input) ───────────────────────────── */
|
||||||
|
.ab-mp-trigger {
|
||||||
|
display: inline-flex; align-items: center; gap: 10px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: var(--ab-card-bg, #fff);
|
||||||
|
border: 1px solid var(--ab-border, #e2e8f0);
|
||||||
|
border-radius: 6px; cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
max-width: 420px; width: 100%; box-sizing: border-box;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.ab-mp-trigger:hover {
|
||||||
|
border-color: var(--ab-primary, #4f46e5);
|
||||||
|
}
|
||||||
|
.ab-mp-trigger:focus {
|
||||||
|
border-color: var(--ab-primary, #4f46e5);
|
||||||
|
box-shadow: 0 0 0 3px var(--ab-primary-light, #eef2ff);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-mp-display-name {
|
||||||
|
font-size: 13px; font-weight: 600;
|
||||||
|
color: var(--ab-text, #1e293b);
|
||||||
|
flex: 1; min-width: 0;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-mp-display-price {
|
||||||
|
font-size: 11px; font-weight: 500;
|
||||||
|
color: var(--ab-text-muted, #64748b);
|
||||||
|
white-space: nowrap; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-mp-display-arrow {
|
||||||
|
color: var(--ab-text-muted, #64748b);
|
||||||
|
font-size: 14px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ─────────────────────────────────────────────────────── */
|
||||||
|
@media screen and (max-width: 680px) {
|
||||||
|
.ab-mp-popup { width: 95vw; max-height: 85vh; }
|
||||||
|
.ab-mp-row { padding: 10px 14px; }
|
||||||
|
.ab-mp-row-meta { gap: 8px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Column headers (sortable) ──────────────────────────────────────── */
|
||||||
|
.ab-mp-col-headers {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 5px 20px;
|
||||||
|
border-bottom: 1px solid var(--ab-border, #e2e8f0);
|
||||||
|
background: var(--ab-bg, #f8fafc);
|
||||||
|
gap: 12px; /* must match .ab-mp-row gap */
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-mp-col-headers-meta {
|
||||||
|
display: flex; align-items: center;
|
||||||
|
gap: 16px; /* must match .ab-mp-row-meta gap */
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-mp-col-header {
|
||||||
|
display: inline-flex; align-items: center;
|
||||||
|
font-size: 10px; font-weight: 700; text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--ab-text-muted, #64748b);
|
||||||
|
cursor: pointer; user-select: none;
|
||||||
|
background: none; border: none; padding: 3px 0;
|
||||||
|
white-space: nowrap; line-height: 1.2;
|
||||||
|
transition: color 0.1s ease;
|
||||||
|
}
|
||||||
|
.ab-mp-col-header:hover {
|
||||||
|
color: var(--ab-text, #1e293b);
|
||||||
|
}
|
||||||
|
.ab-mp-col-header.ab-mp-col-active {
|
||||||
|
color: var(--ab-primary, #4f46e5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "Model" header spans the left flex-1 area */
|
||||||
|
.ab-mp-col-model {
|
||||||
|
flex: 1; min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "Price" header width matches .ab-mp-price */
|
||||||
|
.ab-mp-col-price {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
min-width: 90px; justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "Context" header width matches .ab-mp-ctx (min-width 36px + padding) */
|
||||||
|
.ab-mp-col-ctx {
|
||||||
|
min-width: 48px; justify-content: center; text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-mp-sort-arrow {
|
||||||
|
font-size: 9px; opacity: 0.5;
|
||||||
|
transition: opacity 0.1s ease;
|
||||||
|
}
|
||||||
|
.ab-mp-col-header.ab-mp-col-active .ab-mp-sort-arrow {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
93
assets/fonts/OFL.txt
Normal file
93
assets/fonts/OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2020 The Poppins Project Authors (https://github.com/itfoundry/Poppins)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
BIN
assets/fonts/Poppins-Bold.ttf
Normal file
BIN
assets/fonts/Poppins-Bold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Poppins-SemiBold.ttf
Normal file
BIN
assets/fonts/Poppins-SemiBold.ttf
Normal file
Binary file not shown.
139
assets/js/admin.js
Executable file → Normal file
139
assets/js/admin.js
Executable file → Normal file
@@ -36,6 +36,14 @@
|
|||||||
url.searchParams.set('tab', tab);
|
url.searchParams.set('tab', tab);
|
||||||
window.history.replaceState({}, '', url);
|
window.history.replaceState({}, '', url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update the _wp_http_referer so WordPress redirects back to this tab after save.
|
||||||
|
var $referer = $('#ab-settings-form input[name="_wp_http_referer"]');
|
||||||
|
if ($referer.length) {
|
||||||
|
var refUrl = new URL($referer.val(), window.location.origin);
|
||||||
|
refUrl.searchParams.set('tab', tab);
|
||||||
|
$referer.val(refUrl.pathname + refUrl.search);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -55,6 +63,37 @@
|
|||||||
$notice.fadeOut(400, function () { $(this).remove(); });
|
$notice.fadeOut(400, function () { $(this).remove(); });
|
||||||
}, 6000);
|
}, 6000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On load, sync _wp_http_referer with the current tab so the first
|
||||||
|
// save without switching tabs still returns to the right place.
|
||||||
|
var currentTab = new URL(window.location).searchParams.get('tab');
|
||||||
|
if (currentTab) {
|
||||||
|
var $referer = $('#ab-settings-form input[name="_wp_http_referer"]');
|
||||||
|
if ($referer.length) {
|
||||||
|
var refUrl = new URL($referer.val(), window.location.origin);
|
||||||
|
refUrl.searchParams.set('tab', currentTab);
|
||||||
|
$referer.val(refUrl.pathname + refUrl.search);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resume polling if a generation is already running (e.g., user
|
||||||
|
// navigated away and came back, or opened a new tab mid-generation).
|
||||||
|
var $genBtn = $('#prautoblogger-generate-now');
|
||||||
|
if ($genBtn.length && config.ajaxUrl) {
|
||||||
|
$.ajax({
|
||||||
|
url: config.ajaxUrl,
|
||||||
|
method: 'POST',
|
||||||
|
data: { action: 'prautoblogger_generation_status', nonce: config.generateNonce },
|
||||||
|
timeout: 10000,
|
||||||
|
success: function (response) {
|
||||||
|
if (response.success && response.data && response.data.status === 'running') {
|
||||||
|
setButtonLoading($genBtn, config.generatingText || 'Generating…');
|
||||||
|
showProgress(response.data.stage || 'Generation in progress…');
|
||||||
|
pollGenerationStatus($genBtn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -126,29 +165,76 @@
|
|||||||
* Shows pipeline stages while the request is in-flight so the user
|
* Shows pipeline stages while the request is in-flight so the user
|
||||||
* knows it hasn't hung.
|
* knows it hasn't hung.
|
||||||
*/
|
*/
|
||||||
var generateStages = [
|
/*
|
||||||
{ text: 'Collecting sources from Reddit…', delay: 0 },
|
* Pipeline stages are now reported by the server via the status polling
|
||||||
{ text: 'Analyzing topics and scoring…', delay: 8000 },
|
* endpoint. The old client-side fake timers have been removed.
|
||||||
{ text: 'Selecting best topic…', delay: 16000 },
|
*/
|
||||||
{ text: 'Generating article draft via AI…', delay: 24000 },
|
|
||||||
{ text: 'Running editorial pass…', delay: 50000 },
|
/** Poll interval handle for generation status checks. */
|
||||||
{ text: 'Saving and publishing…', delay: 75000 },
|
var statusPollTimer = null;
|
||||||
{ text: 'Almost done — finalizing…', delay: 100000 }
|
|
||||||
];
|
/**
|
||||||
var stageTimers = [];
|
* Poll the generation status endpoint until the run finishes.
|
||||||
|
* Updates the progress UI and resolves the button state on completion.
|
||||||
|
*
|
||||||
|
* @param {jQuery} $btn The Generate Now button.
|
||||||
|
*/
|
||||||
|
function pollGenerationStatus($btn) {
|
||||||
|
statusPollTimer = setInterval(function () {
|
||||||
|
$.ajax({
|
||||||
|
url: config.ajaxUrl,
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
action: 'prautoblogger_generation_status',
|
||||||
|
nonce: config.generateNonce
|
||||||
|
},
|
||||||
|
timeout: 15000,
|
||||||
|
success: function (response) {
|
||||||
|
if (!response.success) return;
|
||||||
|
var d = response.data;
|
||||||
|
|
||||||
|
if (d.status === 'running') {
|
||||||
|
showProgress(d.stage || 'Generating…');
|
||||||
|
return; // Keep polling.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminal state — stop polling.
|
||||||
|
clearInterval(statusPollTimer);
|
||||||
|
statusPollTimer = null;
|
||||||
|
hideProgress();
|
||||||
|
resetButton($btn, config.generateText || 'Generate Now');
|
||||||
|
|
||||||
|
if (d.status === 'complete') {
|
||||||
|
showStatus(
|
||||||
|
'Generation complete: ' + d.generated + ' generated, ' +
|
||||||
|
d.published + ' published, ' + d.rejected + ' rejected. ' +
|
||||||
|
'Cost: $' + parseFloat(d.cost).toFixed(4),
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
} else if (d.status === 'error') {
|
||||||
|
showStatus(
|
||||||
|
'Generation failed: ' + (d.message || 'Unknown error'),
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// idle or unknown — generation may have finished before
|
||||||
|
// polling started. Reset the button cleanly.
|
||||||
|
hideProgress();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function () {
|
||||||
|
// Network blip — keep polling, don't bail on transient errors.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 5000); // Poll every 5 seconds.
|
||||||
|
}
|
||||||
|
|
||||||
$(document).on('click', '#prautoblogger-generate-now', function () {
|
$(document).on('click', '#prautoblogger-generate-now', function () {
|
||||||
var $btn = $(this);
|
var $btn = $(this);
|
||||||
if ($btn.prop('disabled')) return;
|
if ($btn.prop('disabled')) return;
|
||||||
|
|
||||||
setButtonLoading($btn, config.generatingText || 'Generating…');
|
setButtonLoading($btn, config.generatingText || 'Generating…');
|
||||||
|
showProgress('Starting generation…');
|
||||||
// Start pipeline stage messages.
|
|
||||||
stageTimers = [];
|
|
||||||
$.each(generateStages, function (i, stage) {
|
|
||||||
var t = setTimeout(function () { showProgress(stage.text); }, stage.delay);
|
|
||||||
stageTimers.push(t);
|
|
||||||
});
|
|
||||||
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: config.ajaxUrl,
|
url: config.ajaxUrl,
|
||||||
@@ -158,21 +244,19 @@
|
|||||||
nonce: config.generateNonce,
|
nonce: config.generateNonce,
|
||||||
force: '1' // Always clear stale locks on manual runs.
|
force: '1' // Always clear stale locks on manual runs.
|
||||||
},
|
},
|
||||||
timeout: 300000, // 5 minutes — generation can be slow.
|
timeout: 30000, // 30s is plenty — this just schedules the cron.
|
||||||
success: function (response) {
|
success: function (response) {
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
var d = response.data;
|
// Generation kicked off in background — start polling.
|
||||||
showStatus(
|
showProgress(response.data.message || 'Generation started…');
|
||||||
'Generation complete: ' + d.generated + ' generated, ' +
|
pollGenerationStatus($btn);
|
||||||
d.published + ' published, ' + d.rejected + ' rejected. ' +
|
|
||||||
'Cost: $' + parseFloat(d.cost).toFixed(4),
|
|
||||||
'success'
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
showStatus(
|
showStatus(
|
||||||
'Generation failed: ' + (response.data && response.data.message || 'Unknown error'),
|
'Generation failed: ' + (response.data && response.data.message || 'Unknown error'),
|
||||||
'error'
|
'error'
|
||||||
);
|
);
|
||||||
|
hideProgress();
|
||||||
|
resetButton($btn, config.generateText || 'Generate Now');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: function (xhr, status) {
|
error: function (xhr, status) {
|
||||||
@@ -180,11 +264,6 @@
|
|||||||
'Request failed: ' + status + '. Check server error logs.',
|
'Request failed: ' + status + '. Check server error logs.',
|
||||||
'error'
|
'error'
|
||||||
);
|
);
|
||||||
},
|
|
||||||
complete: function () {
|
|
||||||
// Clear all stage timers.
|
|
||||||
$.each(stageTimers, function (_, t) { clearTimeout(t); });
|
|
||||||
stageTimers = [];
|
|
||||||
hideProgress();
|
hideProgress();
|
||||||
resetButton($btn, config.generateText || 'Generate Now');
|
resetButton($btn, config.generateText || 'Generate Now');
|
||||||
}
|
}
|
||||||
|
|||||||
53
assets/js/board-generate.js
Normal file
53
assets/js/board-generate.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* PRAutoBlogger Board -- "New Article" generate button handler (v0.21.0, M4).
|
||||||
|
*
|
||||||
|
* Loaded on the board page alongside board.js. Depends on the prabBoard config
|
||||||
|
* object and jQuery, both already present when board.js loads.
|
||||||
|
*
|
||||||
|
* @see assets/js/board.js -- Board poller (loads first).
|
||||||
|
* @see includes/admin/class-board-page.php -- Enqueues + localizes prabBoard.
|
||||||
|
*/
|
||||||
|
( function ( $ ) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var cfg = window.prabBoard || {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle click on the "New Article" button. Fires the existing generate_now
|
||||||
|
* AJAX action and triggers board backoff reset so the new card appears fast.
|
||||||
|
*/
|
||||||
|
function onBoardGenerateNow() {
|
||||||
|
var $btn = $( '#prab-board-generate-now' );
|
||||||
|
if ( ! $btn.length || $btn.prop( 'disabled' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$btn.prop( 'disabled', true ).text( cfg.strings && cfg.strings.generatingBtn || 'Generating\u2026' );
|
||||||
|
var $status = $( '#prab-board-gen-status' );
|
||||||
|
$status.text( '' ).hide();
|
||||||
|
|
||||||
|
$.ajax( {
|
||||||
|
url: cfg.ajaxUrl,
|
||||||
|
method: 'POST',
|
||||||
|
data: { action: 'prautoblogger_generate_now', nonce: cfg.generateNonce },
|
||||||
|
success: function ( response ) {
|
||||||
|
if ( response && response.success ) {
|
||||||
|
$status.text( cfg.strings && cfg.strings.genStarted || 'Generation started.' ).show();
|
||||||
|
// Notify board poller to reset backoff (run is now active).
|
||||||
|
$( document ).trigger( 'prab:generation-started' );
|
||||||
|
} else {
|
||||||
|
$status.text( cfg.strings && cfg.strings.genError || 'Generation failed.' ).show();
|
||||||
|
$btn.prop( 'disabled', false ).text( cfg.strings && cfg.strings.newArticle || 'New Article' );
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function () {
|
||||||
|
$status.text( cfg.strings && cfg.strings.genError || 'Generation failed.' ).show();
|
||||||
|
$btn.prop( 'disabled', false ).text( cfg.strings && cfg.strings.newArticle || 'New Article' );
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
$( function () {
|
||||||
|
$( document ).on( 'click', '#prab-board-generate-now', onBoardGenerateNow );
|
||||||
|
} );
|
||||||
|
|
||||||
|
}( jQuery ) );
|
||||||
236
assets/js/board.js
Normal file
236
assets/js/board.js
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
/**
|
||||||
|
* PRAutoBlogger — Kanban Board Poller
|
||||||
|
*
|
||||||
|
* Live-polls the board status AJAX endpoint and updates the four columns
|
||||||
|
* (Generating | In Review | Published | Failed) in-place without a full
|
||||||
|
* page reload.
|
||||||
|
*
|
||||||
|
* Poll behaviour:
|
||||||
|
* - Polls at `prabBoard.pollInterval` ms when any article is generating.
|
||||||
|
* - Doubles the interval (backoff) when `has_active_runs` is false, capping
|
||||||
|
* at 4× the base interval — prevents needless AJAX on an idle board.
|
||||||
|
* - Resets to base interval as soon as an active run is detected again.
|
||||||
|
*
|
||||||
|
* Click-throughs (M1):
|
||||||
|
* - Generating → Activity Log
|
||||||
|
* - In Review → Review Queue
|
||||||
|
* - Published → Post edit screen
|
||||||
|
* - Failed → Activity Log
|
||||||
|
* (M2 rewires all to Article Dossier)
|
||||||
|
*
|
||||||
|
* Localised config: `prabBoard` object (ajaxUrl, nonce, pollInterval, action,
|
||||||
|
* strings) injected by PRAutoBlogger_Board_Page::on_enqueue_assets().
|
||||||
|
*
|
||||||
|
* @see includes/admin/class-board-page.php — AJAX handler + localise.
|
||||||
|
* @see assets/css/board.css — Card and column styles.
|
||||||
|
* @see templates/admin/board-page.php — Initial server-rendered HTML.
|
||||||
|
*/
|
||||||
|
( function ( $ ) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var cfg = window.prabBoard || {};
|
||||||
|
var baseInterval = cfg.pollInterval || 5000;
|
||||||
|
var maxBackoff = 4;
|
||||||
|
var backoffFactor = 1;
|
||||||
|
var pollTimer = null;
|
||||||
|
var $board = null;
|
||||||
|
var $errorBanner = null;
|
||||||
|
|
||||||
|
/** Escape HTML for safe DOM insertion. */
|
||||||
|
function escHtml( str ) {
|
||||||
|
return $( '<div>' ).text( String( str ) ).html();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format cost to 4 decimal places with a leading $. */
|
||||||
|
function formatCost( cost ) {
|
||||||
|
var n = parseFloat( cost ) || 0;
|
||||||
|
return '$' + n.toFixed( 4 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a card HTML string from a card data object.
|
||||||
|
*
|
||||||
|
* @param {Object} card Card data from server.
|
||||||
|
* @param {string} columnKey Column identifier.
|
||||||
|
* @returns {string} HTML string.
|
||||||
|
*/
|
||||||
|
function buildCardHtml( card, columnKey ) {
|
||||||
|
var isGenerating = ( columnKey === 'generating' );
|
||||||
|
var cls = 'prab-board-card' + ( isGenerating ? ' prab-board-card--generating' : '' );
|
||||||
|
var html = '';
|
||||||
|
|
||||||
|
html += '<div class="' + escHtml( cls ) + '"';
|
||||||
|
html += ' data-run-id="' + escHtml( card.run_id || '' ) + '"';
|
||||||
|
html += ' data-post-id="' + escHtml( card.post_id || 0 ) + '">';
|
||||||
|
|
||||||
|
if ( isGenerating ) {
|
||||||
|
html += '<div class="prab-card-spinner" aria-label="' + escHtml( cfg.strings.generating ) + '"></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '<div class="prab-card-title">' + escHtml( card.title || cfg.strings.empty ) + '</div>';
|
||||||
|
|
||||||
|
// v0.20.0: human-modified runs are visually distinct at run-list level.
|
||||||
|
if ( card.human_modified ) {
|
||||||
|
html += '<span class="prab-card-human-chip">' + escHtml( cfg.strings.humanModified || 'Human-modified' ) + '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( card.cost_total ) {
|
||||||
|
html += '<div class="prab-card-meta"><span class="prab-card-cost">';
|
||||||
|
html += escHtml( formatCost( card.cost_total ) );
|
||||||
|
html += '</span></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( isGenerating && card.stage_current ) {
|
||||||
|
html += '<div class="prab-card-stage">';
|
||||||
|
/* translators: stage name */
|
||||||
|
html += escHtml( cfg.strings.generating + ': ' + card.stage_current );
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( card.error_message ) {
|
||||||
|
var errShort = String( card.error_message ).split( ' ' ).slice( 0, 12 ).join( ' ' );
|
||||||
|
if ( card.error_message.split( ' ' ).length > 12 ) {
|
||||||
|
errShort += '…';
|
||||||
|
}
|
||||||
|
html += '<div class="prab-card-error">' + escHtml( errShort ) + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click-through link.
|
||||||
|
var linkUrl = '';
|
||||||
|
var linkText = '';
|
||||||
|
if ( card.click_action === 'dossier' && card.dossier_url ) {
|
||||||
|
linkUrl = card.dossier_url;
|
||||||
|
linkText = cfg.strings.view;
|
||||||
|
} else if ( card.click_action === 'review' && card.review_url ) {
|
||||||
|
linkUrl = card.review_url;
|
||||||
|
linkText = cfg.strings.view;
|
||||||
|
} else if ( card.click_action === 'edit' && card.edit_url ) {
|
||||||
|
linkUrl = card.edit_url;
|
||||||
|
linkText = cfg.strings.edit;
|
||||||
|
} else if ( card.click_action === 'logs' && card.log_url ) {
|
||||||
|
linkUrl = card.log_url;
|
||||||
|
linkText = cfg.strings.viewLog;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( linkUrl ) {
|
||||||
|
html += '<div class="prab-card-actions">';
|
||||||
|
html += '<a href="' + escHtml( linkUrl ) + '" class="prab-card-link">';
|
||||||
|
html += escHtml( linkText );
|
||||||
|
html += '</a></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-render a single column from the server snapshot.
|
||||||
|
*
|
||||||
|
* Updates the pill count and replaces card HTML. Avoids full DOM teardown
|
||||||
|
* to prevent flicker on the running spinner.
|
||||||
|
*
|
||||||
|
* @param {string} colKey Column identifier.
|
||||||
|
* @param {Array} cards Array of card objects.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
function renderColumn( colKey, cards ) {
|
||||||
|
var $col = $board.find( '[data-column="' + colKey + '"]' );
|
||||||
|
var $cards = $col.find( '[data-column-cards]' );
|
||||||
|
var $pill = $col.find( '.prab-col-count' );
|
||||||
|
|
||||||
|
$pill.text( cards.length );
|
||||||
|
|
||||||
|
if ( ! cards.length ) {
|
||||||
|
$cards.html( '<p class="prab-col-empty">' + escHtml( cfg.strings.empty ) + '</p>' );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build new card HTML.
|
||||||
|
var newHtml = '';
|
||||||
|
for ( var i = 0; i < cards.length; i++ ) {
|
||||||
|
newHtml += buildCardHtml( cards[ i ], colKey );
|
||||||
|
}
|
||||||
|
$cards.html( newHtml );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a server snapshot to all four columns.
|
||||||
|
*
|
||||||
|
* @param {Object} snapshot Board snapshot from AJAX.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
function applySnapshot( snapshot ) {
|
||||||
|
renderColumn( 'generating', snapshot.generating || [] );
|
||||||
|
renderColumn( 'in_review', snapshot.in_review || [] );
|
||||||
|
renderColumn( 'published', snapshot.published || [] );
|
||||||
|
renderColumn( 'failed', snapshot.failed || [] );
|
||||||
|
// Re-enable board Generate button once no active runs remain.
|
||||||
|
if ( ! snapshot.has_active_runs ) {
|
||||||
|
$( '#prab-board-generate-now' ).prop( 'disabled', false )
|
||||||
|
.text( ( cfg.strings && cfg.strings.newArticle ) || 'New Article' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the next poll delay based on whether there are active runs.
|
||||||
|
*
|
||||||
|
* @param {boolean} hasActiveRuns
|
||||||
|
* @returns {number} Milliseconds until next poll.
|
||||||
|
*/
|
||||||
|
function nextDelay( hasActiveRuns ) {
|
||||||
|
if ( hasActiveRuns ) {
|
||||||
|
backoffFactor = 1;
|
||||||
|
return baseInterval;
|
||||||
|
}
|
||||||
|
backoffFactor = Math.min( backoffFactor + 1, maxBackoff );
|
||||||
|
return baseInterval * backoffFactor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule the next poll after a given delay.
|
||||||
|
*
|
||||||
|
* @param {number} delay Milliseconds.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
function schedulePoll( delay ) {
|
||||||
|
clearTimeout( pollTimer );
|
||||||
|
pollTimer = setTimeout( doPoll, delay );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute one board poll: AJAX POST → apply snapshot → reschedule.
|
||||||
|
*
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
function doPoll() {
|
||||||
|
$.ajax( {
|
||||||
|
url: cfg.ajaxUrl,
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
action: cfg.action,
|
||||||
|
nonce: cfg.nonce
|
||||||
|
},
|
||||||
|
success: function ( response ) {
|
||||||
|
if ( $errorBanner ) {
|
||||||
|
$errorBanner.removeClass( 'is-visible' );
|
||||||
|
}
|
||||||
|
if ( response && response.success && response.data ) {
|
||||||
|
applySnapshot( response.data );
|
||||||
|
schedulePoll( nextDelay( !! response.data.has_active_runs ) );
|
||||||
|
} else {
|
||||||
|
schedulePoll( nextDelay( false ) );
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function () {
|
||||||
|
if ( $errorBanner ) {
|
||||||
|
$errorBanner.addClass( 'is-visible' );
|
||||||
|
}
|
||||||
|
schedulePoll( nextDelay( false ) );
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
} );
|
||||||
|
|
||||||
|
}( jQuery ) );
|
||||||
235
assets/js/dossier-edit.js
Normal file
235
assets/js/dossier-edit.js
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* PRAutoBlogger Article Dossier — edit + re-run actions (M3, v0.20.0).
|
||||||
|
*
|
||||||
|
* Chained-cron UX contract (CPO hard constraint): clicking a re-run
|
||||||
|
* button QUEUES a background job — the UI says "queued", starts the
|
||||||
|
* stage-status poll, and renders pickup/result as the rows change.
|
||||||
|
* Nothing here implies synchronous execution.
|
||||||
|
*
|
||||||
|
* Vanilla JS, no build step. Config injected via wp_localize_script
|
||||||
|
* ('prabDossierEdit': ajaxUrl, nonce, postId, pollInterval, strings).
|
||||||
|
*
|
||||||
|
* @see admin/class-dossier-actions.php — The endpoints this calls.
|
||||||
|
* @see admin/class-dossier-page.php — Enqueue + localization.
|
||||||
|
*/
|
||||||
|
( function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var cfg = window.prabDossierEdit || null;
|
||||||
|
|
||||||
|
function post( action, fields ) {
|
||||||
|
var data = new window.FormData();
|
||||||
|
data.append( 'action', action );
|
||||||
|
data.append( 'nonce', cfg.nonce );
|
||||||
|
data.append( 'post_id', cfg.postId );
|
||||||
|
Object.keys( fields || {} ).forEach( function ( key ) {
|
||||||
|
data.append( key, fields[ key ] );
|
||||||
|
} );
|
||||||
|
return window.fetch( cfg.ajaxUrl, { method: 'POST', credentials: 'same-origin', body: data } )
|
||||||
|
.then( function ( res ) { return res.json(); } );
|
||||||
|
}
|
||||||
|
|
||||||
|
function feedback( panel, message, isError ) {
|
||||||
|
var el = panel.querySelector( '[data-feedback]' );
|
||||||
|
if ( el ) {
|
||||||
|
el.textContent = message || '';
|
||||||
|
el.classList.toggle( 'prab-edit-feedback--error', !! isError );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionFor( el ) {
|
||||||
|
var node = el;
|
||||||
|
while ( node && ! node.classList.contains( 'prab-dossier-section--stage' ) ) {
|
||||||
|
node = node.parentElement;
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage-status polling (queued → pickup → result) ────────────────
|
||||||
|
var pollTimer = null;
|
||||||
|
var sawActivity = false;
|
||||||
|
|
||||||
|
function applyStageState( state ) {
|
||||||
|
var selector = '.prab-dossier-section--stage[data-stage="' + state.stage + '"][data-agent-role="' + state.agent_role + '"]';
|
||||||
|
var section = document.querySelector( selector );
|
||||||
|
if ( ! section ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var pill = section.querySelector( '.prab-stage-status-pill' );
|
||||||
|
if ( pill ) {
|
||||||
|
pill.className = 'prab-stage-status-pill prab-stage-status-pill--' + state.status;
|
||||||
|
pill.textContent = state.status.charAt( 0 ).toUpperCase() + state.status.slice( 1 );
|
||||||
|
}
|
||||||
|
var staleChip = section.querySelector( '[data-chip="stale"]' );
|
||||||
|
if ( staleChip ) {
|
||||||
|
staleChip.hidden = ! state.stale;
|
||||||
|
}
|
||||||
|
section.classList.toggle( 'prab-stage--stale', !! state.stale );
|
||||||
|
var humanChip = section.querySelector( '[data-chip="human"]' );
|
||||||
|
if ( humanChip ) {
|
||||||
|
humanChip.hidden = ! state.human_modified;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function poll() {
|
||||||
|
post( 'prautoblogger_dossier_stage_status', {} ).then( function ( res ) {
|
||||||
|
if ( ! res || ! res.success || ! res.data ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var anyRunning = false;
|
||||||
|
var anyStale = false;
|
||||||
|
var anyHuman = false;
|
||||||
|
( res.data.stages || [] ).forEach( function ( state ) {
|
||||||
|
applyStageState( state );
|
||||||
|
if ( 'running' === state.status || 'pending' === state.status ) {
|
||||||
|
anyRunning = true;
|
||||||
|
}
|
||||||
|
anyStale = anyStale || !! state.stale;
|
||||||
|
anyHuman = anyHuman || !! state.human_modified;
|
||||||
|
} );
|
||||||
|
var headerHuman = document.querySelector( '[data-header-chip="human"]' );
|
||||||
|
if ( headerHuman ) {
|
||||||
|
headerHuman.hidden = ! anyHuman;
|
||||||
|
}
|
||||||
|
var headerStale = document.querySelector( '[data-header-chip="stale"]' );
|
||||||
|
if ( headerStale ) {
|
||||||
|
headerStale.hidden = ! anyStale;
|
||||||
|
}
|
||||||
|
|
||||||
|
var terminal = -1 === [ 'running', 'pending' ].indexOf( res.data.run_status );
|
||||||
|
if ( anyRunning || ! terminal ) {
|
||||||
|
sawActivity = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Run settled after activity we watched: re-render the dossier
|
||||||
|
// fresh (server-rendered outputs, receipts, fork info).
|
||||||
|
if ( sawActivity ) {
|
||||||
|
window.clearInterval( pollTimer );
|
||||||
|
pollTimer = null;
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
} ).catch( function () { /* transient poll errors are ignored */ } );
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
sawActivity = true;
|
||||||
|
if ( ! pollTimer ) {
|
||||||
|
pollTimer = window.setInterval( poll, cfg.pollInterval * 1000 );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wiring ──────────────────────────────────────────────────────────
|
||||||
|
document.addEventListener( 'DOMContentLoaded', function () {
|
||||||
|
if ( ! cfg ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit panel toggles.
|
||||||
|
document.querySelectorAll( '.prab-edit-toggle' ).forEach( function ( btn ) {
|
||||||
|
btn.addEventListener( 'click', function () {
|
||||||
|
var panel = document.getElementById( btn.getAttribute( 'aria-controls' ) );
|
||||||
|
if ( ! panel ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var open = btn.getAttribute( 'aria-expanded' ) === 'true';
|
||||||
|
panel.hidden = open;
|
||||||
|
btn.setAttribute( 'aria-expanded', open ? 'false' : 'true' );
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
|
||||||
|
// Save fork.
|
||||||
|
document.querySelectorAll( '.prab-edit-save' ).forEach( function ( btn ) {
|
||||||
|
btn.addEventListener( 'click', function () {
|
||||||
|
var panel = btn.closest( '.prab-edit-panel' );
|
||||||
|
var messages = [];
|
||||||
|
panel.querySelectorAll( '.prab-edit-textarea' ).forEach( function ( area ) {
|
||||||
|
messages.push( { role: area.getAttribute( 'data-role' ), content: area.value } );
|
||||||
|
} );
|
||||||
|
btn.disabled = true;
|
||||||
|
feedback( panel, cfg.strings.saving, false );
|
||||||
|
post( 'prautoblogger_dossier_save_input', {
|
||||||
|
stage: panel.getAttribute( 'data-stage' ),
|
||||||
|
agent_role: panel.getAttribute( 'data-agent-role' ),
|
||||||
|
messages: JSON.stringify( messages )
|
||||||
|
} ).then( function ( res ) {
|
||||||
|
btn.disabled = false;
|
||||||
|
if ( res && res.success ) {
|
||||||
|
feedback( panel, res.data.message, false );
|
||||||
|
var rerunBtn = panel.querySelector( '.prab-edit-rerun' );
|
||||||
|
if ( rerunBtn ) {
|
||||||
|
rerunBtn.disabled = false;
|
||||||
|
}
|
||||||
|
var info = panel.querySelector( '[data-forkinfo]' );
|
||||||
|
if ( info && res.data.version ) {
|
||||||
|
info.textContent = 'v' + res.data.version;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
feedback( panel, ( res && res.data && res.data.message ) || cfg.strings.error, true );
|
||||||
|
}
|
||||||
|
} ).catch( function () {
|
||||||
|
btn.disabled = false;
|
||||||
|
feedback( panel, cfg.strings.error, true );
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
|
||||||
|
// Replay latest fork (queued).
|
||||||
|
document.querySelectorAll( '.prab-edit-rerun' ).forEach( function ( btn ) {
|
||||||
|
btn.addEventListener( 'click', function () {
|
||||||
|
var panel = btn.closest( '.prab-edit-panel' );
|
||||||
|
if ( ! window.confirm( cfg.strings.confirmReplay ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btn.disabled = true;
|
||||||
|
post( 'prautoblogger_dossier_rerun_stage', {
|
||||||
|
stage: panel.getAttribute( 'data-stage' ),
|
||||||
|
agent_role: panel.getAttribute( 'data-agent-role' )
|
||||||
|
} ).then( function ( res ) {
|
||||||
|
if ( res && res.success ) {
|
||||||
|
feedback( panel, res.data.message, false );
|
||||||
|
startPolling();
|
||||||
|
} else {
|
||||||
|
btn.disabled = false;
|
||||||
|
feedback( panel, ( res && res.data && res.data.message ) || cfg.strings.error, true );
|
||||||
|
}
|
||||||
|
} ).catch( function () {
|
||||||
|
btn.disabled = false;
|
||||||
|
feedback( panel, cfg.strings.error, true );
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
|
||||||
|
// Re-run from here (queued rebuild).
|
||||||
|
document.querySelectorAll( '.prab-rerun-from' ).forEach( function ( btn ) {
|
||||||
|
btn.addEventListener( 'click', function () {
|
||||||
|
var section = sectionFor( btn );
|
||||||
|
if ( ! window.confirm( cfg.strings.confirmRerunFrom ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = cfg.strings.queued;
|
||||||
|
post( 'prautoblogger_dossier_rerun_from', {
|
||||||
|
stage: btn.getAttribute( 'data-stage' ),
|
||||||
|
agent_role: section ? section.getAttribute( 'data-agent-role' ) : ''
|
||||||
|
} ).then( function ( res ) {
|
||||||
|
if ( res && res.success ) {
|
||||||
|
startPolling();
|
||||||
|
} else {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = cfg.strings.rerunFromLabel;
|
||||||
|
window.alert( ( res && res.data && res.data.message ) || cfg.strings.error );
|
||||||
|
}
|
||||||
|
} ).catch( function () {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = cfg.strings.rerunFromLabel;
|
||||||
|
window.alert( cfg.strings.error );
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
|
||||||
|
// A stage already running (e.g. revisit mid-job): poll from load.
|
||||||
|
if ( document.querySelector( '.prab-stage-status-pill--running' ) ) {
|
||||||
|
startPolling();
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
}() );
|
||||||
41
assets/js/dossier.js
Normal file
41
assets/js/dossier.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* PRAutoBlogger Article Dossier -- raw-trace toggle (M2).
|
||||||
|
*
|
||||||
|
* Toggles the hidden raw-trace <div> per stage when the "Show raw trace"
|
||||||
|
* button is clicked. Vanilla JS, no build step, no external deps.
|
||||||
|
*
|
||||||
|
* Accessible: uses aria-expanded + aria-controls; respects the hidden attribute.
|
||||||
|
*
|
||||||
|
* @see admin/class-dossier-page.php -- Enqueues this file.
|
||||||
|
* @see templates/admin/dossier-page.php -- Renders the toggle buttons.
|
||||||
|
*/
|
||||||
|
( function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
document.addEventListener( 'DOMContentLoaded', function () {
|
||||||
|
var toggles = document.querySelectorAll( '.prab-trace-toggle' );
|
||||||
|
|
||||||
|
toggles.forEach( function ( btn ) {
|
||||||
|
btn.addEventListener( 'click', function () {
|
||||||
|
var targetId = btn.getAttribute( 'aria-controls' );
|
||||||
|
if ( ! targetId ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var panel = document.getElementById( targetId );
|
||||||
|
if ( ! panel ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var isOpen = btn.getAttribute( 'aria-expanded' ) === 'true';
|
||||||
|
if ( isOpen ) {
|
||||||
|
panel.hidden = true;
|
||||||
|
btn.setAttribute( 'aria-expanded', 'false' );
|
||||||
|
btn.textContent = btn.textContent.replace( /hide/i, 'Show' );
|
||||||
|
} else {
|
||||||
|
panel.hidden = false;
|
||||||
|
btn.setAttribute( 'aria-expanded', 'true' );
|
||||||
|
btn.textContent = btn.textContent.replace( /show/i, 'Hide' );
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
}() );
|
||||||
157
assets/js/ideas-browser.js
Normal file
157
assets/js/ideas-browser.js
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* PRAutoBlogger Ideas Browser — "Generate Article" button handler.
|
||||||
|
*
|
||||||
|
* Each idea row has a Generate button that triggers single-article generation
|
||||||
|
* via AJAX → WP-Cron background job. Polls for status and shows stage updates
|
||||||
|
* in the button cell, then swaps to a "View" link on completion.
|
||||||
|
*
|
||||||
|
* @see admin/class-ideas-browser.php — AJAX handlers.
|
||||||
|
* @see templates/admin/ideas-browser.php — Button markup.
|
||||||
|
*/
|
||||||
|
(function ($) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var config = window.prautobloggerAdmin || {};
|
||||||
|
var pollTimers = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kick off generation for an idea and start polling.
|
||||||
|
*
|
||||||
|
* @param {jQuery} $cell The table cell containing the button.
|
||||||
|
* @param {number} ideaId Analysis result row ID.
|
||||||
|
*/
|
||||||
|
function generateFromIdea($cell, ideaId) {
|
||||||
|
$cell.html(
|
||||||
|
'<span class="dashicons dashicons-update ab-spin"></span> ' +
|
||||||
|
'<span class="prab-idea-stage">Starting…</span>'
|
||||||
|
);
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: config.ajaxUrl,
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
action: 'prautoblogger_generate_from_idea',
|
||||||
|
nonce: config.ideaGenNonce,
|
||||||
|
idea_id: ideaId
|
||||||
|
},
|
||||||
|
timeout: 15000,
|
||||||
|
success: function (response) {
|
||||||
|
if (response.success) {
|
||||||
|
pollIdeaStatus($cell, ideaId);
|
||||||
|
} else {
|
||||||
|
showIdeaError($cell, ideaId, response.data && response.data.message || 'Failed to start.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function () {
|
||||||
|
showIdeaError($cell, ideaId, 'Request failed.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll the per-idea status endpoint until generation completes.
|
||||||
|
*
|
||||||
|
* @param {jQuery} $cell The table cell to update.
|
||||||
|
* @param {number} ideaId Analysis result row ID.
|
||||||
|
*/
|
||||||
|
function pollIdeaStatus($cell, ideaId) {
|
||||||
|
pollTimers[ideaId] = setInterval(function () {
|
||||||
|
$.ajax({
|
||||||
|
url: config.ajaxUrl,
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
action: 'prautoblogger_idea_gen_status',
|
||||||
|
nonce: config.ideaGenNonce,
|
||||||
|
idea_id: ideaId
|
||||||
|
},
|
||||||
|
timeout: 10000,
|
||||||
|
success: function (response) {
|
||||||
|
if (!response.success) return;
|
||||||
|
var d = response.data;
|
||||||
|
|
||||||
|
if (d.status === 'running') {
|
||||||
|
$cell.find('.prab-idea-stage').text(d.stage || 'Generating…');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminal — stop polling.
|
||||||
|
clearInterval(pollTimers[ideaId]);
|
||||||
|
delete pollTimers[ideaId];
|
||||||
|
|
||||||
|
if (d.status === 'complete') {
|
||||||
|
showIdeaComplete($cell, ideaId, d);
|
||||||
|
} else if (d.status === 'error') {
|
||||||
|
showIdeaError($cell, ideaId, d.message || 'Generation failed.');
|
||||||
|
} else {
|
||||||
|
showIdeaError($cell, ideaId, 'Unknown status.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the completion state with a View link.
|
||||||
|
*
|
||||||
|
* @param {jQuery} $cell The table cell.
|
||||||
|
* @param {number} ideaId Analysis result row ID.
|
||||||
|
* @param {Object} data Server response data.
|
||||||
|
*/
|
||||||
|
function showIdeaComplete($cell, ideaId, data) {
|
||||||
|
var html = '<span class="dashicons dashicons-yes-alt" style="color:#00a32a;"></span> ';
|
||||||
|
html += '<span style="color:#00a32a;">Done</span>';
|
||||||
|
|
||||||
|
if (data.post_id) {
|
||||||
|
var editUrl = config.adminUrl + 'post.php?post=' + data.post_id + '&action=edit';
|
||||||
|
var viewUrl = config.siteUrl + '?p=' + data.post_id;
|
||||||
|
html += ' — <a href="' + editUrl + '">Edit</a>';
|
||||||
|
html += ' | <a href="' + viewUrl + '" target="_blank">View</a>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.cost > 0) {
|
||||||
|
html += '<div style="font-size:11px;color:#666;margin-top:2px;">$' + parseFloat(data.cost).toFixed(4) + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$cell.html(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show an error state with a retry button.
|
||||||
|
*
|
||||||
|
* @param {jQuery} $cell The table cell.
|
||||||
|
* @param {number} ideaId Analysis result row ID.
|
||||||
|
* @param {string} message Error message.
|
||||||
|
*/
|
||||||
|
function showIdeaError($cell, ideaId, message) {
|
||||||
|
$cell.html(
|
||||||
|
'<span class="dashicons dashicons-warning" style="color:#d63638;"></span> ' +
|
||||||
|
'<span style="color:#d63638; font-size:12px;">' + $('<span>').text(message).html() + '</span>' +
|
||||||
|
'<br><button type="button" class="button button-small prab-gen-idea-btn" data-idea-id="' + ideaId + '" style="margin-top:4px;">Retry</button>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click handler for Generate / Retry buttons (event delegation).
|
||||||
|
$(document).on('click', '.prab-gen-idea-btn', function () {
|
||||||
|
var $btn = $(this);
|
||||||
|
if ($btn.prop('disabled')) return;
|
||||||
|
|
||||||
|
var ideaId = $btn.data('idea-id');
|
||||||
|
var $cell = $btn.closest('td');
|
||||||
|
|
||||||
|
generateFromIdea($cell, ideaId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// On page load, resume polling for any ideas that are mid-generation.
|
||||||
|
$(function () {
|
||||||
|
$('.prab-idea-gen-cell[data-status="running"]').each(function () {
|
||||||
|
var $cell = $(this);
|
||||||
|
var ideaId = $cell.data('idea-id');
|
||||||
|
$cell.html(
|
||||||
|
'<span class="dashicons dashicons-update ab-spin"></span> ' +
|
||||||
|
'<span class="prab-idea-stage">Resuming…</span>'
|
||||||
|
);
|
||||||
|
pollIdeaStatus($cell, ideaId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
})(jQuery);
|
||||||
363
assets/js/model-picker.js
Normal file
363
assets/js/model-picker.js
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
/**
|
||||||
|
* Model picker popup for PRAutoBlogger admin settings.
|
||||||
|
*
|
||||||
|
* Fetches the OpenRouter model registry via AJAX, then renders a searchable,
|
||||||
|
* sortable popup overlay when the user clicks a model-select trigger button.
|
||||||
|
* Columns: Model, Price (in / out per 1M tokens), Context window.
|
||||||
|
* Each column header is clickable to sort ascending/descending.
|
||||||
|
*
|
||||||
|
* @see admin/class-admin-page.php — Renders model_select fields and localizes config.
|
||||||
|
* @see services/class-open-router-model-registry.php — Server-side registry source.
|
||||||
|
*/
|
||||||
|
(function ($) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var config = window.prautobloggerAdmin || {};
|
||||||
|
var cachedModels = null;
|
||||||
|
var fetchInProgress = null;
|
||||||
|
|
||||||
|
// Persists across open/close so the user's preferred sort is remembered.
|
||||||
|
var sortState = { col: 'name', dir: 'asc' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch models from the server (cached after first call).
|
||||||
|
*
|
||||||
|
* @param {string} capability Filter capability (e.g. 'text→text').
|
||||||
|
* @return {jQuery.Deferred} Resolves with model array.
|
||||||
|
*/
|
||||||
|
function fetchModels( capability ) {
|
||||||
|
// Image models are embedded in the page config — no AJAX needed.
|
||||||
|
if ( capability === 'image_generation' ) {
|
||||||
|
var imgModels = config.imageModels || [];
|
||||||
|
return $.Deferred().resolve( imgModels );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( cachedModels ) {
|
||||||
|
return $.Deferred().resolve( filterModels( cachedModels, capability ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( fetchInProgress ) {
|
||||||
|
return fetchInProgress.then( function () {
|
||||||
|
return filterModels( cachedModels, capability );
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchInProgress = $.ajax( {
|
||||||
|
url: config.ajaxUrl,
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
action: 'prautoblogger_get_models',
|
||||||
|
nonce: config.modelsNonce
|
||||||
|
},
|
||||||
|
timeout: 15000
|
||||||
|
} ).then( function ( response ) {
|
||||||
|
fetchInProgress = null;
|
||||||
|
if ( response.success && Array.isArray( response.data ) ) {
|
||||||
|
cachedModels = response.data;
|
||||||
|
return filterModels( cachedModels, capability );
|
||||||
|
}
|
||||||
|
cachedModels = [];
|
||||||
|
return [];
|
||||||
|
}, function () {
|
||||||
|
fetchInProgress = null;
|
||||||
|
cachedModels = [];
|
||||||
|
return [];
|
||||||
|
} );
|
||||||
|
|
||||||
|
return fetchInProgress;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter model array by capability.
|
||||||
|
*
|
||||||
|
* @param {Array} models Full model list.
|
||||||
|
* @param {string} capability Capability string to match.
|
||||||
|
* @return {Array} Filtered models.
|
||||||
|
*/
|
||||||
|
function filterModels( models, capability ) {
|
||||||
|
if ( ! capability ) return models;
|
||||||
|
return models.filter( function ( m ) {
|
||||||
|
return m.capabilities && m.capabilities.indexOf( capability ) !== -1;
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort a model array according to the current sortState.
|
||||||
|
* Deprecated models always sink to the bottom regardless of column.
|
||||||
|
*
|
||||||
|
* @param {Array} arr Model array to sort (not mutated — returns a new array).
|
||||||
|
* @return {Array} Sorted copy.
|
||||||
|
*/
|
||||||
|
function applySort( arr ) {
|
||||||
|
var col = sortState.col;
|
||||||
|
var dir = sortState.dir;
|
||||||
|
|
||||||
|
return arr.slice().sort( function ( a, b ) {
|
||||||
|
// Deprecated always last.
|
||||||
|
if ( a.deprecated !== b.deprecated ) {
|
||||||
|
return a.deprecated ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var valA, valB;
|
||||||
|
|
||||||
|
if ( col === 'price' ) {
|
||||||
|
// Image models use cost_per_image; text models use input_price_per_m.
|
||||||
|
valA = ( a.cost_per_image !== undefined ) ? a.cost_per_image : ( a.input_price_per_m || 0 );
|
||||||
|
valB = ( b.cost_per_image !== undefined ) ? b.cost_per_image : ( b.input_price_per_m || 0 );
|
||||||
|
} else if ( col === 'context' ) {
|
||||||
|
valA = a.context_length || 0;
|
||||||
|
valB = b.context_length || 0;
|
||||||
|
} else {
|
||||||
|
// Default: sort by model name.
|
||||||
|
valA = ( a.name || '' ).toLowerCase();
|
||||||
|
valB = ( b.name || '' ).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( valA < valB ) return dir === 'asc' ? -1 : 1;
|
||||||
|
if ( valA > valB ) return dir === 'asc' ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a price-per-million-tokens value for display.
|
||||||
|
*
|
||||||
|
* @param {number} price Price per 1M tokens.
|
||||||
|
* @return {string} Formatted string like "$0.25" or "Free".
|
||||||
|
*/
|
||||||
|
function formatPrice( price ) {
|
||||||
|
if ( ! price || price <= 0 ) return 'Free';
|
||||||
|
if ( price < 0.01 ) return '$' + price.toFixed( 4 );
|
||||||
|
if ( price < 1 ) return '$' + price.toFixed( 2 );
|
||||||
|
return '$' + price.toFixed( 2 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format context length for display.
|
||||||
|
*
|
||||||
|
* @param {number} ctx Context length in tokens.
|
||||||
|
* @return {string} Formatted string like "128K" or "1M".
|
||||||
|
*/
|
||||||
|
function formatContext( ctx ) {
|
||||||
|
if ( ! ctx ) return '—';
|
||||||
|
if ( ctx >= 1000000 ) return ( ctx / 1000000 ).toFixed( 0 ) + 'M';
|
||||||
|
if ( ctx >= 1000 ) return Math.round( ctx / 1000 ) + 'K';
|
||||||
|
return ctx.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build HTML for a single model row in the picker list.
|
||||||
|
*
|
||||||
|
* @param {Object} model Normalized model record.
|
||||||
|
* @return {string} HTML string.
|
||||||
|
*/
|
||||||
|
function renderModelRow( model ) {
|
||||||
|
var deprecated = model.deprecated ? ' ab-mp-deprecated' : '';
|
||||||
|
var isImageModel = model.cost_per_image !== undefined && model.cost_per_image > 0;
|
||||||
|
var priceHtml, priceTitle, metaRight;
|
||||||
|
|
||||||
|
if ( isImageModel ) {
|
||||||
|
priceHtml = '$' + model.cost_per_image.toFixed( 4 ) + '/image';
|
||||||
|
priceTitle = 'Cost per generated image';
|
||||||
|
metaRight = model.description
|
||||||
|
? '<span class="ab-mp-ctx" title="Details">' + escHtml( model.description ) + '</span>'
|
||||||
|
: '';
|
||||||
|
} else {
|
||||||
|
priceHtml = formatPrice( model.input_price_per_m ) + ' / ' + formatPrice( model.output_price_per_m );
|
||||||
|
priceTitle = 'Input / Output per 1M tokens';
|
||||||
|
metaRight = '<span class="ab-mp-ctx" title="Context window">' + formatContext( model.context_length ) + '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
'<div class="ab-mp-row' + deprecated + '" data-model-id="' + escAttr( model.id ) + '">' +
|
||||||
|
'<div class="ab-mp-row-main">' +
|
||||||
|
'<span class="ab-mp-name">' + escHtml( model.name ) + '</span>' +
|
||||||
|
'<span class="ab-mp-provider">' + escHtml( model.provider ) + '</span>' +
|
||||||
|
( model.deprecated ? '<span class="ab-mp-dep-badge">deprecated</span>' : '' ) +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="ab-mp-row-meta">' +
|
||||||
|
metaRight +
|
||||||
|
'<span class="ab-mp-price" title="' + priceTitle + '">' + priceHtml + '</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal HTML escaping for display. */
|
||||||
|
function escHtml( str ) {
|
||||||
|
if ( ! str ) return '';
|
||||||
|
return str.replace( /&/g, '&' ).replace( /</g, '<' ).replace( />/g, '>' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escape a string for use in an HTML attribute. */
|
||||||
|
function escAttr( str ) {
|
||||||
|
return escHtml( str ).replace( /"/g, '"' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the model picker popup for a given trigger button.
|
||||||
|
*
|
||||||
|
* @param {jQuery} $trigger The .ab-mp-trigger button that was clicked.
|
||||||
|
*/
|
||||||
|
function openPicker( $trigger ) {
|
||||||
|
var fieldId = $trigger.data( 'field-id' );
|
||||||
|
var capability = $trigger.data( 'capability' ) || '';
|
||||||
|
|
||||||
|
var isImageCap = capability === 'image_generation';
|
||||||
|
var priceLabel = isImageCap ? 'Cost per generated image' : 'Prices per 1M tokens (in / out)';
|
||||||
|
|
||||||
|
// ── Build overlay + popup ───────────────────────────────────────
|
||||||
|
var $overlay = $( '<div class="ab-mp-overlay"></div>' );
|
||||||
|
var $popup = $(
|
||||||
|
'<div class="ab-mp-popup">' +
|
||||||
|
'<div class="ab-mp-header">' +
|
||||||
|
'<span class="ab-mp-title">Select Model</span>' +
|
||||||
|
'<button type="button" class="ab-mp-close">×</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="ab-mp-search-wrap">' +
|
||||||
|
'<input type="text" class="ab-mp-search" placeholder="Search models…" autocomplete="off" />' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="ab-mp-col-headers">' +
|
||||||
|
'<button type="button" class="ab-mp-col-header ab-mp-col-model" data-col="name">Model <span class="ab-mp-sort-arrow"></span></button>' +
|
||||||
|
'<div class="ab-mp-col-headers-meta">' +
|
||||||
|
'<button type="button" class="ab-mp-col-header ab-mp-col-ctx" data-col="context">Context <span class="ab-mp-sort-arrow"></span></button>' +
|
||||||
|
'<button type="button" class="ab-mp-col-header ab-mp-col-price" data-col="price">Price (in / out) <span class="ab-mp-sort-arrow"></span></button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="ab-mp-list"><div class="ab-mp-loading">Loading models…</div></div>' +
|
||||||
|
'<div class="ab-mp-footer">' +
|
||||||
|
'<span class="ab-mp-count"></span>' +
|
||||||
|
'<span class="ab-mp-price-label">' + priceLabel + '</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
|
||||||
|
$overlay.append( $popup );
|
||||||
|
$( 'body' ).append( $overlay );
|
||||||
|
|
||||||
|
var $search = $popup.find( '.ab-mp-search' );
|
||||||
|
var $list = $popup.find( '.ab-mp-list' );
|
||||||
|
var $count = $popup.find( '.ab-mp-count' );
|
||||||
|
var $colHeaders = $popup.find( '.ab-mp-col-headers' );
|
||||||
|
var allModels = [];
|
||||||
|
var currentQuery = '';
|
||||||
|
|
||||||
|
// Focus search after render.
|
||||||
|
setTimeout( function () { $search.focus(); }, 50 );
|
||||||
|
|
||||||
|
// ── Sort header indicators ──────────────────────────────────────
|
||||||
|
/** Update active-column highlight and sort arrow glyphs. */
|
||||||
|
function updateSortHeaders() {
|
||||||
|
$colHeaders.find( '.ab-mp-col-header' ).each( function () {
|
||||||
|
var col = $( this ).data( 'col' );
|
||||||
|
var $arrow = $( this ).find( '.ab-mp-sort-arrow' );
|
||||||
|
if ( col === sortState.col ) {
|
||||||
|
$( this ).addClass( 'ab-mp-col-active' );
|
||||||
|
$arrow.text( sortState.dir === 'asc' ? ' ▲' : ' ▼' );
|
||||||
|
} else {
|
||||||
|
$( this ).removeClass( 'ab-mp-col-active' );
|
||||||
|
$arrow.text( ' ⇅' );
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render ──────────────────────────────────────────────────────
|
||||||
|
/** Render filtered + sorted model list. */
|
||||||
|
function renderList( models, query ) {
|
||||||
|
var filtered = models;
|
||||||
|
if ( query ) {
|
||||||
|
var q = query.toLowerCase();
|
||||||
|
filtered = models.filter( function ( m ) {
|
||||||
|
return ( m.name && m.name.toLowerCase().indexOf( q ) !== -1 ) ||
|
||||||
|
( m.id && m.id.toLowerCase().indexOf( q ) !== -1 ) ||
|
||||||
|
( m.provider && m.provider.toLowerCase().indexOf( q ) !== -1 );
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered = applySort( filtered );
|
||||||
|
|
||||||
|
if ( filtered.length === 0 ) {
|
||||||
|
$list.html( '<div class="ab-mp-empty">No models found.</div>' );
|
||||||
|
} else {
|
||||||
|
var html = '';
|
||||||
|
for ( var i = 0; i < filtered.length; i++ ) {
|
||||||
|
html += renderModelRow( filtered[ i ] );
|
||||||
|
}
|
||||||
|
$list.html( html );
|
||||||
|
}
|
||||||
|
$count.text( filtered.length + ' model' + ( filtered.length !== 1 ? 's' : '' ) );
|
||||||
|
updateSortHeaders();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial render after data loads.
|
||||||
|
fetchModels( capability ).then( function ( models ) {
|
||||||
|
allModels = models;
|
||||||
|
renderList( allModels, currentQuery );
|
||||||
|
} );
|
||||||
|
|
||||||
|
// ── Column header sort clicks ───────────────────────────────────
|
||||||
|
$colHeaders.on( 'click', '.ab-mp-col-header', function () {
|
||||||
|
var col = $( this ).data( 'col' );
|
||||||
|
if ( sortState.col === col ) {
|
||||||
|
sortState.dir = sortState.dir === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
sortState.col = col;
|
||||||
|
sortState.dir = 'asc';
|
||||||
|
}
|
||||||
|
renderList( allModels, currentQuery );
|
||||||
|
} );
|
||||||
|
|
||||||
|
// ── Search (debounced) ──────────────────────────────────────────
|
||||||
|
var searchTimer = null;
|
||||||
|
$search.on( 'input', function () {
|
||||||
|
currentQuery = $( this ).val();
|
||||||
|
clearTimeout( searchTimer );
|
||||||
|
searchTimer = setTimeout( function () {
|
||||||
|
renderList( allModels, currentQuery );
|
||||||
|
}, 150 );
|
||||||
|
} );
|
||||||
|
|
||||||
|
// ── Select a model ──────────────────────────────────────────────
|
||||||
|
$list.on( 'click', '.ab-mp-row', function () {
|
||||||
|
var id = $( this ).data( 'model-id' );
|
||||||
|
var model = null;
|
||||||
|
for ( var i = 0; i < allModels.length; i++ ) {
|
||||||
|
if ( allModels[ i ].id === id ) { model = allModels[ i ]; break; }
|
||||||
|
}
|
||||||
|
if ( ! model ) return;
|
||||||
|
|
||||||
|
$( '#' + fieldId ).val( model.id );
|
||||||
|
$trigger.find( '.ab-mp-display-name' ).text( model.name || model.id );
|
||||||
|
|
||||||
|
var isImageModel = model.cost_per_image !== undefined && model.cost_per_image > 0;
|
||||||
|
var priceText = isImageModel
|
||||||
|
? '$' + model.cost_per_image.toFixed( 4 ) + '/image'
|
||||||
|
: formatPrice( model.input_price_per_m ) + ' / ' + formatPrice( model.output_price_per_m );
|
||||||
|
$trigger.find( '.ab-mp-display-price' ).text( priceText );
|
||||||
|
closePicker();
|
||||||
|
} );
|
||||||
|
|
||||||
|
// ── Close ───────────────────────────────────────────────────────
|
||||||
|
function closePicker() {
|
||||||
|
$overlay.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
$popup.find( '.ab-mp-close' ).on( 'click', closePicker );
|
||||||
|
$overlay.on( 'click', function ( e ) {
|
||||||
|
if ( $( e.target ).is( '.ab-mp-overlay' ) ) closePicker();
|
||||||
|
} );
|
||||||
|
$( document ).on( 'keydown.abModelPicker', function ( e ) {
|
||||||
|
if ( e.key === 'Escape' ) {
|
||||||
|
closePicker();
|
||||||
|
$( document ).off( 'keydown.abModelPicker' );
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind click on all model picker triggers.
|
||||||
|
$( document ).on( 'click', '.ab-mp-trigger', function ( e ) {
|
||||||
|
e.preventDefault();
|
||||||
|
openPicker( $( this ) );
|
||||||
|
} );
|
||||||
|
|
||||||
|
} )( jQuery );
|
||||||
@@ -8,7 +8,6 @@
|
|||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"phpunit/phpunit": "^9.5",
|
"phpunit/phpunit": "^9.5",
|
||||||
"wp-phpunit/wp-phpunit": "^6.0",
|
|
||||||
"brain/monkey": "^2.6"
|
"brain/monkey": "^2.6"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
@@ -20,5 +19,8 @@
|
|||||||
"psr-4": {
|
"psr-4": {
|
||||||
"PRAutoBlogger\\Tests\\": "tests/unit/"
|
"PRAutoBlogger\\Tests\\": "tests/unit/"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "vendor/bin/phpunit --testdox"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
142
eval/dataset.json
Normal file
142
eval/dataset.json
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "bpc157-healing",
|
||||||
|
"topic": "BPC-157 healing protocols for tendons and gut lining",
|
||||||
|
"expected_keywords": ["BPC-157", "tendon", "gut", "healing"],
|
||||||
|
"min_words": 700,
|
||||||
|
"style": "comic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tb500-muscle-recovery",
|
||||||
|
"topic": "TB-500 for accelerated muscle recovery and repair",
|
||||||
|
"expected_keywords": ["TB-500", "muscle", "recovery", "growth"],
|
||||||
|
"min_words": 700,
|
||||||
|
"style": "comic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ghk-cu-skin-collagen",
|
||||||
|
"topic": "GHK-Cu peptide for skin collagen synthesis and anti-aging",
|
||||||
|
"expected_keywords": ["GHK-Cu", "collagen", "skin", "aging"],
|
||||||
|
"min_words": 700,
|
||||||
|
"style": "comic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "semaglutide-glp1-weight",
|
||||||
|
"topic": "GLP-1 receptor agonists like Semaglutide for metabolic health",
|
||||||
|
"expected_keywords": ["Semaglutide", "GLP-1", "weight", "metabolism"],
|
||||||
|
"min_words": 700,
|
||||||
|
"style": "comic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ipamorelin-growth-hormone",
|
||||||
|
"topic": "Ipamorelin for natural growth hormone stimulation and recovery",
|
||||||
|
"expected_keywords": ["Ipamorelin", "growth hormone", "HGH", "secretagogue"],
|
||||||
|
"min_words": 700,
|
||||||
|
"style": "comic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hexarelin-strength-gains",
|
||||||
|
"topic": "Hexarelin peptide for lean muscle and strength improvement",
|
||||||
|
"expected_keywords": ["Hexarelin", "muscle", "strength", "lean"],
|
||||||
|
"min_words": 700,
|
||||||
|
"style": "comic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "semax-cognitive-enhancement",
|
||||||
|
"topic": "Semax nootropic peptide for cognitive function and focus",
|
||||||
|
"expected_keywords": ["Semax", "cognition", "focus", "neuroprotection"],
|
||||||
|
"min_words": 700,
|
||||||
|
"style": "comic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "selank-anxiety-stress",
|
||||||
|
"topic": "Selank peptide for anxiety reduction and stress management",
|
||||||
|
"expected_keywords": ["Selank", "anxiety", "stress", "mood"],
|
||||||
|
"min_words": 700,
|
||||||
|
"style": "comic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "oxytocin-social-bonding",
|
||||||
|
"topic": "Oxytocin peptide hormone and its role in social connection",
|
||||||
|
"expected_keywords": ["Oxytocin", "social", "bonding", "neuropeptide"],
|
||||||
|
"min_words": 700,
|
||||||
|
"style": "comic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "kisspeptin-fertility-hormone",
|
||||||
|
"topic": "Kisspeptin's role in reproductive health and hormone signaling",
|
||||||
|
"expected_keywords": ["Kisspeptin", "fertility", "hormone", "reproduction"],
|
||||||
|
"min_words": 700,
|
||||||
|
"style": "comic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "melanotan-tanning-peptide",
|
||||||
|
"topic": "Melanotan peptide for pigmentation and UV protection",
|
||||||
|
"expected_keywords": ["Melanotan", "melanin", "tanning", "skin"],
|
||||||
|
"min_words": 700,
|
||||||
|
"style": "comic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cerebrolysin-neuroprotection",
|
||||||
|
"topic": "Cerebrolysin for brain health and neuroprotection",
|
||||||
|
"expected_keywords": ["Cerebrolysin", "brain", "neuroprotection", "cognition"],
|
||||||
|
"min_words": 700,
|
||||||
|
"style": "comic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "epithalon-telomerase-aging",
|
||||||
|
"topic": "Epithalon peptide and telomerase activation for longevity",
|
||||||
|
"expected_keywords": ["Epithalon", "telomerase", "aging", "longevity"],
|
||||||
|
"min_words": 700,
|
||||||
|
"style": "comic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "thymosin-immune-function",
|
||||||
|
"topic": "Thymosin alpha-1 for immune system enhancement",
|
||||||
|
"expected_keywords": ["Thymosin", "immune", "T-cells", "immunity"],
|
||||||
|
"min_words": 700,
|
||||||
|
"style": "comic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "igf1-muscle-growth-aging",
|
||||||
|
"topic": "IGF-1 peptide for muscle growth, recovery, and anti-aging",
|
||||||
|
"expected_keywords": ["IGF-1", "muscle", "growth", "aging"],
|
||||||
|
"min_words": 700,
|
||||||
|
"style": "comic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mechano-growth-factor",
|
||||||
|
"topic": "MGF (Mechano Growth Factor) for muscle hypertrophy",
|
||||||
|
"expected_keywords": ["MGF", "mechano", "muscle", "hypertrophy"],
|
||||||
|
"min_words": 700,
|
||||||
|
"style": "comic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ll37-antimicrobial-peptide",
|
||||||
|
"topic": "LL-37 antimicrobial peptide for immune and skin health",
|
||||||
|
"expected_keywords": ["LL-37", "antimicrobial", "immune", "peptide"],
|
||||||
|
"min_words": 700,
|
||||||
|
"style": "comic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "lactoferrin-iron-immunity",
|
||||||
|
"topic": "Lactoferrin peptide for iron absorption and immune support",
|
||||||
|
"expected_keywords": ["Lactoferrin", "iron", "immune", "absorption"],
|
||||||
|
"min_words": 700,
|
||||||
|
"style": "comic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "collagen-peptides-joints",
|
||||||
|
"topic": "Collagen peptides for joint health and connective tissue repair",
|
||||||
|
"expected_keywords": ["Collagen", "peptides", "joints", "connective tissue"],
|
||||||
|
"min_words": 700,
|
||||||
|
"style": "comic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ckgp-cognitive-longevity",
|
||||||
|
"topic": "Cognitive peptides and nootropic stacks for brain longevity",
|
||||||
|
"expected_keywords": ["peptide", "cognitive", "nootropic", "brain"],
|
||||||
|
"min_words": 700,
|
||||||
|
"style": "comic"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
* Displays admin notices for onboarding, errors, and budget warnings.
|
* Displays admin notices for onboarding, errors, and budget warnings.
|
||||||
*
|
*
|
||||||
* Triggered by: PRAutoBlogger::register_admin_hooks() on `admin_notices`.
|
* Triggered by: PRAutoBlogger::register_admin_hooks() on `admin_notices`.
|
||||||
@@ -62,11 +64,13 @@ class PRAutoBlogger_Admin_Notices {
|
|||||||
} elseif ( $utilization >= 80.0 ) {
|
} elseif ( $utilization >= 80.0 ) {
|
||||||
printf(
|
printf(
|
||||||
'<div class="notice notice-warning"><p>%s</p></div>',
|
'<div class="notice notice-warning"><p>%s</p></div>',
|
||||||
esc_html( sprintf(
|
esc_html(
|
||||||
|
sprintf(
|
||||||
/* translators: %s: budget utilization percentage */
|
/* translators: %s: budget utilization percentage */
|
||||||
__( 'PRAutoBlogger: Monthly API budget is at %.0f%%. Consider increasing the budget or reducing daily article count.', 'prautoblogger' ),
|
__( 'PRAutoBlogger: Monthly API budget is at %.0f%%. Consider increasing the budget or reducing daily article count.', 'prautoblogger' ),
|
||||||
$utilization
|
$utilization
|
||||||
) )
|
)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
233
includes/admin/class-admin-page.php
Executable file → Normal file
233
includes/admin/class-admin-page.php
Executable file → Normal file
@@ -2,6 +2,8 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
* Registers and renders the main PRAutoBlogger settings page in wp-admin.
|
* Registers and renders the main PRAutoBlogger settings page in wp-admin.
|
||||||
*
|
*
|
||||||
* Uses the WordPress Settings API for option registration, sanitization, and rendering.
|
* Uses the WordPress Settings API for option registration, sanitization, and rendering.
|
||||||
@@ -26,7 +28,7 @@ class PRAutoBlogger_Admin_Page {
|
|||||||
__( 'PRAutoBlogger', 'prautoblogger' ),
|
__( 'PRAutoBlogger', 'prautoblogger' ),
|
||||||
'manage_options',
|
'manage_options',
|
||||||
self::PAGE_SLUG,
|
self::PAGE_SLUG,
|
||||||
[ $this, 'render_page' ],
|
array( $this, 'render_page' ),
|
||||||
'dashicons-edit-page',
|
'dashicons-edit-page',
|
||||||
30
|
30
|
||||||
);
|
);
|
||||||
@@ -43,40 +45,66 @@ class PRAutoBlogger_Admin_Page {
|
|||||||
|
|
||||||
foreach ( $fields as $field ) {
|
foreach ( $fields as $field ) {
|
||||||
$section = $field['section'] ?? 'prautoblogger_api';
|
$section = $field['section'] ?? 'prautoblogger_api';
|
||||||
register_setting( self::OPTION_GROUP, $field['id'], [
|
register_setting(
|
||||||
|
self::OPTION_GROUP,
|
||||||
|
$field['id'],
|
||||||
|
array(
|
||||||
'type' => $field['wp_type'] ?? 'string',
|
'type' => $field['wp_type'] ?? 'string',
|
||||||
'sanitize_callback' => [ $this, 'sanitize_field' ],
|
'sanitize_callback' => array( $this, 'sanitize_field' ),
|
||||||
'default' => $field['default'] ?? '',
|
'default' => $field['default'] ?? '',
|
||||||
] );
|
)
|
||||||
add_settings_field( $field['id'], $field['label'], [ $this, 'render_field' ], self::PAGE_SLUG, $section, $field );
|
);
|
||||||
|
add_settings_field( $field['id'], $field['label'], array( $this, 'render_field' ), self::PAGE_SLUG, $section, $field );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Enqueue admin CSS and JS on all PRAutoBlogger admin pages. */
|
/** Enqueue admin CSS and JS on all PRAutoBlogger admin pages. */
|
||||||
public function on_enqueue_assets( string $hook_suffix ): void {
|
public function on_enqueue_assets( string $hook_suffix ): void {
|
||||||
$pages = [
|
$pages = array(
|
||||||
'toplevel_page_' . self::PAGE_SLUG,
|
'toplevel_page_' . self::PAGE_SLUG,
|
||||||
|
'prautoblogger_page_prautoblogger-board',
|
||||||
'prautoblogger_page_prautoblogger-metrics',
|
'prautoblogger_page_prautoblogger-metrics',
|
||||||
'prautoblogger_page_prautoblogger-review-queue',
|
'prautoblogger_page_prautoblogger-review-queue',
|
||||||
|
'prautoblogger_page_prautoblogger-ideas',
|
||||||
'prautoblogger_page_prautoblogger-logs',
|
'prautoblogger_page_prautoblogger-logs',
|
||||||
];
|
);
|
||||||
if ( ! in_array( $hook_suffix, $pages, true ) ) {
|
if ( ! in_array( $hook_suffix, $pages, true ) ) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
wp_enqueue_style( 'prautoblogger-admin', PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/admin.css', [], PRAUTOBLOGGER_VERSION );
|
wp_enqueue_style( 'prautoblogger-admin', PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/admin.css', array(), PRAUTOBLOGGER_VERSION );
|
||||||
wp_enqueue_script( 'prautoblogger-admin', PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/admin.js', [ 'jquery' ], PRAUTOBLOGGER_VERSION, true );
|
wp_enqueue_style( 'prautoblogger-model-picker', PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/model-picker.css', array( 'prautoblogger-admin' ), PRAUTOBLOGGER_VERSION );
|
||||||
|
wp_enqueue_script( 'prautoblogger-admin', PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/admin.js', array( 'jquery' ), PRAUTOBLOGGER_VERSION, true );
|
||||||
|
wp_enqueue_script( 'prautoblogger-model-picker', PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/model-picker.js', array( 'jquery', 'prautoblogger-admin' ), PRAUTOBLOGGER_VERSION, true );
|
||||||
|
wp_enqueue_script( 'peptiderepo-model-picker', PRAUTOBLOGGER_PLUGIN_URL . 'assets/admin/peptiderepo-model-picker.js', array( 'jquery' ), PRAUTOBLOGGER_VERSION, true );
|
||||||
|
wp_enqueue_style( 'peptiderepo-model-picker', PRAUTOBLOGGER_PLUGIN_URL . 'assets/admin/peptiderepo-model-picker.css', array(), PRAUTOBLOGGER_VERSION );
|
||||||
|
|
||||||
wp_localize_script( 'prautoblogger-admin', 'prautobloggerAdmin', [
|
wp_localize_script(
|
||||||
|
'prautoblogger-admin',
|
||||||
|
'prautobloggerAdmin',
|
||||||
|
array(
|
||||||
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||||
|
'adminUrl' => admin_url(),
|
||||||
|
'siteUrl' => home_url( '/' ),
|
||||||
'generateNonce' => wp_create_nonce( 'prautoblogger_generate_now' ),
|
'generateNonce' => wp_create_nonce( 'prautoblogger_generate_now' ),
|
||||||
|
'imageNonce' => wp_create_nonce( 'prautoblogger_generate_image' ),
|
||||||
'testNonce' => wp_create_nonce( 'prautoblogger_test_connection' ),
|
'testNonce' => wp_create_nonce( 'prautoblogger_test_connection' ),
|
||||||
|
'modelsNonce' => wp_create_nonce( 'prautoblogger_get_models' ),
|
||||||
'reviewNonce' => wp_create_nonce( 'prautoblogger_review_queue' ),
|
'reviewNonce' => wp_create_nonce( 'prautoblogger_review_queue' ),
|
||||||
|
'ideaGenNonce' => wp_create_nonce( 'prautoblogger_idea_gen' ),
|
||||||
'generatingText' => __( 'Generating...', 'prautoblogger' ),
|
'generatingText' => __( 'Generating...', 'prautoblogger' ),
|
||||||
'generateText' => __( 'Generate Now', 'prautoblogger' ),
|
'generateText' => __( 'Generate Now', 'prautoblogger' ),
|
||||||
'testingText' => __( 'Testing...', 'prautoblogger' ),
|
'testingText' => __( 'Testing...', 'prautoblogger' ),
|
||||||
'testText' => __( 'Test Connections', 'prautoblogger' ),
|
'testText' => __( 'Test Connections', 'prautoblogger' ),
|
||||||
] );
|
// Image models for the picker — defined in the settings class to keep this file short.
|
||||||
|
'imageModels' => PRAutoBlogger_Settings_Fields_Extended::get_image_models(),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ideas browser: generate-from-idea JS (only on the Ideas page).
|
||||||
|
if ( 'prautoblogger_page_prautoblogger-ideas' === $hook_suffix ) {
|
||||||
|
wp_enqueue_script( 'prautoblogger-ideas-browser', PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/ideas-browser.js', array( 'jquery', 'prautoblogger-admin' ), PRAUTOBLOGGER_VERSION, true );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Render the settings page (delegates to template). */
|
/** Render the settings page (delegates to template). */
|
||||||
@@ -112,15 +140,22 @@ class PRAutoBlogger_Admin_Page {
|
|||||||
case 'url':
|
case 'url':
|
||||||
case 'time':
|
case 'time':
|
||||||
$attrs = '';
|
$attrs = '';
|
||||||
if ( isset( $args['min'] ) ) { $attrs .= ' min="' . esc_attr( (string) $args['min'] ) . '"'; }
|
if ( isset( $args['min'] ) ) {
|
||||||
if ( isset( $args['max'] ) ) { $attrs .= ' max="' . esc_attr( (string) $args['max'] ) . '"'; }
|
$attrs .= ' min="' . esc_attr( (string) $args['min'] ) . '"'; }
|
||||||
if ( isset( $args['step'] ) ) { $attrs .= ' step="' . esc_attr( (string) $args['step'] ) . '"'; }
|
if ( isset( $args['max'] ) ) {
|
||||||
|
$attrs .= ' max="' . esc_attr( (string) $args['max'] ) . '"'; }
|
||||||
|
if ( isset( $args['step'] ) ) {
|
||||||
|
$attrs .= ' step="' . esc_attr( (string) $args['step'] ) . '"'; }
|
||||||
printf( '<input type="%s" id="%s" name="%s" value="%s" class="ab-input" %s />', esc_attr( $type ), $id, $id, esc_attr( (string) $value ), $attrs );
|
printf( '<input type="%s" id="%s" name="%s" value="%s" class="ab-input" %s />', esc_attr( $type ), $id, $id, esc_attr( (string) $value ), $attrs );
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'password':
|
case 'password':
|
||||||
printf( '<input type="password" id="%s" name="%s" value="" class="ab-input" placeholder="%s" autocomplete="off" />',
|
printf(
|
||||||
$id, $id, '' !== $value ? esc_attr__( 'Saved (enter new value to change)', 'prautoblogger' ) : '' );
|
'<input type="password" id="%s" name="%s" value="" class="ab-input" placeholder="%s" autocomplete="off" />',
|
||||||
|
$id,
|
||||||
|
$id,
|
||||||
|
'' !== $value ? esc_attr__( 'Saved (enter new value to change)', 'prautoblogger' ) : ''
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'textarea':
|
case 'textarea':
|
||||||
@@ -129,38 +164,76 @@ class PRAutoBlogger_Admin_Page {
|
|||||||
|
|
||||||
case 'select':
|
case 'select':
|
||||||
printf( '<select id="%s" name="%s" class="ab-select">', $id, $id );
|
printf( '<select id="%s" name="%s" class="ab-select">', $id, $id );
|
||||||
foreach ( ( $args['options'] ?? [] ) as $v => $label ) {
|
foreach ( ( $args['options'] ?? array() ) as $v => $label ) {
|
||||||
printf( '<option value="%s"%s>%s</option>', esc_attr( (string) $v ), selected( $value, (string) $v, false ), esc_html( $label ) );
|
printf( '<option value="%s"%s>%s</option>', esc_attr( (string) $v ), selected( $value, (string) $v, false ), esc_html( $label ) );
|
||||||
}
|
}
|
||||||
echo '</select>';
|
echo '</select>';
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'toggle':
|
case 'toggle':
|
||||||
$checked = in_array( $value, [ '1', 'yes', true ], true );
|
$checked = in_array( $value, array( '1', 'yes', true ), true );
|
||||||
printf( '<label class="ab-toggle"><input type="hidden" name="%s" value="0" /><input type="checkbox" name="%s" value="1" %s /><span class="ab-toggle-slider"></span></label>',
|
printf(
|
||||||
$id, $id, checked( $checked, true, false ) );
|
'<label class="ab-toggle"><input type="hidden" name="%s" value="0" /><input type="checkbox" name="%s" value="1" %s /><span class="ab-toggle-slider"></span></label>',
|
||||||
|
$id,
|
||||||
|
$id,
|
||||||
|
checked( $checked, true, false )
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'checkboxes':
|
case 'checkboxes':
|
||||||
$current = json_decode( (string) $value, true ) ?: [];
|
$current = json_decode( (string) $value, true ) ?? array();
|
||||||
foreach ( ( $args['options'] ?? [] ) as $v => $label ) {
|
// Hidden input ensures an empty value is POSTed when all boxes
|
||||||
|
// are unchecked — without this, the browser omits the field
|
||||||
|
// entirely, so WordPress never fires the sanitize callback and
|
||||||
|
// the old value persists in wp_options.
|
||||||
|
printf( '<input type="hidden" name="%s" value="" />', $id );
|
||||||
|
foreach ( ( $args['options'] ?? array() ) as $v => $label ) {
|
||||||
$is_checked = in_array( $v, $current, true );
|
$is_checked = in_array( $v, $current, true );
|
||||||
$disabled = strpos( $label, 'coming soon' ) !== false ? ' disabled' : '';
|
$disabled = strpos( $label, 'coming soon' ) !== false ? ' disabled' : '';
|
||||||
printf( '<label class="ab-checkbox-label"><input type="checkbox" name="%s[]" value="%s" %s%s /> %s</label>',
|
printf(
|
||||||
$id, esc_attr( $v ), checked( $is_checked, true, false ), $disabled, esc_html( $label ) );
|
'<label class="ab-checkbox-label"><input type="checkbox" name="%s[]" value="%s" %s%s /> %s</label>',
|
||||||
|
$id,
|
||||||
|
esc_attr( $v ),
|
||||||
|
checked( $is_checked, true, false ),
|
||||||
|
$disabled,
|
||||||
|
esc_html( $label )
|
||||||
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'model_select':
|
||||||
|
$this->render_model_select_field( $id, (string) $value, $args );
|
||||||
|
break;
|
||||||
|
|
||||||
case 'source_status':
|
case 'source_status':
|
||||||
$this->render_source_status_field();
|
$this->render_source_status_field();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'author_select':
|
case 'author_select':
|
||||||
wp_dropdown_users( [ 'name' => $id, 'id' => $id, 'selected' => absint( $value ), 'show_option_none' => __( '— Auto (first admin) —', 'prautoblogger' ), 'option_none_value' => '0', 'class' => 'ab-select' ] );
|
wp_dropdown_users(
|
||||||
|
array(
|
||||||
|
'name' => $id,
|
||||||
|
'id' => $id,
|
||||||
|
'selected' => absint( $value ),
|
||||||
|
'show_option_none' => __( '— Auto (first admin) —', 'prautoblogger' ),
|
||||||
|
'option_none_value' => '0',
|
||||||
|
'class' => 'ab-select',
|
||||||
|
)
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'category_select':
|
case 'category_select':
|
||||||
wp_dropdown_categories( [ 'name' => $id, 'id' => $id, 'selected' => absint( $value ), 'show_option_none' => __( '— Auto-assign by type —', 'prautoblogger' ), 'option_none_value' => '0', 'class' => 'ab-select', 'hide_empty' => false ] );
|
wp_dropdown_categories(
|
||||||
|
array(
|
||||||
|
'name' => $id,
|
||||||
|
'id' => $id,
|
||||||
|
'selected' => absint( $value ),
|
||||||
|
'show_option_none' => __( '— Auto-assign by type —', 'prautoblogger' ),
|
||||||
|
'option_none_value' => '0',
|
||||||
|
'class' => 'ab-select',
|
||||||
|
'hide_empty' => false,
|
||||||
|
)
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,108 +246,38 @@ class PRAutoBlogger_Admin_Page {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the Reddit source status indicator.
|
* Delegate source status rendering to the dedicated field class.
|
||||||
*
|
*
|
||||||
* Shows Reddit RSS (primary) and .json (fallback) availability
|
* @see admin/fields/class-source-status-field.php
|
||||||
* with live status checks via transient cache and rate limit info.
|
|
||||||
*
|
|
||||||
* Side effects: Reads transients for cached availability status.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
private function render_source_status_field(): void {
|
private function render_source_status_field(): void {
|
||||||
$last_collection = get_option( 'prautoblogger_last_collection_time', '' );
|
PRAutoBlogger_Source_Status_Field::render();
|
||||||
$last_source_used = get_option( 'prautoblogger_last_source_used', '' );
|
|
||||||
|
|
||||||
echo '<div class="ab-source-status">';
|
|
||||||
|
|
||||||
// Reddit RSS — primary source.
|
|
||||||
printf(
|
|
||||||
'<div class="ab-source-row">'
|
|
||||||
. '<span class="ab-source-dot ab-status-ok"></span>'
|
|
||||||
. '<strong>%s</strong> <span class="ab-source-badge">%s</span>'
|
|
||||||
. '<span class="ab-source-label">%s</span>'
|
|
||||||
. '</div>',
|
|
||||||
esc_html__( 'Reddit RSS', 'prautoblogger' ),
|
|
||||||
esc_html__( 'Primary', 'prautoblogger' ),
|
|
||||||
esc_html__( 'Reliable from all IPs', 'prautoblogger' )
|
|
||||||
);
|
|
||||||
|
|
||||||
// Reddit .json — fallback source.
|
|
||||||
printf(
|
|
||||||
'<div class="ab-source-row">'
|
|
||||||
. '<span class="ab-source-dot ab-status-ok"></span>'
|
|
||||||
. '<strong>%s</strong> <span class="ab-source-badge ab-badge-secondary">%s</span>'
|
|
||||||
. '<span class="ab-source-label">%s</span>'
|
|
||||||
. '</div>',
|
|
||||||
esc_html__( 'Reddit .json', 'prautoblogger' ),
|
|
||||||
esc_html__( 'Fallback + Comments', 'prautoblogger' ),
|
|
||||||
esc_html__( 'May be IP-blocked on some hosts', 'prautoblogger' )
|
|
||||||
);
|
|
||||||
|
|
||||||
// Last collection info.
|
|
||||||
if ( '' !== $last_collection ) {
|
|
||||||
$time_ago = human_time_diff( (int) $last_collection, time() );
|
|
||||||
printf(
|
|
||||||
'<div class="ab-source-meta">%s <strong>%s</strong> %s</div>',
|
|
||||||
esc_html__( 'Last collection:', 'prautoblogger' ),
|
|
||||||
/* translators: %s is a human-readable time difference like "2 hours" */
|
|
||||||
esc_html( sprintf( __( '%s ago', 'prautoblogger' ), $time_ago ) ),
|
|
||||||
'' !== $last_source_used ? esc_html( sprintf( __( 'via %s', 'prautoblogger' ), $last_source_used ) ) : ''
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
echo '</div>';
|
/**
|
||||||
|
* Delegate model picker rendering to the dedicated field class.
|
||||||
|
*
|
||||||
|
* @param string $id Field HTML id/name.
|
||||||
|
* @param string $value Current model id.
|
||||||
|
* @param array<string, mixed> $args Field definition.
|
||||||
|
*
|
||||||
|
* @see admin/fields/class-openrouter-model-field.php
|
||||||
|
*/
|
||||||
|
private function render_model_select_field( string $id, string $value, array $args ): void {
|
||||||
|
PRAutoBlogger_OpenRouter_Model_Field::render( $id, $value, $args );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitize a settings field value.
|
* Sanitize a settings field value.
|
||||||
*
|
*
|
||||||
|
* Delegates to PRAutoBlogger_Settings_Sanitizer (extracted for 300-line compliance).
|
||||||
|
*
|
||||||
* @param mixed $value The submitted value.
|
* @param mixed $value The submitted value.
|
||||||
* @return mixed Sanitized value.
|
* @return mixed Sanitized value.
|
||||||
|
*
|
||||||
|
* @see admin/class-settings-sanitizer.php
|
||||||
*/
|
*/
|
||||||
public function sanitize_field( $value ) {
|
public function sanitize_field( $value ) {
|
||||||
$option_name = '';
|
return PRAutoBlogger_Settings_Sanitizer::sanitize_field( $value );
|
||||||
$filter = current_filter();
|
|
||||||
if ( 0 === strpos( $filter, 'sanitize_option_' ) ) {
|
|
||||||
$option_name = substr( $filter, strlen( 'sanitize_option_' ) );
|
|
||||||
}
|
|
||||||
|
|
||||||
$encrypted = [ 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json' ];
|
|
||||||
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 = [ 'prautoblogger_target_subreddits', 'prautoblogger_topic_exclusions', 'prautoblogger_enabled_sources' ];
|
|
||||||
if ( in_array( $option_name, $json_fields, true ) ) {
|
|
||||||
if ( is_array( $value ) ) { return wp_json_encode( array_values( array_map( 'sanitize_text_field', $value ) ) ); }
|
|
||||||
if ( is_string( $value ) && '[' !== substr( trim( $value ), 0, 1 ) ) {
|
|
||||||
$items = array_filter( array_map( 'trim', explode( ',', $value ) ) );
|
|
||||||
return wp_json_encode( array_values( $items ) );
|
|
||||||
}
|
|
||||||
return sanitize_text_field( $value );
|
|
||||||
}
|
|
||||||
|
|
||||||
$numeric = [ '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' ];
|
|
||||||
if ( in_array( $option_name, $numeric, true ) ) {
|
|
||||||
return is_numeric( $value ) ? $value : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return sanitize_text_field( (string) $value );
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
230
includes/admin/class-board-data-provider.php
Normal file
230
includes/admin/class-board-data-provider.php
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Supplies card data for the kanban board page from existing DB tables.
|
||||||
|
*
|
||||||
|
* Orchestrates the four board columns:
|
||||||
|
* Generating | In Review | Published | Failed
|
||||||
|
*
|
||||||
|
* Generation-log queries (Generating, Failed) are delegated to
|
||||||
|
* PRAutoBlogger_Board_Gen_Log_Query. WP_Query columns (In Review, Published)
|
||||||
|
* are handled here.
|
||||||
|
*
|
||||||
|
* M2: cards now include `dossier_url` so board.js can deep-link to the
|
||||||
|
* Article Dossier instead of the old per-column destinations.
|
||||||
|
*
|
||||||
|
* No schema changes -- all data comes from tables that already exist.
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger_Board_Page::on_ajax_board_status().
|
||||||
|
* Dependencies: PRAutoBlogger_Board_Gen_Log_Query, PRAutoBlogger_Dossier_Page,
|
||||||
|
* WordPress WP_Query, get_option().
|
||||||
|
*
|
||||||
|
* @see admin/class-board-gen-log-query.php -- Raw gen_log queries (Generating/Failed).
|
||||||
|
* @see admin/class-board-page.php -- Calls get_board_snapshot().
|
||||||
|
* @see admin/class-dossier-page.php -- Provides url_for_post() for deep-links.
|
||||||
|
* @see ARCHITECTURE.md -- Database schema.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Board_Data_Provider {
|
||||||
|
|
||||||
|
/** @var PRAutoBlogger_Board_Gen_Log_Query Handles raw gen_log DB queries. */
|
||||||
|
private PRAutoBlogger_Board_Gen_Log_Query $gen_log_query;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param PRAutoBlogger_Board_Gen_Log_Query|null $gen_log_query Optional injection for testing.
|
||||||
|
*/
|
||||||
|
public function __construct( ?PRAutoBlogger_Board_Gen_Log_Query $gen_log_query = null ) {
|
||||||
|
$this->gen_log_query = $gen_log_query ?? new PRAutoBlogger_Board_Gen_Log_Query();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the full board snapshot consumed by the AJAX poller.
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* generating: array<int, array<string, mixed>>,
|
||||||
|
* in_review: array<int, array<string, mixed>>,
|
||||||
|
* published: array<int, array<string, mixed>>,
|
||||||
|
* failed: array<int, array<string, mixed>>,
|
||||||
|
* has_active_runs: bool,
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function get_board_snapshot(): array {
|
||||||
|
$generating = $this->gen_log_query->get_generating_cards();
|
||||||
|
$in_review = $this->get_in_review_cards();
|
||||||
|
$published = $this->get_published_cards();
|
||||||
|
$failed = $this->gen_log_query->get_failed_cards();
|
||||||
|
|
||||||
|
return array(
|
||||||
|
'generating' => $generating,
|
||||||
|
'in_review' => $in_review,
|
||||||
|
'published' => $published,
|
||||||
|
'failed' => $failed,
|
||||||
|
'has_active_runs' => ! empty( $generating ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cards for runs that are currently in-progress.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function get_generating_cards(): array {
|
||||||
|
return $this->gen_log_query->get_generating_cards();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cards for generation runs that ended in an error state.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function get_failed_cards(): array {
|
||||||
|
return $this->gen_log_query->get_failed_cards();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cards for generated draft posts awaiting human review.
|
||||||
|
* M2: cards include dossier_url for deep-linking.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function get_in_review_cards(): array {
|
||||||
|
$query = new WP_Query(
|
||||||
|
array(
|
||||||
|
'post_type' => 'post',
|
||||||
|
'post_status' => 'draft',
|
||||||
|
'posts_per_page' => max( 5, (int) get_option( 'prautoblogger_board_column_limit', PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT ) ),
|
||||||
|
'meta_query' => array(
|
||||||
|
array(
|
||||||
|
'key' => '_prautoblogger_generated',
|
||||||
|
'value' => '1',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'orderby' => 'date',
|
||||||
|
'order' => 'DESC',
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$cards = array();
|
||||||
|
foreach ( $query->posts as $post ) {
|
||||||
|
if ( ! ( $post instanceof WP_Post ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$run_id = (string) get_post_meta( $post->ID, '_prautoblogger_run_id', true );
|
||||||
|
$cost_total = (float) get_post_meta( $post->ID, '_prautoblogger_total_cost', true );
|
||||||
|
|
||||||
|
$cards[] = array(
|
||||||
|
'run_id' => $run_id,
|
||||||
|
'post_id' => $post->ID,
|
||||||
|
'title' => $post->post_title,
|
||||||
|
'cost_total' => $cost_total,
|
||||||
|
'created_at' => strtotime( $post->post_date_gmt ),
|
||||||
|
'click_action' => 'dossier',
|
||||||
|
'dossier_url' => PRAutoBlogger_Dossier_Page::url_for_post( $post->ID ),
|
||||||
|
'review_url' => admin_url( 'admin.php?page=prautoblogger-review-queue' ),
|
||||||
|
'edit_url' => get_edit_post_link( $post->ID, 'raw' ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->flag_human_modified( $cards );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cards for published posts within the configured window.
|
||||||
|
* M2: cards include dossier_url for deep-linking.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function get_published_cards(): array {
|
||||||
|
$window_days = max( 1, (int) get_option( 'prautoblogger_board_published_window_days', 7 ) );
|
||||||
|
$date_after = gmdate( 'Y-m-d H:i:s', time() - ( $window_days * DAY_IN_SECONDS ) );
|
||||||
|
|
||||||
|
$query = new WP_Query(
|
||||||
|
array(
|
||||||
|
'post_type' => 'post',
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'posts_per_page' => max( 5, (int) get_option( 'prautoblogger_board_column_limit', PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT ) ),
|
||||||
|
'date_query' => array(
|
||||||
|
array(
|
||||||
|
'after' => $date_after,
|
||||||
|
'inclusive' => true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'meta_query' => array(
|
||||||
|
array(
|
||||||
|
'key' => '_prautoblogger_generated',
|
||||||
|
'value' => '1',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'orderby' => 'date',
|
||||||
|
'order' => 'DESC',
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$cards = array();
|
||||||
|
foreach ( $query->posts as $post ) {
|
||||||
|
if ( ! ( $post instanceof WP_Post ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$run_id = (string) get_post_meta( $post->ID, '_prautoblogger_run_id', true );
|
||||||
|
$cost_total = (float) get_post_meta( $post->ID, '_prautoblogger_total_cost', true );
|
||||||
|
|
||||||
|
$cards[] = array(
|
||||||
|
'run_id' => $run_id,
|
||||||
|
'post_id' => $post->ID,
|
||||||
|
'title' => $post->post_title,
|
||||||
|
'cost_total' => $cost_total,
|
||||||
|
'published_at' => strtotime( $post->post_date_gmt ),
|
||||||
|
'click_action' => 'dossier',
|
||||||
|
'dossier_url' => PRAutoBlogger_Dossier_Page::url_for_post( $post->ID ),
|
||||||
|
'edit_url' => get_edit_post_link( $post->ID, 'raw' ),
|
||||||
|
'post_url' => get_permalink( $post->ID ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->flag_human_modified( $cards );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set 'human_modified' on each card in ONE batched query (v0.20.0 —
|
||||||
|
* CPO product AC: human-modified runs are visually distinct at the
|
||||||
|
* run-list level). No per-card queries; self-healing on a missing
|
||||||
|
* table/column (flag simply stays false).
|
||||||
|
*
|
||||||
|
* @param array<int, array<string, mixed>> $cards Column cards.
|
||||||
|
* @return array<int, array<string, mixed>> Cards with the flag set.
|
||||||
|
*/
|
||||||
|
private function flag_human_modified( array $cards ): array {
|
||||||
|
$run_ids = array();
|
||||||
|
foreach ( $cards as $i => $card ) {
|
||||||
|
$cards[ $i ]['human_modified'] = false;
|
||||||
|
if ( ! empty( $card['run_id'] ) ) {
|
||||||
|
$run_ids[] = (string) $card['run_id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( empty( $run_ids ) || ! PRAutoBlogger_Run_Stage_State::is_available() ) {
|
||||||
|
return $cards;
|
||||||
|
}
|
||||||
|
global $wpdb;
|
||||||
|
$table = PRAutoBlogger_Run_Stage_State::table_name();
|
||||||
|
$placeholders = implode( ',', array_fill( 0, count( $run_ids ), '%s' ) );
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- $placeholders is a fixed %s list.
|
||||||
|
$flagged = $wpdb->get_col(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"SELECT DISTINCT run_id FROM {$table} WHERE human_modified = 1 AND run_id IN ({$placeholders})",
|
||||||
|
$run_ids
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if ( ! is_array( $flagged ) || empty( $flagged ) ) {
|
||||||
|
return $cards;
|
||||||
|
}
|
||||||
|
$flagged = array_flip( array_map( 'strval', $flagged ) );
|
||||||
|
foreach ( $cards as $i => $card ) {
|
||||||
|
if ( isset( $flagged[ (string) ( $card['run_id'] ?? '' ) ] ) ) {
|
||||||
|
$cards[ $i ]['human_modified'] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $cards;
|
||||||
|
}
|
||||||
|
}
|
||||||
186
includes/admin/class-board-gen-log-query.php
Normal file
186
includes/admin/class-board-gen-log-query.php
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Raw generation-log queries for the kanban board.
|
||||||
|
*
|
||||||
|
* Extracted from PRAutoBlogger_Board_Data_Provider to stay under the 300-line
|
||||||
|
* cap. Handles the two columns that read directly from prab_generation_log:
|
||||||
|
* Generating (in-progress runs) | Failed (error runs with no linked post)
|
||||||
|
*
|
||||||
|
* No schema changes — data comes from prab_generation_log which already exists.
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger_Board_Data_Provider::get_generating_cards()
|
||||||
|
* PRAutoBlogger_Board_Data_Provider::get_failed_cards()
|
||||||
|
* Dependencies: WordPress $wpdb, get_transient().
|
||||||
|
*
|
||||||
|
* @see admin/class-board-data-provider.php — Orchestrator that calls this class.
|
||||||
|
* @see ARCHITECTURE.md — Database schema (generation_log).
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Board_Gen_Log_Query {
|
||||||
|
|
||||||
|
/** Seconds of gen-log inactivity after which a run is no longer "generating". */
|
||||||
|
public const GENERATING_GRACE_SECONDS = 1800;
|
||||||
|
|
||||||
|
/** Transient key written by PRAutoBlogger_Executor for the active generation run. */
|
||||||
|
public const STATUS_TRANSIENT = 'prautoblogger_generation_status';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cards for runs that are currently in-progress.
|
||||||
|
*
|
||||||
|
* Detection strategy: check the generation status transient first (authoritative
|
||||||
|
* for the active manual-run or daily-cron run), then fall back to recent gen_log
|
||||||
|
* run_ids that have no associated published/draft post yet.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function get_generating_cards(): array {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$cards = array();
|
||||||
|
|
||||||
|
// Primary: check if the executor transient signals an active run.
|
||||||
|
$status = get_transient( self::STATUS_TRANSIENT );
|
||||||
|
if ( is_array( $status ) && 'running' === ( $status['status'] ?? '' ) ) {
|
||||||
|
$cards[] = array(
|
||||||
|
'run_id' => '',
|
||||||
|
'post_id' => 0,
|
||||||
|
'title' => $status['stage'] ?? __( 'Generating…', 'prautoblogger' ),
|
||||||
|
'cost_total' => 0.0,
|
||||||
|
'stage_current' => $status['stage'] ?? '',
|
||||||
|
'stage_count' => 0,
|
||||||
|
'started_at' => $status['started'] ?? 0,
|
||||||
|
'click_action' => 'logs',
|
||||||
|
'log_url' => admin_url( 'admin.php?page=prautoblogger-logs' ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secondary: runs in gen_log that are recent and have no linked post.
|
||||||
|
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||||
|
$since = gmdate( 'Y-m-d H:i:s', time() - self::GENERATING_GRACE_SECONDS );
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
$raw_runs = $wpdb->get_results(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"SELECT run_id,
|
||||||
|
MIN(created_at) as started_at,
|
||||||
|
MAX(stage) as last_stage,
|
||||||
|
COUNT(*) as stage_count,
|
||||||
|
SUM(CASE WHEN response_status = 'success' THEN estimated_cost ELSE 0 END) as cost_total
|
||||||
|
FROM {$table}
|
||||||
|
WHERE created_at >= %s
|
||||||
|
AND run_id IS NOT NULL
|
||||||
|
AND post_id IS NULL
|
||||||
|
GROUP BY run_id
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
LIMIT %d",
|
||||||
|
$since,
|
||||||
|
(int) max( 5, get_option( 'prautoblogger_board_column_limit', PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT ) )
|
||||||
|
),
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( ! is_array( $raw_runs ) ) {
|
||||||
|
return $cards;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ( $raw_runs as $row ) {
|
||||||
|
$run_id = (string) $row['run_id'];
|
||||||
|
if ( '' === $run_id ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if we already have a transient card (avoid duplicate).
|
||||||
|
if ( ! empty( $cards ) && '' === $cards[0]['run_id'] ) {
|
||||||
|
// Only add secondary cards if we have a real run_id.
|
||||||
|
// The transient card already covers the active run.
|
||||||
|
}
|
||||||
|
|
||||||
|
$cards[] = array(
|
||||||
|
'run_id' => $run_id,
|
||||||
|
'post_id' => 0,
|
||||||
|
'title' => sprintf(
|
||||||
|
/* translators: %s: pipeline stage name */
|
||||||
|
__( 'Generating — %s', 'prautoblogger' ),
|
||||||
|
esc_html( ucfirst( (string) $row['last_stage'] ) )
|
||||||
|
),
|
||||||
|
'cost_total' => round( (float) $row['cost_total'], 6 ),
|
||||||
|
'stage_current' => (string) $row['last_stage'],
|
||||||
|
'stage_count' => (int) $row['stage_count'],
|
||||||
|
'started_at' => strtotime( (string) $row['started_at'] ),
|
||||||
|
'click_action' => 'logs',
|
||||||
|
'log_url' => admin_url( 'admin.php?page=prautoblogger-logs' ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $cards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cards for generation runs that ended in an error state.
|
||||||
|
*
|
||||||
|
* Looks for recent gen_log rows with response_status='error' that have
|
||||||
|
* no corresponding published post — these are pipeline failures.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function get_failed_cards(): array {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||||
|
$since = gmdate( 'Y-m-d H:i:s', time() - ( 7 * DAY_IN_SECONDS ) );
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
$raw = $wpdb->get_results(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"SELECT run_id,
|
||||||
|
MIN(created_at) as started_at,
|
||||||
|
MAX(error_message) as last_error,
|
||||||
|
MAX(stage) as last_stage,
|
||||||
|
COUNT(*) as call_count
|
||||||
|
FROM {$table}
|
||||||
|
WHERE created_at >= %s
|
||||||
|
AND response_status = 'error'
|
||||||
|
AND run_id IS NOT NULL
|
||||||
|
AND post_id IS NULL
|
||||||
|
GROUP BY run_id
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
LIMIT %d",
|
||||||
|
$since,
|
||||||
|
(int) max( 5, get_option( 'prautoblogger_board_column_limit', PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT ) )
|
||||||
|
),
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( ! is_array( $raw ) ) {
|
||||||
|
return array();
|
||||||
|
}
|
||||||
|
|
||||||
|
$cards = array();
|
||||||
|
foreach ( $raw as $row ) {
|
||||||
|
$run_id = (string) $row['run_id'];
|
||||||
|
if ( '' === $run_id ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$cards[] = array(
|
||||||
|
'run_id' => $run_id,
|
||||||
|
'post_id' => 0,
|
||||||
|
'title' => sprintf(
|
||||||
|
/* translators: %s: pipeline stage name */
|
||||||
|
__( 'Failed — %s', 'prautoblogger' ),
|
||||||
|
esc_html( ucfirst( (string) $row['last_stage'] ) )
|
||||||
|
),
|
||||||
|
'error_message' => (string) $row['last_error'],
|
||||||
|
'started_at' => strtotime( (string) $row['started_at'] ),
|
||||||
|
'click_action' => 'logs',
|
||||||
|
'log_url' => admin_url( 'admin.php?page=prautoblogger-logs' ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $cards;
|
||||||
|
}
|
||||||
|
}
|
||||||
195
includes/admin/class-board-page.php
Normal file
195
includes/admin/class-board-page.php
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Registers and renders the kanban board -- the primary landing screen for PRAutoBlogger.
|
||||||
|
*
|
||||||
|
* Board columns: Generating | In Review | Published | Failed.
|
||||||
|
* Cards live-update via AJAX polling at a settings-backed interval (default 5s),
|
||||||
|
* backing off to 2x when no active generating run is detected.
|
||||||
|
*
|
||||||
|
* Card click-throughs (M1):
|
||||||
|
* - Generating -> Activity Log filtered to run context
|
||||||
|
* - In Review -> Review Queue
|
||||||
|
* - Published -> Post edit screen
|
||||||
|
* - Failed -> Activity Log
|
||||||
|
*
|
||||||
|
* M2 will rewire all click-throughs to the Article Dossier page.
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger::register_admin_hooks() on `admin_menu`.
|
||||||
|
* Dependencies: PRAutoBlogger_Board_Data_Provider, wp_localize_script.
|
||||||
|
*
|
||||||
|
* @see admin/class-board-data-provider.php -- Supplies card data.
|
||||||
|
* @see templates/admin/board-page.php -- HTML template.
|
||||||
|
* @see assets/js/board.js -- Polling + DOM updates.
|
||||||
|
* @see ARCHITECTURE.md -- Section: Board (kanban dashboard).
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Board_Page {
|
||||||
|
|
||||||
|
private const PAGE_SLUG = 'prautoblogger-board';
|
||||||
|
|
||||||
|
/** AJAX action name for board polling. */
|
||||||
|
public const AJAX_ACTION = 'prautoblogger_board_status';
|
||||||
|
|
||||||
|
/** Nonce action string for board AJAX. */
|
||||||
|
public const NONCE_ACTION = 'prautoblogger_board';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the Board as the PRIMARY (first) submenu item, overriding the
|
||||||
|
* implicit Settings landing page. The trick is: WordPress auto-generates a
|
||||||
|
* submenu duplicate of the top-level page; we name it "Board" so the
|
||||||
|
* first click goes to the board, not the raw settings page.
|
||||||
|
*
|
||||||
|
* Registration order note: this method is hooked at admin_menu priority 11
|
||||||
|
* so that PRAutoBlogger_Admin_Page::on_register_menu() (priority 10) has
|
||||||
|
* already called add_menu_page(). This ensures WordPress has populated
|
||||||
|
* $admin_page_hooks['prautoblogger-settings'] before add_submenu_page()
|
||||||
|
* runs here, so get_plugin_page_hookname() resolves to the correct
|
||||||
|
* 'prautoblogger_page_prautoblogger-board' suffix rather than the fallback
|
||||||
|
* 'admin_page_prautoblogger-board'. See ARCHITECTURE.md Section: Board.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function on_register_menu(): void {
|
||||||
|
// Override the auto-created "PRAutoBlogger" submenu entry with "Board".
|
||||||
|
add_submenu_page(
|
||||||
|
'prautoblogger-settings',
|
||||||
|
__( 'PRAutoBlogger Board', 'prautoblogger' ),
|
||||||
|
__( 'Board', 'prautoblogger' ),
|
||||||
|
'manage_options',
|
||||||
|
self::PAGE_SLUG,
|
||||||
|
array( $this, 'render_page' )
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reorder submenu so Board is the FIRST entry (primary click target).
|
||||||
|
// WordPress auto-appended a duplicate of the top-level page as submenu
|
||||||
|
// index 0; our Board was appended at index 1. We swap them so the
|
||||||
|
// top-level PRAutoBlogger click lands on the Board page, not Settings.
|
||||||
|
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- intentional submenu reorder.
|
||||||
|
global $submenu;
|
||||||
|
if ( isset( $submenu['prautoblogger-settings'] ) ) {
|
||||||
|
$items = $submenu['prautoblogger-settings'];
|
||||||
|
// Find the Board entry and move it to position 0.
|
||||||
|
$board_idx = null;
|
||||||
|
foreach ( $items as $i => $item ) {
|
||||||
|
if ( isset( $item[2] ) && self::PAGE_SLUG === $item[2] ) {
|
||||||
|
$board_idx = $i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( null !== $board_idx && $board_idx > 0 ) {
|
||||||
|
$board_item = $items[ $board_idx ];
|
||||||
|
unset( $items[ $board_idx ] );
|
||||||
|
// Prepend board item at position 0 while preserving remaining order.
|
||||||
|
$submenu['prautoblogger-settings'] = array_merge( array( $board_item ), array_values( $items ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the board page.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function render_page(): void {
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/board-page.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueue board-specific JS + CSS and localize the poller config.
|
||||||
|
*
|
||||||
|
* Called via `admin_enqueue_scripts` action, gated to this page's hook suffix.
|
||||||
|
*
|
||||||
|
* @param string $hook_suffix Current admin page hook suffix.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function on_enqueue_assets( string $hook_suffix ): void {
|
||||||
|
$board_hook = 'prautoblogger_page_' . self::PAGE_SLUG;
|
||||||
|
if ( $hook_suffix !== $board_hook ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wp_enqueue_style(
|
||||||
|
'prautoblogger-board',
|
||||||
|
PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/board.css',
|
||||||
|
array( 'prautoblogger-admin' ),
|
||||||
|
PRAUTOBLOGGER_VERSION
|
||||||
|
);
|
||||||
|
|
||||||
|
wp_enqueue_script(
|
||||||
|
'prautoblogger-board',
|
||||||
|
PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/board.js',
|
||||||
|
array( 'jquery' ),
|
||||||
|
PRAUTOBLOGGER_VERSION,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
wp_enqueue_script(
|
||||||
|
'prautoblogger-board-generate',
|
||||||
|
PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/board-generate.js',
|
||||||
|
array( 'jquery', 'prautoblogger-board' ),
|
||||||
|
PRAUTOBLOGGER_VERSION,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
$poll_interval = max( 3, (int) get_option( 'prautoblogger_board_poll_interval', 5 ) );
|
||||||
|
$published_days = max( 1, (int) get_option( 'prautoblogger_board_published_window_days', 7 ) );
|
||||||
|
|
||||||
|
wp_localize_script(
|
||||||
|
'prautoblogger-board',
|
||||||
|
'prabBoard',
|
||||||
|
array(
|
||||||
|
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||||
|
'nonce' => wp_create_nonce( self::NONCE_ACTION ),
|
||||||
|
'generateNonce' => wp_create_nonce( 'prautoblogger_generate_now' ),
|
||||||
|
'pollInterval' => $poll_interval * 1000,
|
||||||
|
'publishedWindowDays' => $published_days,
|
||||||
|
'action' => self::AJAX_ACTION,
|
||||||
|
'strings' => array(
|
||||||
|
'generating' => __( 'Generating', 'prautoblogger' ),
|
||||||
|
'inReview' => __( 'In Review', 'prautoblogger' ),
|
||||||
|
'published' => __( 'Published', 'prautoblogger' ),
|
||||||
|
'failed' => __( 'Failed', 'prautoblogger' ),
|
||||||
|
'empty' => __( 'Nothing here yet.', 'prautoblogger' ),
|
||||||
|
'cost' => __( 'Cost', 'prautoblogger' ),
|
||||||
|
'view' => __( 'View', 'prautoblogger' ),
|
||||||
|
'edit' => __( 'Edit', 'prautoblogger' ),
|
||||||
|
'viewLog' => __( 'View Log', 'prautoblogger' ),
|
||||||
|
'pollError' => __( 'Board update failed -- retrying.', 'prautoblogger' ),
|
||||||
|
'humanModified' => __( 'Human-modified', 'prautoblogger' ),
|
||||||
|
'newArticle' => __( 'New Article', 'prautoblogger' ),
|
||||||
|
'generatingBtn' => __( 'Generating...', 'prautoblogger' ),
|
||||||
|
'genStarted' => __( 'Generation started. Board will update automatically.', 'prautoblogger' ),
|
||||||
|
'genError' => __( 'Generation failed to start. Check the Activity Log.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX handler: return the current board snapshot (all four columns).
|
||||||
|
*
|
||||||
|
* Nonce: `prautoblogger_board`. Requires `manage_options` capability.
|
||||||
|
*
|
||||||
|
* Side effects: DB reads only -- no writes.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function on_ajax_board_status(): void {
|
||||||
|
check_ajax_referer( self::NONCE_ACTION, 'nonce' );
|
||||||
|
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$provider = new PRAutoBlogger_Board_Data_Provider();
|
||||||
|
$snapshot = $provider->get_board_snapshot();
|
||||||
|
|
||||||
|
wp_send_json_success( $snapshot );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
* WordPress Dashboard widget showing PRAutoBlogger generation status at a glance.
|
* WordPress Dashboard widget showing PRAutoBlogger generation status at a glance.
|
||||||
*
|
*
|
||||||
* Displays: last generation time, articles generated today, monthly cost,
|
* Displays: last generation time, articles generated today, monthly cost,
|
||||||
@@ -27,7 +29,7 @@ class PRAutoBlogger_Dashboard_Widget {
|
|||||||
wp_add_dashboard_widget(
|
wp_add_dashboard_widget(
|
||||||
'prautoblogger_status',
|
'prautoblogger_status',
|
||||||
__( 'PRAutoBlogger Status', 'prautoblogger' ),
|
__( 'PRAutoBlogger Status', 'prautoblogger' ),
|
||||||
[ $this, 'render_widget' ]
|
array( $this, 'render_widget' )
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +46,8 @@ class PRAutoBlogger_Dashboard_Widget {
|
|||||||
|
|
||||||
$next_run = wp_next_scheduled( 'prautoblogger_daily_generation' );
|
$next_run = wp_next_scheduled( 'prautoblogger_daily_generation' );
|
||||||
|
|
||||||
printf( '<p><strong>%s</strong> $%.2f / $%.2f (%.0f%%)</p>',
|
printf(
|
||||||
|
'<p><strong>%s</strong> $%.2f / $%.2f (%.0f%%)</p>',
|
||||||
esc_html__( 'Monthly Spend:', 'prautoblogger' ),
|
esc_html__( 'Monthly Spend:', 'prautoblogger' ),
|
||||||
$monthly_spend,
|
$monthly_spend,
|
||||||
$budget,
|
$budget,
|
||||||
@@ -52,7 +55,8 @@ class PRAutoBlogger_Dashboard_Widget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if ( false !== $next_run ) {
|
if ( false !== $next_run ) {
|
||||||
printf( '<p><strong>%s</strong> %s</p>',
|
printf(
|
||||||
|
'<p><strong>%s</strong> %s</p>',
|
||||||
esc_html__( 'Next Generation:', 'prautoblogger' ),
|
esc_html__( 'Next Generation:', 'prautoblogger' ),
|
||||||
esc_html( wp_date( 'M j, Y g:i A', $next_run ) )
|
esc_html( wp_date( 'M j, Y g:i A', $next_run ) )
|
||||||
);
|
);
|
||||||
|
|||||||
299
includes/admin/class-dossier-actions.php
Normal file
299
includes/admin/class-dossier-actions.php
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* AJAX endpoints for the dossier's edit + re-run actions (v0.20.0, M3).
|
||||||
|
*
|
||||||
|
* What: Four manage_options + nonce gated endpoints. The mutation
|
||||||
|
* endpoints follow the chained-cron contract strictly: save_input
|
||||||
|
* only INSERTs an immutable fork version; the two rerun endpoints
|
||||||
|
* only validate + queue (Rerun_Job_Support::queue) — execution and
|
||||||
|
* ALL run/stage state mutations happen in the cron handler after
|
||||||
|
* re-validation under the generation lock. Run identity (run_id /
|
||||||
|
* item_key) is always derived server-side from the post's meta —
|
||||||
|
* never trusted from the client; the client only names the stage
|
||||||
|
* row it acted on, which is validated against an existing row.
|
||||||
|
* stage_status is the read-only poll the dossier JS uses to show
|
||||||
|
* queued -> pickup -> result.
|
||||||
|
* Who triggers it: assets/js/dossier-edit.js via admin-ajax.php;
|
||||||
|
* registered in class-prautoblogger.php.
|
||||||
|
* Dependencies: Rerun_Eligibility, Stage_Input_Store, Stage_Replay
|
||||||
|
* (decode), Rerun_Job_Support/Executor, Run_Stage_State.
|
||||||
|
*
|
||||||
|
* @see assets/js/dossier-edit.js — The frontend caller.
|
||||||
|
* @see core/class-rerun-executor.php — The cron handlers jobs land in.
|
||||||
|
* @see ARCHITECTURE.md #24 — Edit + re-run design.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Dossier_Actions {
|
||||||
|
|
||||||
|
/** Nonce action shared by all four endpoints. */
|
||||||
|
public const NONCE_ACTION = 'prautoblogger_dossier_actions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX: save an edited stage input as a new immutable fork version.
|
||||||
|
*
|
||||||
|
* The fork body is the stage's base request (latest fork, else the
|
||||||
|
* recorded request_json) with ONLY the message contents substituted —
|
||||||
|
* roles, message count, model and call options are not editable, so
|
||||||
|
* a fork is always a well-formed replayable chat body. Prompt text
|
||||||
|
* is stored raw (it is LLM input, never rendered unescaped).
|
||||||
|
*
|
||||||
|
* Side effects: one stage_inputs INSERT.
|
||||||
|
*
|
||||||
|
* @return void Sends JSON.
|
||||||
|
*/
|
||||||
|
public function on_save_input(): void {
|
||||||
|
$request = $this->authorize_and_resolve();
|
||||||
|
|
||||||
|
$check = PRAutoBlogger_Rerun_Eligibility::check( $request['run_id'], $request['post_id'] );
|
||||||
|
if ( ! PRAutoBlogger_Rerun_Eligibility::is_editable_stage( $request['stage'] ) || ! $check['ok'] ) {
|
||||||
|
$reason = $check['ok'] ? __( 'This stage cannot be replayed with an edited input.', 'prautoblogger' ) : $check['reason'];
|
||||||
|
wp_send_json_error( array( 'message' => $reason ), 400 );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$base_body = $this->resolve_base_body( $request );
|
||||||
|
if ( null === $base_body ) {
|
||||||
|
wp_send_json_error( array( 'message' => __( 'No persisted input exists for this stage.', 'prautoblogger' ) ), 400 );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- raw prompt text for the LLM; structure-validated below, stored raw, escaped at render.
|
||||||
|
$raw = isset( $_POST['messages'] ) ? wp_unslash( (string) $_POST['messages'] ) : '';
|
||||||
|
$messages = json_decode( $raw, true );
|
||||||
|
$merged = $this->merge_messages( $base_body, is_array( $messages ) ? $messages : null );
|
||||||
|
if ( null === $merged ) {
|
||||||
|
wp_send_json_error( array( 'message' => __( 'Edited messages must match the original structure (same count and roles, non-empty contents).', 'prautoblogger' ) ), 400 );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = wp_get_current_user();
|
||||||
|
$version = PRAutoBlogger_Stage_Input_Store::save_fork(
|
||||||
|
$request['run_id'],
|
||||||
|
$request['stage'],
|
||||||
|
$request['agent_role'],
|
||||||
|
$request['item_key'],
|
||||||
|
(string) wp_json_encode( $merged ),
|
||||||
|
(string) $user->user_login
|
||||||
|
);
|
||||||
|
if ( null === $version ) {
|
||||||
|
wp_send_json_error( array( 'message' => __( 'The edited input could not be saved.', 'prautoblogger' ) ), 500 );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wp_send_json_success(
|
||||||
|
array(
|
||||||
|
'version' => $version,
|
||||||
|
'message' => sprintf(
|
||||||
|
/* translators: %d: fork version number. */
|
||||||
|
__( 'Saved as version %d. The original input is preserved unchanged; re-running this stage will use your edited version and mark all downstream stages stale.', 'prautoblogger' ),
|
||||||
|
$version
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX: queue a single-stage replay of the latest edited fork.
|
||||||
|
*
|
||||||
|
* Validates, then ONLY schedules — chained-cron semantics. The UI
|
||||||
|
* copy never implies synchronous execution.
|
||||||
|
*
|
||||||
|
* @return void Sends JSON.
|
||||||
|
*/
|
||||||
|
public function on_rerun_stage(): void {
|
||||||
|
$request = $this->authorize_and_resolve();
|
||||||
|
|
||||||
|
$check = PRAutoBlogger_Rerun_Eligibility::check_replay(
|
||||||
|
$request['run_id'],
|
||||||
|
$request['post_id'],
|
||||||
|
$request['stage'],
|
||||||
|
$request['agent_role'],
|
||||||
|
$request['item_key']
|
||||||
|
);
|
||||||
|
if ( ! $check['ok'] ) {
|
||||||
|
wp_send_json_error( array( 'message' => $check['reason'] ), 400 );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PRAutoBlogger_Rerun_Job_Support::queue(
|
||||||
|
PRAutoBlogger_Rerun_Executor::REPLAY_ACTION,
|
||||||
|
array( $request['run_id'], $request['post_id'], $request['stage'], $request['agent_role'], $request['item_key'] ),
|
||||||
|
sprintf(
|
||||||
|
/* translators: %s: stage display label. */
|
||||||
|
__( 'Queued: re-running %s with the edited input…', 'prautoblogger' ),
|
||||||
|
PRAutoBlogger_Stage_Display_Map::label( $request['stage'] )
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
wp_send_json_success(
|
||||||
|
array(
|
||||||
|
'queued' => true,
|
||||||
|
'message' => __( 'Re-run queued. The pipeline will pick it up shortly — progress appears here and on the board.', 'prautoblogger' ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX: queue re-run-from-here (rebuild this stage + downstream).
|
||||||
|
*
|
||||||
|
* @return void Sends JSON.
|
||||||
|
*/
|
||||||
|
public function on_rerun_from(): void {
|
||||||
|
$request = $this->authorize_and_resolve();
|
||||||
|
|
||||||
|
$check = PRAutoBlogger_Rerun_Eligibility::check_rebuild(
|
||||||
|
$request['run_id'],
|
||||||
|
$request['post_id'],
|
||||||
|
$request['stage'],
|
||||||
|
$request['item_key']
|
||||||
|
);
|
||||||
|
if ( ! $check['ok'] ) {
|
||||||
|
wp_send_json_error( array( 'message' => $check['reason'] ), 400 );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PRAutoBlogger_Rerun_Job_Support::queue(
|
||||||
|
PRAutoBlogger_Rerun_Executor::REBUILD_ACTION,
|
||||||
|
array( $request['run_id'], $request['post_id'], $request['stage'], $request['item_key'] ),
|
||||||
|
sprintf(
|
||||||
|
/* translators: %s: stage display label. */
|
||||||
|
__( 'Queued: re-running the pipeline from %s…', 'prautoblogger' ),
|
||||||
|
PRAutoBlogger_Stage_Display_Map::label( $request['stage'] )
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
wp_send_json_success(
|
||||||
|
array(
|
||||||
|
'queued' => true,
|
||||||
|
'message' => __( 'Re-run from here queued. Stages will rebuild in sequence — progress appears here and on the board.', 'prautoblogger' ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX: read-only stage-status poll for the dossier.
|
||||||
|
*
|
||||||
|
* @return void Sends JSON: run status, per-stage state, live message.
|
||||||
|
*/
|
||||||
|
public function on_stage_status(): void {
|
||||||
|
$request = $this->authorize_and_resolve( false );
|
||||||
|
|
||||||
|
$stages = array();
|
||||||
|
foreach ( PRAutoBlogger_Run_Stage_State::stages_for_item( $request['run_id'], $request['item_key'] ) as $row ) {
|
||||||
|
$stages[] = array(
|
||||||
|
'stage' => (string) ( $row['stage'] ?? '' ),
|
||||||
|
'agent_role' => (string) ( $row['agent_role'] ?? '' ),
|
||||||
|
'status' => (string) ( $row['status'] ?? '' ),
|
||||||
|
'stale' => ! empty( $row['stale'] ),
|
||||||
|
'human_modified' => ! empty( $row['human_modified'] ),
|
||||||
|
'attempt' => (int) ( $row['attempt'] ?? 1 ),
|
||||||
|
'finished_at' => (string) ( $row['finished_at'] ?? '' ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$live = get_transient( PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT );
|
||||||
|
|
||||||
|
wp_send_json_success(
|
||||||
|
array(
|
||||||
|
'run_status' => (string) ( PRAutoBlogger_Run_State::get_status( $request['run_id'] ) ?? '' ),
|
||||||
|
'stages' => $stages,
|
||||||
|
'live' => is_array( $live ) ? $live : null,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared gate: nonce + capability, then derive run identity from the
|
||||||
|
* POSTed post_id server-side. Sends a JSON error (and exits) on any
|
||||||
|
* failure.
|
||||||
|
*
|
||||||
|
* @param bool $require_stage Whether the request must name a stage row.
|
||||||
|
* @return array{post_id: int, run_id: string, item_key: string, stage: string, agent_role: string}
|
||||||
|
*/
|
||||||
|
private function authorize_and_resolve( bool $require_stage = true ): array {
|
||||||
|
check_ajax_referer( self::NONCE_ACTION, 'nonce' );
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
|
||||||
|
}
|
||||||
|
|
||||||
|
$post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0;
|
||||||
|
$run_id = $post_id > 0 ? (string) get_post_meta( $post_id, '_prautoblogger_run_id', true ) : '';
|
||||||
|
if ( 0 === $post_id || '' === $run_id ) {
|
||||||
|
wp_send_json_error( array( 'message' => __( 'No generation run is recorded for this article.', 'prautoblogger' ) ), 400 );
|
||||||
|
}
|
||||||
|
$idea_hash = (string) get_post_meta( $post_id, '_prautoblogger_idea_hash', true );
|
||||||
|
$item_key = '' !== $idea_hash ? 'idea:' . $idea_hash : '';
|
||||||
|
|
||||||
|
$stage = isset( $_POST['stage'] ) ? sanitize_key( (string) wp_unslash( $_POST['stage'] ) ) : '';
|
||||||
|
$agent_role = isset( $_POST['agent_role'] ) ? sanitize_key( (string) wp_unslash( $_POST['agent_role'] ) ) : '';
|
||||||
|
if ( $require_stage && '' === $stage ) {
|
||||||
|
wp_send_json_error( array( 'message' => __( 'No stage specified.', 'prautoblogger' ) ), 400 );
|
||||||
|
}
|
||||||
|
|
||||||
|
return array(
|
||||||
|
'post_id' => $post_id,
|
||||||
|
'run_id' => $run_id,
|
||||||
|
'item_key' => $item_key,
|
||||||
|
'stage' => $stage,
|
||||||
|
'agent_role' => $agent_role,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The base body an edit forks from: latest saved fork, else the
|
||||||
|
* stage's recorded request_json.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $request Resolved request context.
|
||||||
|
* @return array<string, mixed>|null Decoded body, or null when absent.
|
||||||
|
*/
|
||||||
|
private function resolve_base_body( array $request ): ?array {
|
||||||
|
$fork = PRAutoBlogger_Stage_Input_Store::latest_fork( $request['run_id'], $request['stage'], $request['agent_role'], $request['item_key'] );
|
||||||
|
if ( null !== $fork && ! empty( $fork['request_json'] ) ) {
|
||||||
|
return PRAutoBlogger_Stage_Replay::decode_body( (string) $fork['request_json'] );
|
||||||
|
}
|
||||||
|
$log_rows = ( new PRAutoBlogger_Dossier_Gen_Log_Query() )->get_by_run( $request['run_id'] );
|
||||||
|
$rows = $log_rows[ $request['stage'] ] ?? array();
|
||||||
|
foreach ( array_reverse( $rows ) as $row ) {
|
||||||
|
if ( ! empty( $row['request_json'] ) ) {
|
||||||
|
$body = PRAutoBlogger_Stage_Replay::decode_body( (string) $row['request_json'] );
|
||||||
|
if ( null !== $body ) {
|
||||||
|
return $body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Substitute edited message contents into the base body. Structure
|
||||||
|
* is locked: same message count, same roles, all contents non-empty
|
||||||
|
* strings — so every fork stays a replayable chat body.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $base Decoded base body.
|
||||||
|
* @param array<int, mixed>|null $messages Edited messages payload.
|
||||||
|
* @return array<string, mixed>|null Merged body, or null when invalid.
|
||||||
|
*/
|
||||||
|
private function merge_messages( array $base, ?array $messages ): ?array {
|
||||||
|
if ( null === $messages || count( $messages ) !== count( $base['messages'] ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$merged = $base;
|
||||||
|
foreach ( array_values( $messages ) as $i => $message ) {
|
||||||
|
if ( ! is_array( $message ) || ! isset( $message['role'], $message['content'] )
|
||||||
|
|| ! is_string( $message['role'] ) || ! is_string( $message['content'] ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ( ( $base['messages'][ $i ]['role'] ?? null ) !== $message['role'] ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ( '' === trim( $message['content'] ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$merged['messages'][ $i ]['content'] = $message['content'];
|
||||||
|
}
|
||||||
|
return $merged;
|
||||||
|
}
|
||||||
|
}
|
||||||
275
includes/admin/class-dossier-data-assembler.php
Normal file
275
includes/admin/class-dossier-data-assembler.php
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Assembles the view model for the Article Dossier page.
|
||||||
|
*
|
||||||
|
* Orchestrates 5 queries (all indexed by run_id or post_id):
|
||||||
|
* 1. wp_prautoblogger_runs -- run ledger row
|
||||||
|
* 2. wp_prautoblogger_run_stages -- all stage rows for the run
|
||||||
|
* 3. wp_prautoblogger_generation_log -- per-stage cost + raw trace
|
||||||
|
* 4. wp_prautoblogger_run_decisions -- rationale per stage
|
||||||
|
* 5. post meta (WP object cache) -- run_id + verdict + keywords
|
||||||
|
*
|
||||||
|
* Legacy posts (no run row) return ['has_run' => false] -- never notices.
|
||||||
|
* Amortized research rows (pv=null, role='') render gracefully.
|
||||||
|
*
|
||||||
|
* v0.20.0 (M3): stage rows are filtered to THIS post's item (item_key
|
||||||
|
* from the idea-hash meta; multi-article runs no longer collide) and
|
||||||
|
* gen_log rows exclude entries linked to OTHER posts; per-stage edit/
|
||||||
|
* re-run affordance data + run spend ride the view model
|
||||||
|
* (Dossier_Rerun_Panel_Data); log-only stages (image_a/image_b/
|
||||||
|
* llm_research/... -- generation_log rows with no run_stages row) and
|
||||||
|
* the post's pipeline attachments are exposed so the image sections
|
||||||
|
* render from their REAL data sources (QA M2 F3).
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger_Dossier_Page::render_page().
|
||||||
|
* Dependencies: PRAutoBlogger_Dossier_Gen_Log_Query, PRAutoBlogger_Stage_Display_Map,
|
||||||
|
* PRAutoBlogger_Run_Stage_State.
|
||||||
|
*
|
||||||
|
* @see admin/class-dossier-gen-log-query.php -- Raw generation_log queries.
|
||||||
|
* @see core/class-run-stage-state.php -- Stage I/O access.
|
||||||
|
* @see ARCHITECTURE.md -- Substrate schema.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Dossier_Data_Assembler {
|
||||||
|
|
||||||
|
/** @var PRAutoBlogger_Dossier_Gen_Log_Query */
|
||||||
|
private PRAutoBlogger_Dossier_Gen_Log_Query $log_query;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param PRAutoBlogger_Dossier_Gen_Log_Query|null $log_query Injectable for tests.
|
||||||
|
*/
|
||||||
|
public function __construct( ?PRAutoBlogger_Dossier_Gen_Log_Query $log_query = null ) {
|
||||||
|
$this->log_query = $log_query ?? new PRAutoBlogger_Dossier_Gen_Log_Query();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assemble the full dossier view model for a post.
|
||||||
|
*
|
||||||
|
* @param int $post_id WordPress post ID (0 = bogus/not found).
|
||||||
|
* @return array<string, mixed> View model consumed by dossier-page.php.
|
||||||
|
*/
|
||||||
|
public function assemble( int $post_id ): array {
|
||||||
|
$base = array(
|
||||||
|
'post_id' => $post_id,
|
||||||
|
'has_run' => false,
|
||||||
|
'post_title' => '',
|
||||||
|
'post_status' => '',
|
||||||
|
'verdict' => '',
|
||||||
|
'tier' => '',
|
||||||
|
'run_id' => '',
|
||||||
|
'run' => null,
|
||||||
|
'stages' => array(),
|
||||||
|
'decisions' => array(),
|
||||||
|
'gen_log_index' => array(),
|
||||||
|
'item_key' => '',
|
||||||
|
'eligibility' => array(
|
||||||
|
'ok' => false,
|
||||||
|
'reason' => '',
|
||||||
|
),
|
||||||
|
'spend' => PRAutoBlogger_Dossier_Rerun_Panel_Data::spend( null ),
|
||||||
|
'human_modified_any' => false,
|
||||||
|
'stale_any' => false,
|
||||||
|
'log_only_stages' => array(),
|
||||||
|
'images' => array(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( $post_id <= 0 ) {
|
||||||
|
return $base;
|
||||||
|
}
|
||||||
|
|
||||||
|
$post = get_post( $post_id );
|
||||||
|
if ( ! ( $post instanceof WP_Post ) ) {
|
||||||
|
return $base;
|
||||||
|
}
|
||||||
|
|
||||||
|
$base['post_title'] = $post->post_title;
|
||||||
|
$base['post_status'] = $post->post_status;
|
||||||
|
$base['verdict'] = (string) get_post_meta( $post_id, '_prautoblogger_editor_verdict', true );
|
||||||
|
$base['tier'] = (string) get_post_meta( $post_id, '_prautoblogger_article_type', true );
|
||||||
|
|
||||||
|
$run_id = (string) get_post_meta( $post_id, '_prautoblogger_run_id', true );
|
||||||
|
if ( '' === $run_id ) {
|
||||||
|
return $base;
|
||||||
|
}
|
||||||
|
$base['run_id'] = $run_id;
|
||||||
|
|
||||||
|
$run = $this->get_run_row( $run_id );
|
||||||
|
if ( null === $run ) {
|
||||||
|
return $base;
|
||||||
|
}
|
||||||
|
|
||||||
|
$idea_hash = (string) get_post_meta( $post_id, '_prautoblogger_idea_hash', true );
|
||||||
|
$item_key = '' !== $idea_hash ? 'idea:' . $idea_hash : '';
|
||||||
|
$base['item_key'] = $item_key;
|
||||||
|
|
||||||
|
$base['has_run'] = true;
|
||||||
|
$base['run'] = $run;
|
||||||
|
$base['stages'] = $this->get_stage_rows( $run_id, $item_key );
|
||||||
|
$base['decisions'] = $this->get_decision_rows( $run_id );
|
||||||
|
$base['gen_log_index'] = $this->filter_log_for_post( $this->log_query->get_by_run( $run_id ), $post_id );
|
||||||
|
$base['eligibility'] = PRAutoBlogger_Rerun_Eligibility::check( $run_id, $post_id );
|
||||||
|
$base['spend'] = PRAutoBlogger_Dossier_Rerun_Panel_Data::spend( $run );
|
||||||
|
|
||||||
|
foreach ( $base['stages'] as $role_key => $stage_row ) {
|
||||||
|
$stage_name = (string) ( $stage_row['stage'] ?? '' );
|
||||||
|
$rerun = PRAutoBlogger_Dossier_Rerun_Panel_Data::for_stage(
|
||||||
|
$stage_row,
|
||||||
|
$base['gen_log_index'][ $stage_name ] ?? array(),
|
||||||
|
$run_id,
|
||||||
|
$base['eligibility']
|
||||||
|
);
|
||||||
|
$base['stages'][ $role_key ]['rerun'] = $rerun;
|
||||||
|
$base['human_modified_any'] = $base['human_modified_any'] || $rerun['human_modified'];
|
||||||
|
$base['stale_any'] = $base['stale_any'] || $rerun['stale'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// QA M2 F3: stages that exist only in generation_log (image_a/
|
||||||
|
// image_b, llm_research, image_prompt_rewrite, ...) render from
|
||||||
|
// their real source instead of silently disappearing.
|
||||||
|
$run_stage_names = array();
|
||||||
|
foreach ( $base['stages'] as $stage_row ) {
|
||||||
|
$run_stage_names[ (string) ( $stage_row['stage'] ?? '' ) ] = true;
|
||||||
|
}
|
||||||
|
$base['log_only_stages'] = array_values( array_diff( array_keys( $base['gen_log_index'] ), array_keys( $run_stage_names ) ) );
|
||||||
|
$base['images'] = PRAutoBlogger_Dossier_Image_Data::for_post( $post_id );
|
||||||
|
|
||||||
|
return $base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the run ledger row.
|
||||||
|
*
|
||||||
|
* @param string $run_id Pipeline run UUID.
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private function get_run_row( string $run_id ): ?array {
|
||||||
|
global $wpdb;
|
||||||
|
if ( null === $wpdb ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$table = $wpdb->prefix . 'prautoblogger_runs';
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||||
|
$row = $wpdb->get_row(
|
||||||
|
$wpdb->prepare( "SELECT * FROM {$table} WHERE run_id = %s", $run_id ),
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
return is_array( $row ) ? $row : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch run_stages rows for a run, indexed by stage name — scoped to
|
||||||
|
* one item (+ run-level rows) when an item key is known.
|
||||||
|
*
|
||||||
|
* @param string $run_id Pipeline run UUID.
|
||||||
|
* @param string $item_key Item key ('' = no scoping, M2 behavior).
|
||||||
|
* @return array<string, array<string, mixed>> Stage rows keyed by stage name.
|
||||||
|
*/
|
||||||
|
private function get_stage_rows( string $run_id, string $item_key = '' ): array {
|
||||||
|
if ( ! PRAutoBlogger_Run_Stage_State::is_available() ) {
|
||||||
|
return array();
|
||||||
|
}
|
||||||
|
global $wpdb;
|
||||||
|
if ( null === $wpdb ) {
|
||||||
|
return array();
|
||||||
|
}
|
||||||
|
$table = PRAutoBlogger_Run_Stage_State::table_name();
|
||||||
|
// v0.20.0: scope to THIS post's item + run-level rows. When the
|
||||||
|
// idea-hash meta is absent (pre-v0.18 posts) fall back to the
|
||||||
|
// unfiltered M2 behavior (single-item runs are unaffected).
|
||||||
|
if ( '' !== $item_key ) {
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||||
|
$rows = $wpdb->get_results(
|
||||||
|
$wpdb->prepare( "SELECT * FROM {$table} WHERE run_id = %s AND item_key IN (%s, '') ORDER BY id ASC", $run_id, $item_key ),
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||||
|
$rows = $wpdb->get_results(
|
||||||
|
$wpdb->prepare( "SELECT * FROM {$table} WHERE run_id = %s ORDER BY id ASC", $run_id ),
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$index = array();
|
||||||
|
foreach ( ( $rows ?? array() ) as $row ) {
|
||||||
|
if ( ! is_array( $row ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$stage = (string) ( $row['stage'] ?? '' );
|
||||||
|
$role_key = $stage . ':' . ( $row['agent_role'] ?? '' );
|
||||||
|
$row['display_output'] = $this->extract_output( $row );
|
||||||
|
$index[ $role_key ] = $row;
|
||||||
|
}
|
||||||
|
return $index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop gen_log rows linked to OTHER posts (multi-article runs share
|
||||||
|
* one run_id). Rows with no post linkage (NULL/0 — run-level work
|
||||||
|
* and pre-link entries) are kept.
|
||||||
|
*
|
||||||
|
* @param array<string, array<int, array<string, mixed>>> $log_index Rows keyed by stage.
|
||||||
|
* @param int $post_id This dossier's post.
|
||||||
|
* @return array<string, array<int, array<string, mixed>>>
|
||||||
|
*/
|
||||||
|
private function filter_log_for_post( array $log_index, int $post_id ): array {
|
||||||
|
$filtered = array();
|
||||||
|
foreach ( $log_index as $stage => $rows ) {
|
||||||
|
foreach ( $rows as $row ) {
|
||||||
|
$row_post = (int) ( $row['post_id'] ?? 0 );
|
||||||
|
if ( 0 !== $row_post && $row_post !== $post_id ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$filtered[ $stage ][] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the text output from a run_stages meta_json blob.
|
||||||
|
* Returns null when meta_json is NULL (pruned) or absent.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $row Stage row.
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
private function extract_output( array $row ): ?string {
|
||||||
|
if ( empty( $row['meta_json'] ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$meta = json_decode( (string) $row['meta_json'], true );
|
||||||
|
return ( is_array( $meta ) && isset( $meta['output'] ) ) ? (string) $meta['output'] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all run_decisions rows, indexed by stage name.
|
||||||
|
*
|
||||||
|
* @param string $run_id Pipeline run UUID.
|
||||||
|
* @return array<string, array<string, mixed>> Decision rows keyed by stage.
|
||||||
|
*/
|
||||||
|
private function get_decision_rows( string $run_id ): array {
|
||||||
|
global $wpdb;
|
||||||
|
if ( null === $wpdb ) {
|
||||||
|
return array();
|
||||||
|
}
|
||||||
|
$table = $wpdb->prefix . 'prautoblogger_run_decisions';
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||||
|
$rows = $wpdb->get_results(
|
||||||
|
$wpdb->prepare( "SELECT * FROM {$table} WHERE run_id = %s ORDER BY id ASC", $run_id ),
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
|
||||||
|
$index = array();
|
||||||
|
foreach ( ( $rows ?? array() ) as $row ) {
|
||||||
|
if ( ! is_array( $row ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$stage = (string) ( $row['stage'] ?? 'unknown' );
|
||||||
|
$index[ $stage ] = $row;
|
||||||
|
}
|
||||||
|
return $index;
|
||||||
|
}
|
||||||
|
}
|
||||||
95
includes/admin/class-dossier-gen-log-query.php
Normal file
95
includes/admin/class-dossier-gen-log-query.php
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Raw generation_log queries for the Article Dossier (M2).
|
||||||
|
*
|
||||||
|
* Returns per-stage generation log data indexed by stage name: model,
|
||||||
|
* prompt_version, agent_role, token counts, estimated cost, request_json
|
||||||
|
* (raw trace), and response_status. Amortized research rows (pv=null,
|
||||||
|
* role='') are included and rendered gracefully by the template.
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger_Dossier_Data_Assembler::assemble().
|
||||||
|
* Dependencies: WordPress $wpdb.
|
||||||
|
*
|
||||||
|
* @see admin/class-dossier-data-assembler.php -- Calls get_by_run().
|
||||||
|
* @see ARCHITECTURE.md -- generation_log schema.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Dossier_Gen_Log_Query {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all generation_log rows for a run, indexed by stage.
|
||||||
|
*
|
||||||
|
* Multiple rows per stage (e.g. multi-step draft) are accumulated as an
|
||||||
|
* array so the template can render multiple raw-trace entries per section.
|
||||||
|
*
|
||||||
|
* SECURITY: request_json is stored without API keys (never logged per
|
||||||
|
* architecture). The template must escape this via esc_html() before render.
|
||||||
|
*
|
||||||
|
* @param string $run_id Pipeline run UUID.
|
||||||
|
* @return array<string, array<int, array<string, mixed>>> Rows keyed by stage.
|
||||||
|
*/
|
||||||
|
public function get_by_run( string $run_id ): array {
|
||||||
|
global $wpdb;
|
||||||
|
if ( null === $wpdb || '' === $run_id ) {
|
||||||
|
return array();
|
||||||
|
}
|
||||||
|
|
||||||
|
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||||
|
$rows = $wpdb->get_results(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"SELECT id, stage, model, agent_role, prompt_version,
|
||||||
|
prompt_tokens, completion_tokens, estimated_cost,
|
||||||
|
request_json, response_status, error_message, created_at
|
||||||
|
FROM {$table}
|
||||||
|
WHERE run_id = %s
|
||||||
|
ORDER BY id ASC",
|
||||||
|
$run_id
|
||||||
|
),
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
|
||||||
|
$index = array();
|
||||||
|
foreach ( ( $rows ?? array() ) as $row ) {
|
||||||
|
if ( ! is_array( $row ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$stage = (string) ( $row['stage'] ?? 'unknown' );
|
||||||
|
if ( ! isset( $index[ $stage ] ) ) {
|
||||||
|
$index[ $stage ] = array();
|
||||||
|
}
|
||||||
|
$index[ $stage ][] = $row;
|
||||||
|
}
|
||||||
|
return $index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate cost + token totals per stage for the sidebar receipt.
|
||||||
|
*
|
||||||
|
* @param array<string, array<int, array<string, mixed>>> $log_index Output of get_by_run().
|
||||||
|
* @return array<string, array{cost: float, prompt_tokens: int, completion_tokens: int, calls: int}>
|
||||||
|
*/
|
||||||
|
public function aggregate_per_stage( array $log_index ): array {
|
||||||
|
$totals = array();
|
||||||
|
foreach ( $log_index as $stage => $rows ) {
|
||||||
|
$cost = 0.0;
|
||||||
|
$prompt_tokens = 0;
|
||||||
|
$completion_tokens = 0;
|
||||||
|
foreach ( $rows as $row ) {
|
||||||
|
$cost += (float) ( $row['estimated_cost'] ?? 0 );
|
||||||
|
$prompt_tokens += (int) ( $row['prompt_tokens'] ?? 0 );
|
||||||
|
$completion_tokens += (int) ( $row['completion_tokens'] ?? 0 );
|
||||||
|
}
|
||||||
|
$totals[ $stage ] = array(
|
||||||
|
'cost' => $cost,
|
||||||
|
'prompt_tokens' => $prompt_tokens,
|
||||||
|
'completion_tokens' => $completion_tokens,
|
||||||
|
'calls' => count( $rows ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return $totals;
|
||||||
|
}
|
||||||
|
}
|
||||||
82
includes/admin/class-dossier-image-data.php
Normal file
82
includes/admin/class-dossier-image-data.php
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Resolves a post's pipeline image attachments for the dossier (M3/F3).
|
||||||
|
*
|
||||||
|
* What: The image sections' REAL data source — the attachments the
|
||||||
|
* image pipeline produced (featured + composed og/square variants,
|
||||||
|
* discovered via the `_prautoblogger_image_role` attachment meta
|
||||||
|
* and the post thumbnail), paired in the template with the
|
||||||
|
* image_a/image_b generation_log rows. Replaces the M2 sections
|
||||||
|
* that keyed off run_stages rows the image pipeline never writes
|
||||||
|
* (QA M2 F3 — wiring the dossier to real data was the smaller,
|
||||||
|
* safer change vs. teaching the live image pipeline to write
|
||||||
|
* run_stages; the pipeline restructure is Phase 2b).
|
||||||
|
* Who triggers it: PRAutoBlogger_Dossier_Data_Assembler::assemble().
|
||||||
|
* Dependencies: get_children(), attachment meta, wp_get_attachment_image_url().
|
||||||
|
*
|
||||||
|
* @see admin/class-dossier-data-assembler.php — Caller.
|
||||||
|
* @see templates/admin/dossier-log-stage-section.php — Renders the pairs.
|
||||||
|
* @see ARCHITECTURE.md #24 — M3 design (F3 note).
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Dossier_Image_Data {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The post's pipeline attachments (featured/og/square) via the
|
||||||
|
* `_prautoblogger_image_role` attachment meta — the image sections'
|
||||||
|
* real data source (QA M2 F3).
|
||||||
|
*
|
||||||
|
* @param int $post_id Post ID.
|
||||||
|
* @return array<int, array{id: int, role: string, url: string, is_featured: bool}>
|
||||||
|
*/
|
||||||
|
public static function for_post( int $post_id ): array {
|
||||||
|
$featured_id = (int) get_post_thumbnail_id( $post_id );
|
||||||
|
$children = get_children(
|
||||||
|
array(
|
||||||
|
'post_parent' => $post_id,
|
||||||
|
'post_type' => 'attachment',
|
||||||
|
'numberposts' => 12,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$images = array();
|
||||||
|
$seen = array();
|
||||||
|
foreach ( (array) $children as $attachment ) {
|
||||||
|
$att_id = (int) ( $attachment->ID ?? 0 );
|
||||||
|
if ( $att_id <= 0 ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$role = (string) get_post_meta( $att_id, '_prautoblogger_image_role', true );
|
||||||
|
if ( '' === $role && $att_id !== $featured_id ) {
|
||||||
|
continue; // Not a pipeline attachment.
|
||||||
|
}
|
||||||
|
$url = (string) wp_get_attachment_image_url( $att_id, 'medium' );
|
||||||
|
if ( '' === $url ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seen[ $att_id ] = true;
|
||||||
|
$images[] = array(
|
||||||
|
'id' => $att_id,
|
||||||
|
'role' => '' !== $role ? $role : 'featured',
|
||||||
|
'url' => $url,
|
||||||
|
'is_featured' => $att_id === $featured_id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Featured image attached elsewhere (not a child) still renders.
|
||||||
|
if ( $featured_id > 0 && ! isset( $seen[ $featured_id ] ) ) {
|
||||||
|
$url = (string) wp_get_attachment_image_url( $featured_id, 'medium' );
|
||||||
|
if ( '' !== $url ) {
|
||||||
|
$images[] = array(
|
||||||
|
'id' => $featured_id,
|
||||||
|
'role' => 'featured',
|
||||||
|
'url' => $url,
|
||||||
|
'is_featured' => true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $images;
|
||||||
|
}
|
||||||
|
}
|
||||||
172
includes/admin/class-dossier-page.php
Normal file
172
includes/admin/class-dossier-page.php
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Registers and renders the Article Dossier admin page (M2).
|
||||||
|
*
|
||||||
|
* The dossier is link-accessed -- it is NOT a visible submenu item. It uses
|
||||||
|
* the canonical hidden-admin-page pattern: register under parent 'options.php'
|
||||||
|
* so WordPress computes the hookname as `admin_page_prautoblogger-dossier` at
|
||||||
|
* BOTH registration time AND request time. The page is hidden from nav by
|
||||||
|
* construction (options.php is a built-in WP page with no visible submenu block)
|
||||||
|
* so no $submenu mutation is needed or permitted.
|
||||||
|
*
|
||||||
|
* Why NOT the hide-by-unset pattern (the v0.19.2 bug):
|
||||||
|
* add_submenu_page('prautoblogger-settings', ...) → hookname 'prautoblogger_page_prautoblogger-dossier'
|
||||||
|
* post-registration unset($submenu['prautoblogger-settings'][$idx])
|
||||||
|
* → at request time get_admin_page_parent() no longer finds the slug in $submenu
|
||||||
|
* → WP recomputes hookname in the 'admin_page_*' orphan namespace
|
||||||
|
* → no callback registered there → wp_die(403).
|
||||||
|
* See ARCHITECTURE.md §22b + CONVENTIONS.md §Hidden Admin Pages for the full incident
|
||||||
|
* history (board 404 v0.19.1, dossier 403 v0.19.2).
|
||||||
|
*
|
||||||
|
* Board cards and post-metabox deep-link here via
|
||||||
|
* admin.php?page=prautoblogger-dossier&post_id=X. Deep-link URLs are parent-agnostic
|
||||||
|
* (they reference only the page slug) so they are unchanged by this fix.
|
||||||
|
*
|
||||||
|
* Design contract: Proposal C "Editorial Record" -- warm editorial dossier,
|
||||||
|
* verdict pills, receipt-style cost sidebar, per-stage I/O with raw-trace toggle.
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger::register_admin_hooks() on `admin_menu` (priority 12).
|
||||||
|
* Dependencies: PRAutoBlogger_Dossier_Data_Assembler, templates/admin/dossier-page.php.
|
||||||
|
*
|
||||||
|
* @see admin/class-dossier-data-assembler.php -- Builds the view model.
|
||||||
|
* @see templates/admin/dossier-page.php -- HTML template.
|
||||||
|
* @see ARCHITECTURE.md -- §22b (hidden admin page convention).
|
||||||
|
* @see CONVENTIONS.md -- §Hidden Admin Pages.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Dossier_Page {
|
||||||
|
|
||||||
|
/** Admin page slug. */
|
||||||
|
public const PAGE_SLUG = 'prautoblogger-dossier';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parent slug used at registration.
|
||||||
|
* 'options.php' is the canonical WP hidden-page parent: the hookname resolves
|
||||||
|
* to `admin_page_{slug}` at both registration time and request time, so there is
|
||||||
|
* no hookname mismatch and no need to manipulate $submenu.
|
||||||
|
*/
|
||||||
|
public const PARENT_SLUG = 'options.php';
|
||||||
|
|
||||||
|
/** Nonce action for any future stateful actions. */
|
||||||
|
public const NONCE_ACTION = 'prautoblogger_dossier';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the dossier as a hidden admin page.
|
||||||
|
*
|
||||||
|
* Uses the canonical options.php-parent pattern so the hookname is stable at
|
||||||
|
* both registration time and request time. No $submenu manipulation required
|
||||||
|
* or permitted (see CONVENTIONS.md §Hidden Admin Pages).
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function on_register_menu(): void {
|
||||||
|
add_submenu_page(
|
||||||
|
self::PARENT_SLUG,
|
||||||
|
__( 'Article Dossier', 'prautoblogger' ),
|
||||||
|
__( 'Dossier', 'prautoblogger' ),
|
||||||
|
'manage_options',
|
||||||
|
self::PAGE_SLUG,
|
||||||
|
array( $this, 'render_page' )
|
||||||
|
);
|
||||||
|
// No $submenu mutation. options.php-parent pages are hidden by construction.
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the dossier page.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function render_page(): void {
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only dossier; no state changes.
|
||||||
|
$post_id = isset( $_GET['post_id'] ) ? absint( $_GET['post_id'] ) : 0;
|
||||||
|
$assembler = new PRAutoBlogger_Dossier_Data_Assembler();
|
||||||
|
$dossier = $assembler->assemble( $post_id );
|
||||||
|
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/dossier-page.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueue dossier-specific CSS + JS.
|
||||||
|
*
|
||||||
|
* The hook suffix for an options.php-parent page is `admin_page_{slug}`.
|
||||||
|
*
|
||||||
|
* @param string $hook_suffix Current admin page hook suffix.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function on_enqueue_assets( string $hook_suffix ): void {
|
||||||
|
// options.php-parent pages resolve to 'admin_page_{slug}' (not 'prautoblogger_page_*').
|
||||||
|
$dossier_hook = 'admin_page_' . self::PAGE_SLUG;
|
||||||
|
if ( $hook_suffix !== $dossier_hook ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wp_enqueue_style(
|
||||||
|
'prautoblogger-dossier',
|
||||||
|
PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/dossier.css',
|
||||||
|
array( 'prautoblogger-admin' ),
|
||||||
|
PRAUTOBLOGGER_VERSION
|
||||||
|
);
|
||||||
|
|
||||||
|
wp_enqueue_script(
|
||||||
|
'prautoblogger-dossier',
|
||||||
|
PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/dossier.js',
|
||||||
|
array(),
|
||||||
|
PRAUTOBLOGGER_VERSION,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
// M3: edit + re-run layer (separate files; dossier.css is not grown).
|
||||||
|
wp_enqueue_style(
|
||||||
|
'prautoblogger-dossier-edit',
|
||||||
|
PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/dossier-edit.css',
|
||||||
|
array( 'prautoblogger-dossier' ),
|
||||||
|
PRAUTOBLOGGER_VERSION
|
||||||
|
);
|
||||||
|
wp_enqueue_script(
|
||||||
|
'prautoblogger-dossier-edit',
|
||||||
|
PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/dossier-edit.js',
|
||||||
|
array(),
|
||||||
|
PRAUTOBLOGGER_VERSION,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only context resolution for script config.
|
||||||
|
$post_id = isset( $_GET['post_id'] ) ? absint( $_GET['post_id'] ) : 0;
|
||||||
|
wp_localize_script(
|
||||||
|
'prautoblogger-dossier-edit',
|
||||||
|
'prabDossierEdit',
|
||||||
|
array(
|
||||||
|
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||||
|
'nonce' => wp_create_nonce( PRAutoBlogger_Dossier_Actions::NONCE_ACTION ),
|
||||||
|
'postId' => $post_id,
|
||||||
|
// Settings read at point of use -- same poll setting as the board.
|
||||||
|
'pollInterval' => max( 3, (int) get_option( 'prautoblogger_board_poll_interval', 5 ) ),
|
||||||
|
'strings' => array(
|
||||||
|
'saving' => __( 'Saving…', 'prautoblogger' ),
|
||||||
|
'error' => __( 'Request failed — try again.', 'prautoblogger' ),
|
||||||
|
'queued' => __( 'Queued…', 'prautoblogger' ),
|
||||||
|
'rerunFromLabel' => __( 'Re-run from here', 'prautoblogger' ),
|
||||||
|
'confirmReplay' => __( 'Queue a re-run of this stage with your edited input? This makes a new LLM call billed against this run\'s ceiling, and all downstream stages will be marked stale (nothing re-runs automatically).', 'prautoblogger' ),
|
||||||
|
'confirmRerunFrom' => __( 'Queue a re-run of this stage AND every stage after it? Each re-run stage makes new LLM calls billed against this run\'s ceiling. The article draft will be refreshed; published posts are never touched.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the URL to the dossier for a given post.
|
||||||
|
*
|
||||||
|
* Deep-link URLs use admin.php?page=<slug> which is parent-agnostic.
|
||||||
|
* This method is unchanged from v0.19.2 -- only the registration parent changed.
|
||||||
|
*
|
||||||
|
* @param int $post_id WordPress post ID.
|
||||||
|
* @return string Admin URL to the dossier page.
|
||||||
|
*/
|
||||||
|
public static function url_for_post( int $post_id ): string {
|
||||||
|
return admin_url( 'admin.php?page=' . self::PAGE_SLUG . '&post_id=' . $post_id );
|
||||||
|
}
|
||||||
|
}
|
||||||
181
includes/admin/class-dossier-rerun-panel-data.php
Normal file
181
includes/admin/class-dossier-rerun-panel-data.php
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Builds the edit + re-run view-model layer for the dossier (v0.20.0, M3).
|
||||||
|
*
|
||||||
|
* What: Per-stage affordance data (editable? why not? latest fork,
|
||||||
|
* prefill messages from the fork or the stage's recorded
|
||||||
|
* request_json) plus the run-level spend strip (settled + reserved
|
||||||
|
* vs ceiling, warn fraction — guardrail 4's visible cost
|
||||||
|
* implication) and the overall eligibility verdict. Pure
|
||||||
|
* assembly: reads the rows the Dossier_Data_Assembler already
|
||||||
|
* fetched plus the stage_inputs store; performs NO mutations.
|
||||||
|
* Disabled affordances always carry an operator-readable reason —
|
||||||
|
* the UI never hides why an edit is unavailable.
|
||||||
|
* Who triggers it: PRAutoBlogger_Dossier_Data_Assembler::assemble().
|
||||||
|
* Dependencies: Rerun_Eligibility (policy), Stage_Input_Store (forks),
|
||||||
|
* Stage_Replay (body decode).
|
||||||
|
*
|
||||||
|
* @see admin/class-dossier-data-assembler.php — Caller.
|
||||||
|
* @see templates/admin/dossier-edit-panel.php — Renders this data.
|
||||||
|
* @see ARCHITECTURE.md #24 — Edit + re-run design.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Dossier_Rerun_Panel_Data {
|
||||||
|
|
||||||
|
/** Default warn threshold as a fraction of the per-run ceiling. */
|
||||||
|
private const DEFAULT_WARN_FRACTION = 0.8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run-level spend strip data (guardrail 4 visibility).
|
||||||
|
*
|
||||||
|
* @param array<string, mixed>|null $run Run ledger row, or null.
|
||||||
|
* @return array{settled: float, reserved: float, ceiling: float, fraction: float, warn: bool}
|
||||||
|
*/
|
||||||
|
public static function spend( ?array $run ): array {
|
||||||
|
$settled = null !== $run ? (float) ( $run['settled_usd'] ?? 0 ) : 0.0;
|
||||||
|
$reserved = null !== $run ? (float) ( $run['reserved_usd'] ?? 0 ) : 0.0;
|
||||||
|
$ceiling = null !== $run ? (float) ( $run['ceiling_usd'] ?? 0 ) : 0.0;
|
||||||
|
$fraction = $ceiling > 0 ? ( $settled + $reserved ) / $ceiling : 0.0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters the spend fraction at which re-run confirms warn about
|
||||||
|
* approaching the per-run ceiling.
|
||||||
|
*
|
||||||
|
* @param float $warn_fraction Fraction of ceiling (default 0.8).
|
||||||
|
*/
|
||||||
|
$warn_fraction = (float) apply_filters( 'prautoblogger_rerun_ceiling_warn_fraction', self::DEFAULT_WARN_FRACTION );
|
||||||
|
|
||||||
|
return array(
|
||||||
|
'settled' => $settled,
|
||||||
|
'reserved' => $reserved,
|
||||||
|
'ceiling' => $ceiling,
|
||||||
|
'fraction' => $fraction,
|
||||||
|
'warn' => $ceiling > 0 && $fraction >= $warn_fraction,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit + re-run affordance data for one stage row.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $stage_row run_stages row.
|
||||||
|
* @param array<int, array<string, mixed>> $log_rows generation_log rows for the stage.
|
||||||
|
* @param string $run_id Run UUID.
|
||||||
|
* @param array{ok: bool, reason: string} $eligibility Overall run eligibility.
|
||||||
|
* @return array<string, mixed> Affordance view model (see keys below).
|
||||||
|
*/
|
||||||
|
public static function for_stage( array $stage_row, array $log_rows, string $run_id, array $eligibility ): array {
|
||||||
|
$stage = (string) ( $stage_row['stage'] ?? '' );
|
||||||
|
$role = (string) ( $stage_row['agent_role'] ?? '' );
|
||||||
|
$item_key = (string) ( $stage_row['item_key'] ?? '' );
|
||||||
|
|
||||||
|
$data = array(
|
||||||
|
'stale' => ! empty( $stage_row['stale'] ),
|
||||||
|
'human_modified' => ! empty( $stage_row['human_modified'] ),
|
||||||
|
'attempt' => (int) ( $stage_row['attempt'] ?? 1 ),
|
||||||
|
'editable' => false,
|
||||||
|
'edit_reason' => '',
|
||||||
|
'fork' => null,
|
||||||
|
'prefill' => null,
|
||||||
|
'rerun_from' => $eligibility['ok'] && PRAutoBlogger_Rerun_Eligibility::is_chain_stage( $stage ),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( ! PRAutoBlogger_Rerun_Eligibility::is_editable_stage( $stage ) ) {
|
||||||
|
$data['edit_reason'] = self::policy_reason( $stage );
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
if ( ! $eligibility['ok'] ) {
|
||||||
|
$data['edit_reason'] = $eligibility['reason'];
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fork = PRAutoBlogger_Stage_Input_Store::latest_fork( $run_id, $stage, $role, $item_key );
|
||||||
|
if ( null !== $fork ) {
|
||||||
|
$data['fork'] = array(
|
||||||
|
'version' => (int) $fork['version'],
|
||||||
|
'author' => (string) $fork['author'],
|
||||||
|
'created_at' => (string) $fork['created_at'],
|
||||||
|
'has_body' => ! empty( $fork['request_json'] ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$base_json = ( null !== $fork && ! empty( $fork['request_json'] ) )
|
||||||
|
? (string) $fork['request_json']
|
||||||
|
: self::latest_request_json( $log_rows );
|
||||||
|
|
||||||
|
if ( null === $base_json ) {
|
||||||
|
$days = (int) get_option( 'prautoblogger_request_json_retention_days', PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS );
|
||||||
|
$data['edit_reason'] = sprintf(
|
||||||
|
/* translators: %d: retention days. */
|
||||||
|
__( 'No persisted input for this stage — request bodies are recorded from v0.20.0 onward and retained for %d days.', 'prautoblogger' ),
|
||||||
|
$days
|
||||||
|
);
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = PRAutoBlogger_Stage_Replay::decode_body( $base_json );
|
||||||
|
if ( null === $body ) {
|
||||||
|
$data['edit_reason'] = __( 'The persisted input for this stage is not a replayable chat request.', 'prautoblogger' );
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data['editable'] = true;
|
||||||
|
$data['prefill'] = array(
|
||||||
|
'model' => (string) $body['model'],
|
||||||
|
'messages' => $body['messages'],
|
||||||
|
);
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why a non-writer stage has no edit affordance (always shown —
|
||||||
|
* the UI never silently hides the option).
|
||||||
|
*
|
||||||
|
* @param string $stage Stage name.
|
||||||
|
* @return string Operator-readable reason.
|
||||||
|
*/
|
||||||
|
private static function policy_reason( string $stage ): string {
|
||||||
|
if ( 'review' === $stage ) {
|
||||||
|
return __( 'The review input is derived entirely from the article content — edit a writing stage, then use "Re-run from here" on this one.', 'prautoblogger' );
|
||||||
|
}
|
||||||
|
if ( 'publish' === $stage ) {
|
||||||
|
return __( 'Publish is not an LLM stage — use "Re-run from here" to refresh the post from current outputs.', 'prautoblogger' );
|
||||||
|
}
|
||||||
|
if ( in_array( $stage, array( 'analysis', 'research', 'llm_research' ), true ) ) {
|
||||||
|
return __( 'Run-level stage: its output fed idea selection for the whole run and cannot be re-flowed into a single article.', 'prautoblogger' );
|
||||||
|
}
|
||||||
|
if ( 0 === strpos( $stage, 'image' ) ) {
|
||||||
|
return __( 'Image stages are not replayable yet (Phase 2b restructure).', 'prautoblogger' );
|
||||||
|
}
|
||||||
|
return __( 'This stage cannot be replayed with an edited input.', 'prautoblogger' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The most recent recorded request body among a stage's log rows
|
||||||
|
* (success rows preferred, then latest id).
|
||||||
|
*
|
||||||
|
* @param array<int, array<string, mixed>> $log_rows generation_log rows.
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
private static function latest_request_json( array $log_rows ): ?string {
|
||||||
|
$best = null;
|
||||||
|
foreach ( $log_rows as $row ) {
|
||||||
|
if ( empty( $row['request_json'] ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ( null === $best ) {
|
||||||
|
$best = $row;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$row_success = ( 'success' === ( $row['response_status'] ?? '' ) );
|
||||||
|
$best_success = ( 'success' === ( $best['response_status'] ?? '' ) );
|
||||||
|
if ( ( $row_success && ! $best_success )
|
||||||
|
|| ( $row_success === $best_success && (int) ( $row['id'] ?? 0 ) > (int) ( $best['id'] ?? 0 ) ) ) {
|
||||||
|
$best = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null !== $best ? (string) $best['request_json'] : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
136
includes/admin/class-ideas-browser-query.php
Normal file
136
includes/admin/class-ideas-browser-query.php
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Query helpers for PRAutoBlogger_Ideas_Browser.
|
||||||
|
*
|
||||||
|
* Extracted to keep class-ideas-browser.php under 300 lines (CONVENTIONS §1).
|
||||||
|
* All methods are static; class is never instantiated.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Ideas_Browser_Query {
|
||||||
|
|
||||||
|
/** Fall-through if setting is absent. */
|
||||||
|
public const PER_PAGE_DEFAULT = 30;
|
||||||
|
|
||||||
|
/** Transient key prefix for per-idea generation status. */
|
||||||
|
public const STATUS_PREFIX = 'prab_idea_gen_';
|
||||||
|
|
||||||
|
/** How long to keep per-idea status (seconds). */
|
||||||
|
public const STATUS_TTL = 600;
|
||||||
|
|
||||||
|
/** Query analysis results with optional filtering and pagination. */
|
||||||
|
public static function query_ideas( int $paged, string $type ): array {
|
||||||
|
global $wpdb;
|
||||||
|
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
|
||||||
|
$where = array();
|
||||||
|
$params = array();
|
||||||
|
|
||||||
|
if ( '' !== $type ) {
|
||||||
|
$where[] = 'analysis_type = %s';
|
||||||
|
$params[] = $type;
|
||||||
|
}
|
||||||
|
|
||||||
|
$where_sql = empty( $where ) ? '' : 'WHERE ' . implode( ' AND ', $where );
|
||||||
|
$per_page = max( 5, (int) get_option( 'prautoblogger_ideas_per_page', self::PER_PAGE_DEFAULT ) );
|
||||||
|
$offset = ( $paged - 1 ) * $per_page;
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
$total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
empty( $params ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||||
|
? "SELECT COUNT(*) FROM {$table} {$where_sql}" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||||
|
: $wpdb->prepare( "SELECT COUNT(*) FROM {$table} {$where_sql}", ...$params ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
||||||
|
);
|
||||||
|
|
||||||
|
$order_sql = 'ORDER BY analyzed_at DESC, relevance_score DESC';
|
||||||
|
$limit_sql = sprintf( 'LIMIT %d OFFSET %d', $per_page, $offset );
|
||||||
|
$full_sql = "SELECT * FROM {$table} {$where_sql} {$order_sql} {$limit_sql}";
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
$rows = empty( $params ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||||
|
? $wpdb->get_results( $full_sql, ARRAY_A ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||||
|
: $wpdb->get_results( $wpdb->prepare( $full_sql, ...$params ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
||||||
|
|
||||||
|
return array(
|
||||||
|
'rows' => $rows ?? array(),
|
||||||
|
'total' => $total,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get counts per analysis_type for the filter badges. */
|
||||||
|
public static function get_type_counts(): array {
|
||||||
|
global $wpdb;
|
||||||
|
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
$rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
"SELECT analysis_type, COUNT(*) AS cnt FROM {$table} GROUP BY analysis_type ORDER BY cnt DESC",
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
|
||||||
|
$counts = array();
|
||||||
|
foreach ( $rows ?? array() as $row ) {
|
||||||
|
$counts[ $row['analysis_type'] ] = (int) $row['cnt'];
|
||||||
|
}
|
||||||
|
return $counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load an analysis result row and map it to Article_Idea constructor data.
|
||||||
|
*
|
||||||
|
* @param int $idea_id Analysis result row ID.
|
||||||
|
* @return array<string, mixed>|null Article_Idea-compatible array, or null.
|
||||||
|
*/
|
||||||
|
public static function load_idea_data( int $idea_id ): ?array {
|
||||||
|
global $wpdb;
|
||||||
|
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
$row = $wpdb->get_row( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
$wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $idea_id ),
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
if ( ! $row ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$meta = json_decode( $row['metadata_json'] ?? '{}', true ) ?? array();
|
||||||
|
return array(
|
||||||
|
'topic' => $row['topic'],
|
||||||
|
'article_type' => $row['analysis_type'],
|
||||||
|
'suggested_title' => $meta['suggested_title'] ?? $row['topic'],
|
||||||
|
'summary' => $row['summary'] ?? '',
|
||||||
|
'score' => (float) $row['relevance_score'],
|
||||||
|
'analysis_id' => (int) $row['id'],
|
||||||
|
'source_ids' => json_decode( $row['source_ids_json'] ?? '[]', true ) ?? array(),
|
||||||
|
'key_points' => $meta['key_points'] ?? array(),
|
||||||
|
'target_keywords' => $meta['target_keywords'] ?? array(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find the most recent post created by a specific run_id. */
|
||||||
|
public static function find_post_by_run_id( string $run_id ): ?int {
|
||||||
|
global $wpdb;
|
||||||
|
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
$post_id = $wpdb->get_var(
|
||||||
|
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
"SELECT DISTINCT post_id FROM {$table} WHERE run_id = %s AND post_id IS NOT NULL LIMIT 1",
|
||||||
|
$run_id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return null !== $post_id ? (int) $post_id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update the per-idea status transient with a stage message. */
|
||||||
|
public static function update_idea_stage( int $idea_id, string $stage ): void {
|
||||||
|
$key = self::STATUS_PREFIX . $idea_id;
|
||||||
|
$current = get_transient( $key );
|
||||||
|
if ( is_array( $current ) ) {
|
||||||
|
$current['stage'] = $stage;
|
||||||
|
set_transient( $key, $current, self::STATUS_TTL );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
223
includes/admin/class-ideas-browser.php
Normal file
223
includes/admin/class-ideas-browser.php
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Admin page listing analysis results (article ideas) with per-idea generation.
|
||||||
|
*
|
||||||
|
* 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 {
|
||||||
|
|
||||||
|
/** Register the Ideas submenu page under PRAutoBlogger. */
|
||||||
|
public function on_register_menu(): void {
|
||||||
|
add_submenu_page(
|
||||||
|
'prautoblogger-settings',
|
||||||
|
__( 'Ideas', 'prautoblogger' ),
|
||||||
|
__( 'Ideas', 'prautoblogger' ),
|
||||||
|
'manage_options',
|
||||||
|
'prautoblogger-ideas',
|
||||||
|
array( $this, 'render_page' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render the ideas browser page. */
|
||||||
|
public function render_page(): void {
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$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();
|
||||||
|
|
||||||
|
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/ideas-browser.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX: trigger article generation from a specific idea.
|
||||||
|
*
|
||||||
|
* Loads the analysis result, stores it as a transient, sets a per-idea
|
||||||
|
* status transient, and schedules a one-shot cron event so the actual
|
||||||
|
* generation runs in a separate PHP process (Hostinger 120s limit).
|
||||||
|
*
|
||||||
|
* Side effects: transient writes, cron scheduling.
|
||||||
|
*/
|
||||||
|
public function on_ajax_generate_from_idea(): void {
|
||||||
|
check_ajax_referer( 'prautoblogger_idea_gen', 'nonce' );
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
wp_send_json_error( array( 'message' => 'Insufficient permissions.' ), 403 );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$idea_id = absint( $_POST['idea_id'] ?? 0 );
|
||||||
|
if ( $idea_id < 1 ) {
|
||||||
|
wp_send_json_error( array( 'message' => 'Invalid idea ID.' ) );
|
||||||
|
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 );
|
||||||
|
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(
|
||||||
|
$status_prefix . $idea_id,
|
||||||
|
array(
|
||||||
|
'status' => 'running',
|
||||||
|
'stage' => __( 'Starting generation…', 'prautoblogger' ),
|
||||||
|
'started' => time(),
|
||||||
|
),
|
||||||
|
$status_ttl
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schedule background cron — passes idea_id as argument.
|
||||||
|
$hook = 'prautoblogger_generate_from_idea';
|
||||||
|
if ( ! wp_next_scheduled( $hook, array( $idea_id ) ) ) {
|
||||||
|
wp_schedule_single_event( time(), $hook, array( $idea_id ) );
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
wp_send_json_success( array( 'message' => 'Generation started.' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX: return per-idea generation status for frontend polling.
|
||||||
|
*
|
||||||
|
* Side effects: none (reads transient only).
|
||||||
|
*/
|
||||||
|
public function on_ajax_idea_gen_status(): void {
|
||||||
|
check_ajax_referer( 'prautoblogger_idea_gen', 'nonce' );
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
wp_send_json_error( array( 'message' => 'Insufficient permissions.' ), 403 );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$idea_id = absint( $_POST['idea_id'] ?? 0 );
|
||||||
|
$status = get_transient( PRAutoBlogger_Ideas_Browser_Query::STATUS_PREFIX . $idea_id );
|
||||||
|
if ( ! is_array( $status ) ) {
|
||||||
|
wp_send_json_success( array( 'status' => 'idle' ) );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wp_send_json_success( $status );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron handler: generate a single article from a stored idea.
|
||||||
|
*
|
||||||
|
* Runs in a background PHP process. Acquires the generation lock,
|
||||||
|
* runs Article_Worker, and writes the result to the per-idea status transient.
|
||||||
|
*
|
||||||
|
* Side effects: LLM API calls, DB writes, post creation, cost logging.
|
||||||
|
*
|
||||||
|
* @param int $idea_id Analysis result row ID.
|
||||||
|
*/
|
||||||
|
public static function on_cron_generate_from_idea( int $idea_id ): void {
|
||||||
|
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||||
|
@ignore_user_abort( true );
|
||||||
|
// 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 );
|
||||||
|
if ( ! is_array( $idea_data ) ) {
|
||||||
|
set_transient(
|
||||||
|
$key,
|
||||||
|
array(
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Idea data expired.',
|
||||||
|
),
|
||||||
|
$ttl
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! PRAutoBlogger_Generation_Lock::acquire() ) {
|
||||||
|
set_transient(
|
||||||
|
$key,
|
||||||
|
array(
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Another generation is running.',
|
||||||
|
),
|
||||||
|
$ttl
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$idea = new PRAutoBlogger_Article_Idea( $idea_data );
|
||||||
|
$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' ) );
|
||||||
|
$worker = new PRAutoBlogger_Article_Worker( $cost_tracker );
|
||||||
|
$result = $worker->generate( $idea );
|
||||||
|
|
||||||
|
// Amortize any research costs for this single-article run.
|
||||||
|
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() );
|
||||||
|
|
||||||
|
set_transient(
|
||||||
|
$key,
|
||||||
|
array(
|
||||||
|
'status' => 'complete',
|
||||||
|
'generated' => $result['generated'],
|
||||||
|
'published' => $result['published'],
|
||||||
|
'cost' => $result['cost'],
|
||||||
|
'post_id' => $post_id,
|
||||||
|
),
|
||||||
|
$ttl
|
||||||
|
);
|
||||||
|
} catch ( \Throwable $e ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->error(
|
||||||
|
sprintf( 'Idea generation %s for #%d: %s', get_class( $e ), $idea_id, $e->getMessage() ),
|
||||||
|
'pipeline'
|
||||||
|
);
|
||||||
|
set_transient(
|
||||||
|
$key,
|
||||||
|
array(
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
),
|
||||||
|
$ttl
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PRAutoBlogger_Generation_Lock::release();
|
||||||
|
delete_transient( $prefix . 'data_' . $idea_id );
|
||||||
|
}
|
||||||
|
}
|
||||||
264
includes/admin/class-image-model-registry.php
Normal file
264
includes/admin/class-image-model-registry.php
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Static registry of available image generation models.
|
||||||
|
*
|
||||||
|
* What: Returns a merged list of Runware models (from live catalog sync) and
|
||||||
|
* OpenRouter models (static list). No API discovery for OpenRouter — updated
|
||||||
|
* manually when new models are available.
|
||||||
|
* As of 2026-04-26, registry includes 15 Runware models (live) + 6 OpenRouter
|
||||||
|
* models (static), ordered cheapest-to-most-expensive within each provider.
|
||||||
|
* Who calls it: PRAutoBlogger_Admin_Page (model picker UI + save-time provider
|
||||||
|
* derivation) and PRAutoBlogger_Image_Pipeline (provider lookup).
|
||||||
|
* Dependencies: PRAutoBlogger_Runware_Model_Catalog (live Runware models),
|
||||||
|
* hardcoded fallback lists for resilience.
|
||||||
|
*
|
||||||
|
* @see admin/class-settings-fields-extended.php — Image settings reference this registry.
|
||||||
|
* @see core/class-image-pipeline.php — Derives provider from picked model.
|
||||||
|
* @see providers/class-runware-image-provider.php — Runware FLUX backend.
|
||||||
|
* @see providers/class-runware-model-catalog.php — Live model sync and caching.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Image_Model_Registry {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all available image generation models (Runware live + OpenRouter static).
|
||||||
|
*
|
||||||
|
* Each entry contains:
|
||||||
|
* - id: Model identifier used in API calls.
|
||||||
|
* - name: Human-readable display name.
|
||||||
|
* - provider: 'runware' | 'openrouter'.
|
||||||
|
* - cost_per_image: Estimated USD cost per generation.
|
||||||
|
* - capabilities: Array of capability tags.
|
||||||
|
* - description: Short description for the admin UI.
|
||||||
|
*
|
||||||
|
* Ordered cheapest-to-most-expensive within each provider, with the
|
||||||
|
* recommended default (Runware schnell) at the top.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public static function get_models(): array {
|
||||||
|
$catalog = new PRAutoBlogger_Runware_Model_Catalog();
|
||||||
|
$runware_models = $catalog->get_models();
|
||||||
|
$openrouter_models = self::get_openrouter_models();
|
||||||
|
return array_merge( $runware_models, $openrouter_models );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the provider id for a known model, or empty string if the
|
||||||
|
* model id is not in the registry.
|
||||||
|
*
|
||||||
|
* @param string $model_id Model slug from the admin UI.
|
||||||
|
* @return string Provider id ('runware' | 'openrouter' | '').
|
||||||
|
*/
|
||||||
|
public static function provider_for( string $model_id ): string {
|
||||||
|
foreach ( self::get_models() as $model ) {
|
||||||
|
if ( ( $model['id'] ?? '' ) === $model_id ) {
|
||||||
|
return (string) ( $model['provider'] ?? '' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hardcoded Runware model list used as fallback when the live catalog
|
||||||
|
* sync fails or API is unreachable. Kept in sync with actual Runware
|
||||||
|
* pricing page (runware.ai/pricing). This is the source of truth for
|
||||||
|
* pricing data when the Runware API doesn't expose cost information.
|
||||||
|
*
|
||||||
|
* Last verified: 2026-04-26. To update: check runware.ai/pricing for new
|
||||||
|
* text-to-image models and their costs.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public static function get_runware_fallback_models(): array {
|
||||||
|
return array(
|
||||||
|
array(
|
||||||
|
'id' => 'runware:100@1',
|
||||||
|
'name' => 'FLUX.1 schnell (Runware)',
|
||||||
|
'provider' => 'runware',
|
||||||
|
'cost_per_image' => 0.0006,
|
||||||
|
'capabilities' => array( 'image_generation' ),
|
||||||
|
'description' => __( 'Default. Fast, very cheap, comic-friendly fidelity.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'runware:twinflow-z-image-turbo@0',
|
||||||
|
'name' => 'TwinFlow Z-Image-Turbo (Runware)',
|
||||||
|
'provider' => 'runware',
|
||||||
|
'cost_per_image' => 0.0006,
|
||||||
|
'capabilities' => array( 'image_generation' ),
|
||||||
|
'description' => __( 'Fast text-to-image, same cost as schnell.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'runware:400@2',
|
||||||
|
'name' => 'FLUX.2 klein 9B (Runware)',
|
||||||
|
'provider' => 'runware',
|
||||||
|
'cost_per_image' => 0.00078,
|
||||||
|
'capabilities' => array( 'image_generation' ),
|
||||||
|
'description' => __( 'FLUX.2 compact 9B. Stronger than schnell at similar cost.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'runware:400@4',
|
||||||
|
'name' => 'FLUX.2 klein 4B (Runware)',
|
||||||
|
'provider' => 'runware',
|
||||||
|
'cost_per_image' => 0.0006,
|
||||||
|
'capabilities' => array( 'image_generation' ),
|
||||||
|
'description' => __( 'FLUX.2 compact 4B. Newer architecture at schnell price.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'runware:5@1',
|
||||||
|
'name' => 'Stable Diffusion 3 (Runware)',
|
||||||
|
'provider' => 'runware',
|
||||||
|
'cost_per_image' => 0.0013,
|
||||||
|
'capabilities' => array( 'image_generation' ),
|
||||||
|
'description' => __( 'Stable Diffusion 3. Sharp text, complex scenes.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'runware:z-image@turbo',
|
||||||
|
'name' => 'Z-Image Turbo (Runware)',
|
||||||
|
'provider' => 'runware',
|
||||||
|
'cost_per_image' => 0.0013,
|
||||||
|
'capabilities' => array( 'image_generation' ),
|
||||||
|
'description' => __( 'Fast photorealistic generation.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'runware:101@1',
|
||||||
|
'name' => 'FLUX.1 dev (Runware)',
|
||||||
|
'provider' => 'runware',
|
||||||
|
'cost_per_image' => 0.02,
|
||||||
|
'capabilities' => array( 'image_generation' ),
|
||||||
|
'description' => __( 'Higher fidelity FLUX.1. ~30x schnell cost.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'runware:97@3',
|
||||||
|
'name' => 'HiDream-I1 Fast (Runware)',
|
||||||
|
'provider' => 'runware',
|
||||||
|
'cost_per_image' => 0.0038,
|
||||||
|
'capabilities' => array( 'image_generation' ),
|
||||||
|
'description' => __( 'HiDream-I1 Fast. Low latency, 17B quality.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'runware:97@2',
|
||||||
|
'name' => 'HiDream-I1 Dev (Runware)',
|
||||||
|
'provider' => 'runware',
|
||||||
|
'cost_per_image' => 0.0045,
|
||||||
|
'capabilities' => array( 'image_generation' ),
|
||||||
|
'description' => __( 'HiDream-I1 Dev. Strong prompt alignment.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'runware:z-image@0',
|
||||||
|
'name' => 'Z-Image (Runware)',
|
||||||
|
'provider' => 'runware',
|
||||||
|
'cost_per_image' => 0.0045,
|
||||||
|
'capabilities' => array( 'image_generation' ),
|
||||||
|
'description' => __( 'High-quality Z-Image foundation model.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'runware:400@1',
|
||||||
|
'name' => 'FLUX.2 dev (Runware)',
|
||||||
|
'provider' => 'runware',
|
||||||
|
'cost_per_image' => 0.0051,
|
||||||
|
'capabilities' => array( 'image_generation' ),
|
||||||
|
'description' => __( 'FLUX.2 dev. Controllable open text-to-image. From $0.0051.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'runware:108@1',
|
||||||
|
'name' => 'Qwen-Image (Runware)',
|
||||||
|
'provider' => 'runware',
|
||||||
|
'cost_per_image' => 0.0058,
|
||||||
|
'capabilities' => array( 'image_generation' ),
|
||||||
|
'description' => __( 'Alibaba Qwen-Image. Strong text rendering in generated images.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'runware:97@1',
|
||||||
|
'name' => 'HiDream-I1 Full (Runware)',
|
||||||
|
'provider' => 'runware',
|
||||||
|
'cost_per_image' => 0.009,
|
||||||
|
'capabilities' => array( 'image_generation' ),
|
||||||
|
'description' => __( 'HiDream-I1 Full. 17B high-fidelity, LoRA support.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'runware:107@1',
|
||||||
|
'name' => 'FLUX.1 Krea dev (Runware)',
|
||||||
|
'provider' => 'runware',
|
||||||
|
'cost_per_image' => 0.0098,
|
||||||
|
'capabilities' => array( 'image_generation' ),
|
||||||
|
'description' => __( 'FLUX.1 Krea Dev. Photorealistic open-weight generation.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'runware:glm-image@0',
|
||||||
|
'name' => 'GLM-Image (Runware)',
|
||||||
|
'provider' => 'runware',
|
||||||
|
'cost_per_image' => 0.0225,
|
||||||
|
'capabilities' => array( 'image_generation' ),
|
||||||
|
'description' => __( 'GLM-Image. Hybrid autoregressive+diffusion, excellent text rendering.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hardcoded OpenRouter image model list. Left static for now because
|
||||||
|
* OpenRouter's catalog is large (hundreds of models) with noisy filtering.
|
||||||
|
* Future iteration will add smart filtering strategy (output_modalities,
|
||||||
|
* pricing, rate limits).
|
||||||
|
*
|
||||||
|
* Last verified: 2026-04-26. To update: check openrouter.ai/api/v1/models
|
||||||
|
* filtering to output_modalities containing 'image'.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private static function get_openrouter_models(): array {
|
||||||
|
return array(
|
||||||
|
array(
|
||||||
|
'id' => 'google/gemini-2.5-flash-image',
|
||||||
|
'name' => 'Gemini 2.5 Flash Image (OpenRouter)',
|
||||||
|
'provider' => 'openrouter',
|
||||||
|
'cost_per_image' => 0.005,
|
||||||
|
'capabilities' => array( 'image_generation' ),
|
||||||
|
'description' => __( 'Mid-tier quality, proven.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'google/gemini-3.1-flash-image-preview',
|
||||||
|
'name' => 'Gemini 3.1 Flash Image (OpenRouter)',
|
||||||
|
'provider' => 'openrouter',
|
||||||
|
'cost_per_image' => 0.008,
|
||||||
|
'capabilities' => array( 'image_generation' ),
|
||||||
|
'description' => __( 'Latest Google Flash image. Better quality.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'openai/gpt-5-image-mini',
|
||||||
|
'name' => 'GPT-5 Image Mini (OpenRouter)',
|
||||||
|
'provider' => 'openrouter',
|
||||||
|
'cost_per_image' => 0.02,
|
||||||
|
'capabilities' => array( 'image_generation' ),
|
||||||
|
'description' => __( 'OpenAI budget image model.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'openai/gpt-5.4-image-2',
|
||||||
|
'name' => 'GPT-5.4 Image 2 (OpenRouter)',
|
||||||
|
'provider' => 'openrouter',
|
||||||
|
'cost_per_image' => 0.02,
|
||||||
|
'capabilities' => array( 'image_generation' ),
|
||||||
|
'description' => __( 'OpenAI GPT-5.4 image generation.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'google/gemini-3-pro-image-preview',
|
||||||
|
'name' => 'Gemini 3 Pro Image (OpenRouter)',
|
||||||
|
'provider' => 'openrouter',
|
||||||
|
'cost_per_image' => 0.03,
|
||||||
|
'capabilities' => array( 'image_generation' ),
|
||||||
|
'description' => __( 'Highest quality Google image. Premium.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'openai/gpt-5-image',
|
||||||
|
'name' => 'GPT-5 Image (OpenRouter)',
|
||||||
|
'provider' => 'openrouter',
|
||||||
|
'cost_per_image' => 0.08,
|
||||||
|
'capabilities' => array( 'image_generation' ),
|
||||||
|
'description' => __( 'OpenAI premium. Photorealistic.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
* Admin page displaying structured application log entries.
|
* Admin page displaying structured application log entries.
|
||||||
*
|
*
|
||||||
* Provides level filtering (error/warning/info/debug), text search,
|
* Provides level filtering (error/warning/info/debug), text search,
|
||||||
@@ -29,7 +31,7 @@ class PRAutoBlogger_Log_Viewer {
|
|||||||
__( 'Activity Log', 'prautoblogger' ),
|
__( 'Activity Log', 'prautoblogger' ),
|
||||||
'manage_options',
|
'manage_options',
|
||||||
'prautoblogger-logs',
|
'prautoblogger-logs',
|
||||||
[ $this, 'render_page' ]
|
array( $this, 'render_page' )
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,19 +68,21 @@ class PRAutoBlogger_Log_Viewer {
|
|||||||
check_ajax_referer( 'prautoblogger_clear_logs', 'nonce' );
|
check_ajax_referer( 'prautoblogger_clear_logs', 'nonce' );
|
||||||
|
|
||||||
if ( ! current_user_can( 'manage_options' ) ) {
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 );
|
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$days = isset( $_POST['days'] ) ? absint( $_POST['days'] ) : 30;
|
$days = isset( $_POST['days'] ) ? absint( $_POST['days'] ) : 30;
|
||||||
$deleted = PRAutoBlogger_Logger::prune( $days );
|
$deleted = PRAutoBlogger_Logger::prune( $days );
|
||||||
|
|
||||||
wp_send_json_success( [
|
wp_send_json_success(
|
||||||
|
array(
|
||||||
'message' => sprintf(
|
'message' => sprintf(
|
||||||
/* translators: %d: number of entries deleted */
|
/* translators: %d: number of entries deleted */
|
||||||
__( '%d log entries cleared.', 'prautoblogger' ),
|
__( '%d log entries cleared.', 'prautoblogger' ),
|
||||||
$deleted
|
$deleted
|
||||||
),
|
),
|
||||||
] );
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
* Admin page displaying cost dashboard, content scores, and generation history.
|
* Admin page displaying cost dashboard, content scores, and generation history.
|
||||||
*
|
*
|
||||||
* Shows: monthly spend, daily cost chart, spend by pipeline stage, budget
|
* Shows: monthly spend, daily cost chart, spend by pipeline stage, budget
|
||||||
@@ -27,7 +29,7 @@ class PRAutoBlogger_Metrics_Page {
|
|||||||
__( 'Metrics & Costs', 'prautoblogger' ),
|
__( 'Metrics & Costs', 'prautoblogger' ),
|
||||||
'manage_options',
|
'manage_options',
|
||||||
'prautoblogger-metrics',
|
'prautoblogger-metrics',
|
||||||
[ $this, 'render_page' ]
|
array( $this, 'render_page' )
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
204
includes/admin/class-opik-settings.php
Normal file
204
includes/admin/class-opik-settings.php
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opik settings UI and registration.
|
||||||
|
*
|
||||||
|
* Registers settings fields in the PRAutoBlogger Settings page under
|
||||||
|
* a new "Observability" section. Fields include:
|
||||||
|
* - Enable Opik tracing (checkbox, default off)
|
||||||
|
* - Opik project name (text, default 'prautoblogger')
|
||||||
|
* - Capture full prompts (checkbox, default off)
|
||||||
|
* - Status display (read-only: API key configured, last dispatch, queue depth)
|
||||||
|
*
|
||||||
|
* Never renders the API key value in admin HTML.
|
||||||
|
*
|
||||||
|
* @see includes/admin/class-admin-page.php — Main settings page loader.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Opik_Settings {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor.
|
||||||
|
*
|
||||||
|
* Register hooks in __construct so they fire when the plugin loads.
|
||||||
|
*/
|
||||||
|
public function __construct() {
|
||||||
|
add_action( 'admin_init', array( $this, 'register_settings' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register Opik settings section and fields.
|
||||||
|
*
|
||||||
|
* Hooked to 'admin_init'.
|
||||||
|
*/
|
||||||
|
public function register_settings(): void {
|
||||||
|
// Register the section.
|
||||||
|
add_settings_section(
|
||||||
|
'prautoblogger_observability',
|
||||||
|
__( 'Observability (Opik)', 'prautoblogger' ),
|
||||||
|
array( $this, 'render_section_help' ),
|
||||||
|
'prautoblogger_settings'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Register fields.
|
||||||
|
add_settings_field(
|
||||||
|
'prautoblogger_opik_enabled',
|
||||||
|
__( 'Enable Opik tracing', 'prautoblogger' ),
|
||||||
|
array( $this, 'render_field_enabled' ),
|
||||||
|
'prautoblogger_settings',
|
||||||
|
'prautoblogger_observability'
|
||||||
|
);
|
||||||
|
|
||||||
|
register_setting(
|
||||||
|
'prautoblogger_settings',
|
||||||
|
'prautoblogger_opik_enabled',
|
||||||
|
array( 'type' => 'boolean' )
|
||||||
|
);
|
||||||
|
|
||||||
|
add_settings_field(
|
||||||
|
'prautoblogger_opik_project_name',
|
||||||
|
__( 'Opik project name', 'prautoblogger' ),
|
||||||
|
array( $this, 'render_field_project_name' ),
|
||||||
|
'prautoblogger_settings',
|
||||||
|
'prautoblogger_observability'
|
||||||
|
);
|
||||||
|
|
||||||
|
register_setting(
|
||||||
|
'prautoblogger_settings',
|
||||||
|
'prautoblogger_opik_project_name',
|
||||||
|
array(
|
||||||
|
'type' => 'string',
|
||||||
|
'sanitize_callback' => 'sanitize_text_field',
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
add_settings_field(
|
||||||
|
'prautoblogger_opik_capture_full_prompt',
|
||||||
|
__( 'Capture full prompt text', 'prautoblogger' ),
|
||||||
|
array( $this, 'render_field_capture_full' ),
|
||||||
|
'prautoblogger_settings',
|
||||||
|
'prautoblogger_observability'
|
||||||
|
);
|
||||||
|
|
||||||
|
register_setting(
|
||||||
|
'prautoblogger_settings',
|
||||||
|
'prautoblogger_opik_capture_full_prompt',
|
||||||
|
array( 'type' => 'boolean' )
|
||||||
|
);
|
||||||
|
|
||||||
|
add_settings_field(
|
||||||
|
'prautoblogger_opik_status',
|
||||||
|
__( 'Status', 'prautoblogger' ),
|
||||||
|
array( $this, 'render_field_status' ),
|
||||||
|
'prautoblogger_settings',
|
||||||
|
'prautoblogger_observability'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render section help text.
|
||||||
|
*/
|
||||||
|
public function render_section_help(): void {
|
||||||
|
echo wp_kses_post(
|
||||||
|
'<p>Optional LLM observability via <a href="https://www.comet.com/" target="_blank" rel="noopener">Comet Opik</a>. ' .
|
||||||
|
'Captures detailed traces of article generation with per-LLM-call metrics. ' .
|
||||||
|
'Requires API credentials in <code>wp-config.php</code>.</p>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the "Enable Opik" checkbox.
|
||||||
|
*/
|
||||||
|
public function render_field_enabled(): void {
|
||||||
|
$enabled = get_option( 'prautoblogger_opik_enabled', false );
|
||||||
|
$checked = $enabled ? 'checked' : '';
|
||||||
|
?>
|
||||||
|
<input type="checkbox" name="prautoblogger_opik_enabled" value="1" <?php echo esc_attr( $checked ); ?> />
|
||||||
|
<label for="prautoblogger_opik_enabled">
|
||||||
|
<?php esc_html_e( 'Send article generation traces to Opik', 'prautoblogger' ); ?>
|
||||||
|
</label>
|
||||||
|
<?php
|
||||||
|
if ( ! $this->has_credentials() ) {
|
||||||
|
echo '<p style="color: #d63638;"><strong>' .
|
||||||
|
esc_html__( 'Note: API credentials must be set in wp-config.php (PRAUTOBLOGGER_OPIK_API_KEY, PRAUTOBLOGGER_OPIK_WORKSPACE)', 'prautoblogger' ) .
|
||||||
|
'</strong></p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the project name field.
|
||||||
|
*/
|
||||||
|
public function render_field_project_name(): void {
|
||||||
|
$value = get_option( 'prautoblogger_opik_project_name', 'prautoblogger' );
|
||||||
|
?>
|
||||||
|
<input type="text" name="prautoblogger_opik_project_name" value="<?php echo esc_attr( $value ); ?>" style="width: 300px;" />
|
||||||
|
<p class="description">
|
||||||
|
<?php esc_html_e( 'Opik auto-creates this project on first trace (lowercase, no spaces)', 'prautoblogger' ); ?>
|
||||||
|
</p>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the "Capture full prompt" checkbox.
|
||||||
|
*/
|
||||||
|
public function render_field_capture_full(): void {
|
||||||
|
$enabled = get_option( 'prautoblogger_opik_capture_full_prompt', false );
|
||||||
|
$checked = $enabled ? 'checked' : '';
|
||||||
|
?>
|
||||||
|
<input type="checkbox" name="prautoblogger_opik_capture_full_prompt" value="1" <?php echo esc_attr( $checked ); ?> />
|
||||||
|
<label>
|
||||||
|
<?php esc_html_e( 'Include full prompt text in spans (increases payload size)', 'prautoblogger' ); ?>
|
||||||
|
</label>
|
||||||
|
<p class="description">
|
||||||
|
<?php esc_html_e( 'By default, only prompt hash is logged. Enable this for debugging.' ); ?>
|
||||||
|
</p>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the status display (read-only).
|
||||||
|
*/
|
||||||
|
public function render_field_status(): void {
|
||||||
|
$has_api_key = $this->has_credentials();
|
||||||
|
$queue = new PRAutoBlogger_Opik_Span_Queue();
|
||||||
|
$queue_depth = $queue->get_queue_depth();
|
||||||
|
$last_dispatch = get_option( 'prautoblogger_opik_last_dispatch', 0 );
|
||||||
|
$last_dispatch_str = $last_dispatch
|
||||||
|
? sprintf(
|
||||||
|
/* translators: %s = human-readable time */
|
||||||
|
__( '%s ago', 'prautoblogger' ),
|
||||||
|
human_time_diff( (int) $last_dispatch )
|
||||||
|
)
|
||||||
|
: __( 'Never', 'prautoblogger' );
|
||||||
|
|
||||||
|
?>
|
||||||
|
<div style="background: #f1f1f1; padding: 10px; border-radius: 3px; max-width: 600px;">
|
||||||
|
<p>
|
||||||
|
<strong><?php esc_html_e( 'API key configured:', 'prautoblogger' ); ?></strong>
|
||||||
|
<?php echo $has_api_key ? '<span style="color: green;">✓ Yes</span>' : '<span style="color: red;">✗ No</span>'; ?>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong><?php esc_html_e( 'Last dispatch:', 'prautoblogger' ); ?></strong>
|
||||||
|
<?php echo esc_html( $last_dispatch_str ); ?>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong><?php esc_html_e( 'Queue depth:', 'prautoblogger' ); ?></strong>
|
||||||
|
<?php echo esc_html( (string) $queue_depth ); ?>
|
||||||
|
<?php esc_html_e( 'item(s) pending', 'prautoblogger' ); ?>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Opik credentials are configured in wp-config.php.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private function has_credentials(): bool {
|
||||||
|
return defined( 'PRAUTOBLOGGER_OPIK_API_KEY' ) &&
|
||||||
|
defined( 'PRAUTOBLOGGER_OPIK_WORKSPACE' ) &&
|
||||||
|
! empty( PRAUTOBLOGGER_OPIK_API_KEY ) &&
|
||||||
|
! empty( PRAUTOBLOGGER_OPIK_WORKSPACE );
|
||||||
|
}
|
||||||
|
}
|
||||||
269
includes/admin/class-post-list-columns.php
Normal file
269
includes/admin/class-post-list-columns.php
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Adds "Writing Model", "Image Model", and "Cost" columns to the Posts list.
|
||||||
|
*
|
||||||
|
* Shows which LLM wrote each article, which model generated its featured image,
|
||||||
|
* and the total API cost of generating the article. Non-generated posts show "—".
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger::register_admin_hooks().
|
||||||
|
* Dependencies: Post meta, attachment meta, prab_generation_log table.
|
||||||
|
*
|
||||||
|
* @see core/class-publisher.php — Sets _prautoblogger_model_used.
|
||||||
|
* @see core/class-image-media-sideloader.php — Sets _prautoblogger_image_model on attachment.
|
||||||
|
* @see core/class-cost-tracker.php — Writes generation_log with estimated_cost.
|
||||||
|
* @see class-prautoblogger.php — Registers hooks for this class.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Post_List_Columns {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register column and render hooks.
|
||||||
|
*
|
||||||
|
* Side effects: adds WordPress filters/actions for post list customization.
|
||||||
|
*/
|
||||||
|
public function register(): void {
|
||||||
|
add_filter( 'manage_posts_columns', array( $this, 'filter_add_columns' ) );
|
||||||
|
add_action( 'manage_posts_custom_column', array( $this, 'on_render_column' ), 10, 2 );
|
||||||
|
add_filter( 'manage_edit-post_sortable_columns', array( $this, 'filter_sortable_columns' ) );
|
||||||
|
add_action( 'pre_get_posts', array( $this, 'on_sort_by_model' ) );
|
||||||
|
add_action( 'admin_head', array( $this, 'on_column_styles' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert three columns after the title column.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $columns Existing columns.
|
||||||
|
* @return array<string, string> Modified columns.
|
||||||
|
*/
|
||||||
|
public function filter_add_columns( array $columns ): array {
|
||||||
|
$new = array();
|
||||||
|
foreach ( $columns as $key => $label ) {
|
||||||
|
$new[ $key ] = $label;
|
||||||
|
if ( 'title' === $key ) {
|
||||||
|
$new['prab_writing_model'] = __( 'Writing Model', 'prautoblogger' );
|
||||||
|
$new['prab_image_model'] = __( 'Image Model', 'prautoblogger' );
|
||||||
|
$new['prab_cost'] = __( 'Cost', 'prautoblogger' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $new;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render cell content for our custom columns.
|
||||||
|
*
|
||||||
|
* @param string $column Column identifier.
|
||||||
|
* @param int $post_id Current post ID.
|
||||||
|
*/
|
||||||
|
public function on_render_column( string $column, int $post_id ): void {
|
||||||
|
switch ( $column ) {
|
||||||
|
case 'prab_writing_model':
|
||||||
|
$this->render_model_cell(
|
||||||
|
(string) get_post_meta( $post_id, '_prautoblogger_model_used', true )
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'prab_image_model':
|
||||||
|
$this->render_image_model_cell( $post_id );
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'prab_cost':
|
||||||
|
$this->render_cost_cell( $post_id );
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make the writing model column sortable.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $columns Sortable column map.
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function filter_sortable_columns( array $columns ): array {
|
||||||
|
$columns['prab_writing_model'] = 'prab_writing_model';
|
||||||
|
return $columns;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle sorting by writing model meta value.
|
||||||
|
*
|
||||||
|
* @param \WP_Query $query The admin posts query.
|
||||||
|
*/
|
||||||
|
public function on_sort_by_model( \WP_Query $query ): void {
|
||||||
|
if ( ! is_admin() || ! $query->is_main_query() ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( 'prab_writing_model' !== $query->get( 'orderby' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$query->set( 'meta_key', '_prautoblogger_model_used' );
|
||||||
|
$query->set( 'orderby', 'meta_value' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Output column width styles on the Posts list screen only.
|
||||||
|
*/
|
||||||
|
public function on_column_styles(): void {
|
||||||
|
$screen = get_current_screen();
|
||||||
|
if ( ! $screen || 'edit-post' !== $screen->id ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
echo '<style>'
|
||||||
|
. '.wp-list-table .column-title{width:22%}'
|
||||||
|
. '.column-author{width:6%}'
|
||||||
|
. '.column-categories{width:7%}'
|
||||||
|
. '.column-tags{width:9%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}'
|
||||||
|
. '.column-date{width:8%}'
|
||||||
|
. '.column-prab_writing_model{width:85px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}'
|
||||||
|
. '.column-prab_image_model{width:85px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}'
|
||||||
|
. '.column-prab_cost{width:50px;white-space:nowrap;overflow:visible}'
|
||||||
|
. '.column-taxonomy-peptide_topic{width:75px}'
|
||||||
|
. '.prab-cost-wrap{position:relative;display:inline-block}'
|
||||||
|
. '.prab-cost-trigger{font-size:12px;color:#2271b1;text-decoration:underline;cursor:pointer}'
|
||||||
|
. '.prab-cost-popover{display:none;position:absolute;right:0;top:100%;z-index:9999;'
|
||||||
|
. 'background:#fff;border:1px solid #c3c4c7;border-radius:4px;box-shadow:0 2px 8px rgba(0,0,0,.15);'
|
||||||
|
. 'padding:8px;min-width:340px;white-space:normal}'
|
||||||
|
. '.prab-cost-wrap:hover .prab-cost-popover,'
|
||||||
|
. '.prab-cost-trigger:focus+.prab-cost-popover{display:block}'
|
||||||
|
. '.prab-cost-table{width:100%;border-collapse:collapse;font-size:12px}'
|
||||||
|
. '.prab-cost-table th{text-align:left;border-bottom:2px solid #dcdcde;padding:3px 6px;color:#50575e;font-weight:600}'
|
||||||
|
. '.prab-cost-table td{padding:3px 6px;border-bottom:1px solid #f0f0f1;color:#3c434a}'
|
||||||
|
. '.prab-cost-table tr:last-child td{border-bottom:none}'
|
||||||
|
. '.prab-cost-total td{border-top:2px solid #dcdcde;border-bottom:none}'
|
||||||
|
. '</style>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a model name cell with shortened display and full tooltip.
|
||||||
|
*
|
||||||
|
* @param string $model Full model identifier (may be empty).
|
||||||
|
*/
|
||||||
|
private function render_model_cell( string $model ): void {
|
||||||
|
if ( '' === $model ) {
|
||||||
|
echo '—';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
printf(
|
||||||
|
'<span title="%s" style="font-size:12px;color:#666;">%s</span>',
|
||||||
|
esc_attr( $model ),
|
||||||
|
esc_html( $this->shorten( $model ) )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up the featured image's model and render it.
|
||||||
|
*
|
||||||
|
* @param int $post_id The post ID.
|
||||||
|
*/
|
||||||
|
private function render_image_model_cell( int $post_id ): void {
|
||||||
|
$thumbnail_id = (int) get_post_thumbnail_id( $post_id );
|
||||||
|
if ( 0 === $thumbnail_id ) {
|
||||||
|
echo '—';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$model = (string) get_post_meta( $thumbnail_id, '_prautoblogger_image_model', true );
|
||||||
|
$this->render_model_cell( $model );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch generation_log rows for a post, render total with clickable breakdown.
|
||||||
|
*
|
||||||
|
* @param int $post_id The post ID.
|
||||||
|
*/
|
||||||
|
private function render_cost_cell( int $post_id ): void {
|
||||||
|
$is_generated = get_post_meta( $post_id, '_prautoblogger_generated', true );
|
||||||
|
if ( '1' !== $is_generated ) {
|
||||||
|
echo '—';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||||
|
$rows = $wpdb->get_results(
|
||||||
|
$wpdb->prepare(
|
||||||
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
"SELECT stage, model, prompt_tokens, completion_tokens, estimated_cost
|
||||||
|
FROM {$table} WHERE post_id = %d ORDER BY id ASC",
|
||||||
|
$post_id
|
||||||
|
),
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( empty( $rows ) ) {
|
||||||
|
echo '<span style="font-size:12px;color:#999;">$0.00</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = array_sum( array_column( $rows, 'estimated_cost' ) );
|
||||||
|
$formatted = $total < 0.01
|
||||||
|
? sprintf( '$%.4f', $total )
|
||||||
|
: sprintf( '$%.2f', $total );
|
||||||
|
|
||||||
|
$breakdown = $this->build_breakdown_html( $rows, $total );
|
||||||
|
|
||||||
|
printf(
|
||||||
|
'<span class="prab-cost-wrap">'
|
||||||
|
. '<a href="#" class="prab-cost-trigger" onclick="return false;">%s</a>'
|
||||||
|
. '<span class="prab-cost-popover">%s</span>'
|
||||||
|
. '</span>',
|
||||||
|
esc_html( $formatted ),
|
||||||
|
$breakdown
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the HTML breakdown table for the cost popover.
|
||||||
|
*
|
||||||
|
* @param array[] $rows Generation log rows.
|
||||||
|
* @param float $total Total cost.
|
||||||
|
* @return string HTML table (already escaped).
|
||||||
|
*/
|
||||||
|
private function build_breakdown_html( array $rows, float $total ): string {
|
||||||
|
$html = '<table class="prab-cost-table">';
|
||||||
|
$html .= '<tr><th>Stage</th><th>Model</th><th>Tokens</th><th>Cost</th></tr>';
|
||||||
|
|
||||||
|
foreach ( $rows as $row ) {
|
||||||
|
$stage = esc_html( ucfirst( str_replace( '_', ' ', $row['stage'] ) ) );
|
||||||
|
$model = esc_html( $this->shorten( $row['model'] ) );
|
||||||
|
$tokens = (int) $row['prompt_tokens'] + (int) $row['completion_tokens'];
|
||||||
|
$cost = (float) $row['estimated_cost'];
|
||||||
|
$cost_f = $cost < 0.01 ? sprintf( '$%.4f', $cost ) : sprintf( '$%.2f', $cost );
|
||||||
|
|
||||||
|
$html .= sprintf(
|
||||||
|
'<tr><td>%s</td><td title="%s">%s</td><td>%s</td><td>%s</td></tr>',
|
||||||
|
$stage,
|
||||||
|
esc_attr( $row['model'] ),
|
||||||
|
$model,
|
||||||
|
esc_html( number_format( $tokens ) ),
|
||||||
|
esc_html( $cost_f )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$total_f = $total < 0.01 ? sprintf( '$%.4f', $total ) : sprintf( '$%.2f', $total );
|
||||||
|
$html .= sprintf(
|
||||||
|
'<tr class="prab-cost-total"><td colspan="3"><strong>Total</strong></td>'
|
||||||
|
. '<td><strong>%s</strong></td></tr>',
|
||||||
|
esc_html( $total_f )
|
||||||
|
);
|
||||||
|
$html .= '</table>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip provider prefix for compact display.
|
||||||
|
*
|
||||||
|
* @param string $model Full model identifier.
|
||||||
|
* @return string Shortened name.
|
||||||
|
*/
|
||||||
|
private function shorten( string $model ): string {
|
||||||
|
$pos = strpos( $model, '/' );
|
||||||
|
return false !== $pos ? substr( $model, $pos + 1 ) : $model;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,21 +2,24 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a metabox to posts showing PRAutoBlogger generation metadata.
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
*
|
*
|
||||||
* Only appears on posts that were generated by PRAutoBlogger (checked via
|
* Adds a metabox to PRAutoBlogger-generated posts.
|
||||||
* _prautoblogger_generated post meta).
|
*
|
||||||
|
* M2: Slimmed to a "View generation dossier" link. Full generation metadata
|
||||||
|
* lives on the Article Dossier page (PRAutoBlogger_Dossier_Page).
|
||||||
*
|
*
|
||||||
* Triggered by: PRAutoBlogger::register_admin_hooks() on `add_meta_boxes`.
|
* Triggered by: PRAutoBlogger::register_admin_hooks() on `add_meta_boxes`.
|
||||||
* Dependencies: WordPress post meta API.
|
* Dependencies: PRAutoBlogger_Dossier_Page (for URL helper).
|
||||||
*
|
*
|
||||||
* @see class-prautoblogger.php — Registers the hook.
|
* @see class-prautoblogger.php -- Registers the hook.
|
||||||
* @see core/class-publisher.php — Writes the metadata this class displays.
|
* @see admin/class-dossier-page.php -- Dossier target; provides url_for_post().
|
||||||
|
* @see core/class-publisher.php -- Writes the run_id meta this class reads.
|
||||||
*/
|
*/
|
||||||
class PRAutoBlogger_Post_Metabox {
|
class PRAutoBlogger_Post_Metabox {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register the metabox for the post editor.
|
* Register the metabox for PRAutoBlogger-generated posts only.
|
||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
@@ -33,8 +36,8 @@ class PRAutoBlogger_Post_Metabox {
|
|||||||
|
|
||||||
add_meta_box(
|
add_meta_box(
|
||||||
'prautoblogger_generation_info',
|
'prautoblogger_generation_info',
|
||||||
__( 'PRAutoBlogger Generation Info', 'prautoblogger' ),
|
__( 'PRAutoBlogger', 'prautoblogger' ),
|
||||||
[ $this, 'render_metabox' ],
|
array( $this, 'render_metabox' ),
|
||||||
'post',
|
'post',
|
||||||
'side',
|
'side',
|
||||||
'default'
|
'default'
|
||||||
@@ -42,13 +45,12 @@ class PRAutoBlogger_Post_Metabox {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the generation metadata metabox.
|
* Render the dossier-link metabox.
|
||||||
*
|
|
||||||
* @param \WP_Post $post
|
|
||||||
*
|
*
|
||||||
|
* @param \WP_Post $post Current post.
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function render_metabox( \WP_Post $post ): void {
|
public function render_metabox( \WP_Post $post ): void {
|
||||||
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/metabox-generation-info.php';
|
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/metabox-dossier-link.php';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
85
includes/admin/class-review-queue.php
Executable file → Normal file
85
includes/admin/class-review-queue.php
Executable file → Normal file
@@ -2,6 +2,8 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
* Admin page listing generated draft posts for editorial review.
|
* Admin page listing generated draft posts for editorial review.
|
||||||
*
|
*
|
||||||
* Provides approve (publish), edit (opens WP editor), and reject (trash)
|
* Provides approve (publish), edit (opens WP editor), and reject (trash)
|
||||||
@@ -28,11 +30,11 @@ class PRAutoBlogger_Review_Queue {
|
|||||||
__( 'Review Queue', 'prautoblogger' ),
|
__( 'Review Queue', 'prautoblogger' ),
|
||||||
'manage_options',
|
'manage_options',
|
||||||
'prautoblogger-review-queue',
|
'prautoblogger-review-queue',
|
||||||
[ $this, 'render_page' ]
|
array( $this, 'render_page' )
|
||||||
);
|
);
|
||||||
|
|
||||||
if ( false !== $hook ) {
|
if ( false !== $hook ) {
|
||||||
add_action( "load-{$hook}", [ $this, 'on_handle_bulk_actions' ] );
|
add_action( "load-{$hook}", array( $this, 'on_handle_bulk_actions' ) );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,9 +51,22 @@ class PRAutoBlogger_Review_Queue {
|
|||||||
$paged = isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1;
|
$paged = isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1;
|
||||||
$query = $this->get_pending_posts( $paged );
|
$query = $this->get_pending_posts( $paged );
|
||||||
|
|
||||||
|
// v0.18.0 — runs halted by the per-run cost governor are routed
|
||||||
|
// here for human attention (they may have no draft post to list).
|
||||||
|
$halted_runs = $this->get_halted_runs();
|
||||||
|
|
||||||
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/review-queue.php';
|
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/review-queue.php';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs halted by the per-run cost governor, newest first.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>> Halted run rows (max 10).
|
||||||
|
*/
|
||||||
|
public function get_halted_runs(): array {
|
||||||
|
return PRAutoBlogger_Run_State::halted_runs( 10 );
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Query generated draft posts awaiting review.
|
* Query generated draft posts awaiting review.
|
||||||
*
|
*
|
||||||
@@ -60,20 +75,22 @@ class PRAutoBlogger_Review_Queue {
|
|||||||
* @return \WP_Query
|
* @return \WP_Query
|
||||||
*/
|
*/
|
||||||
public function get_pending_posts( int $paged = 1 ): \WP_Query {
|
public function get_pending_posts( int $paged = 1 ): \WP_Query {
|
||||||
return new \WP_Query( [
|
return new \WP_Query(
|
||||||
|
array(
|
||||||
'post_type' => 'post',
|
'post_type' => 'post',
|
||||||
'post_status' => 'draft',
|
'post_status' => 'draft',
|
||||||
'posts_per_page' => 20,
|
'posts_per_page' => 20,
|
||||||
'paged' => $paged,
|
'paged' => $paged,
|
||||||
'meta_query' => [
|
'meta_query' => array(
|
||||||
[
|
array(
|
||||||
'key' => '_prautoblogger_generated',
|
'key' => '_prautoblogger_generated',
|
||||||
'value' => '1',
|
'value' => '1',
|
||||||
],
|
),
|
||||||
],
|
),
|
||||||
'orderby' => 'date',
|
'orderby' => 'date',
|
||||||
'order' => 'DESC',
|
'order' => 'DESC',
|
||||||
] );
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -100,7 +117,7 @@ class PRAutoBlogger_Review_Queue {
|
|||||||
$action = sanitize_text_field( wp_unslash( $_POST['prautoblogger_bulk_action'] ) );
|
$action = sanitize_text_field( wp_unslash( $_POST['prautoblogger_bulk_action'] ) );
|
||||||
$post_ids = isset( $_POST['prautoblogger_post_ids'] ) && is_array( $_POST['prautoblogger_post_ids'] )
|
$post_ids = isset( $_POST['prautoblogger_post_ids'] ) && is_array( $_POST['prautoblogger_post_ids'] )
|
||||||
? array_map( 'absint', $_POST['prautoblogger_post_ids'] )
|
? array_map( 'absint', $_POST['prautoblogger_post_ids'] )
|
||||||
: [];
|
: array();
|
||||||
|
|
||||||
if ( empty( $post_ids ) ) {
|
if ( empty( $post_ids ) ) {
|
||||||
return;
|
return;
|
||||||
@@ -113,25 +130,31 @@ class PRAutoBlogger_Review_Queue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ( 'approve' === $action ) {
|
if ( 'approve' === $action ) {
|
||||||
$result = wp_update_post( [ 'ID' => $post_id, 'post_status' => 'publish' ], true );
|
$result = wp_update_post(
|
||||||
|
array(
|
||||||
|
'ID' => $post_id,
|
||||||
|
'post_status' => 'publish',
|
||||||
|
),
|
||||||
|
true
|
||||||
|
);
|
||||||
if ( ! is_wp_error( $result ) ) {
|
if ( ! is_wp_error( $result ) ) {
|
||||||
update_post_meta( $post_id, '_prautoblogger_approved_at', gmdate( 'c' ) );
|
update_post_meta( $post_id, '_prautoblogger_approved_at', gmdate( 'c' ) );
|
||||||
$count++;
|
++$count;
|
||||||
}
|
}
|
||||||
} elseif ( 'reject' === $action ) {
|
} elseif ( 'reject' === $action ) {
|
||||||
$result = wp_trash_post( $post_id );
|
$result = wp_trash_post( $post_id );
|
||||||
if ( false !== $result ) {
|
if ( false !== $result ) {
|
||||||
$count++;
|
++$count;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$redirect = add_query_arg(
|
$redirect = add_query_arg(
|
||||||
[
|
array(
|
||||||
'page' => 'prautoblogger-review-queue',
|
'page' => 'prautoblogger-review-queue',
|
||||||
'prautoblogger_bulk_done' => $count,
|
'prautoblogger_bulk_done' => $count,
|
||||||
'prautoblogger_bulk_type' => $action,
|
'prautoblogger_bulk_type' => $action,
|
||||||
],
|
),
|
||||||
admin_url( 'admin.php' )
|
admin_url( 'admin.php' )
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -150,25 +173,36 @@ class PRAutoBlogger_Review_Queue {
|
|||||||
check_ajax_referer( 'prautoblogger_review_queue', 'nonce' );
|
check_ajax_referer( 'prautoblogger_review_queue', 'nonce' );
|
||||||
|
|
||||||
if ( ! current_user_can( 'manage_options' ) ) {
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 );
|
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0;
|
$post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0;
|
||||||
if ( $post_id <= 0 ) {
|
if ( $post_id <= 0 ) {
|
||||||
wp_send_json_error( [ 'message' => __( 'Invalid post ID.', 'prautoblogger' ) ] );
|
wp_send_json_error( array( 'message' => __( 'Invalid post ID.', 'prautoblogger' ) ) );
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = wp_update_post( [ 'ID' => $post_id, 'post_status' => 'publish' ], true );
|
$result = wp_update_post(
|
||||||
|
array(
|
||||||
|
'ID' => $post_id,
|
||||||
|
'post_status' => 'publish',
|
||||||
|
),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
if ( is_wp_error( $result ) ) {
|
if ( is_wp_error( $result ) ) {
|
||||||
wp_send_json_error( [ 'message' => $result->get_error_message() ] );
|
wp_send_json_error( array( 'message' => $result->get_error_message() ) );
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
update_post_meta( $post_id, '_prautoblogger_approved_at', gmdate( 'c' ) );
|
update_post_meta( $post_id, '_prautoblogger_approved_at', gmdate( 'c' ) );
|
||||||
wp_send_json_success( [ 'message' => __( 'Post published.', 'prautoblogger' ), 'post_id' => $post_id ] );
|
wp_send_json_success(
|
||||||
|
array(
|
||||||
|
'message' => __( 'Post published.', 'prautoblogger' ),
|
||||||
|
'post_id' => $post_id,
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -182,22 +216,27 @@ class PRAutoBlogger_Review_Queue {
|
|||||||
check_ajax_referer( 'prautoblogger_review_queue', 'nonce' );
|
check_ajax_referer( 'prautoblogger_review_queue', 'nonce' );
|
||||||
|
|
||||||
if ( ! current_user_can( 'manage_options' ) ) {
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 );
|
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0;
|
$post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0;
|
||||||
if ( $post_id <= 0 ) {
|
if ( $post_id <= 0 ) {
|
||||||
wp_send_json_error( [ 'message' => __( 'Invalid post ID.', 'prautoblogger' ) ] );
|
wp_send_json_error( array( 'message' => __( 'Invalid post ID.', 'prautoblogger' ) ) );
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = wp_trash_post( $post_id );
|
$result = wp_trash_post( $post_id );
|
||||||
if ( false === $result || null === $result ) {
|
if ( false === $result || null === $result ) {
|
||||||
wp_send_json_error( [ 'message' => __( 'Failed to reject post.', 'prautoblogger' ) ] );
|
wp_send_json_error( array( 'message' => __( 'Failed to reject post.', 'prautoblogger' ) ) );
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
wp_send_json_success( [ 'message' => __( 'Post rejected.', 'prautoblogger' ), 'post_id' => $post_id ] );
|
wp_send_json_success(
|
||||||
|
array(
|
||||||
|
'message' => __( 'Post rejected.', 'prautoblogger' ),
|
||||||
|
'post_id' => $post_id,
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
228
includes/admin/class-settings-fields-extended.php
Normal file
228
includes/admin/class-settings-fields-extended.php
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Extended settings fields: schedule, publishing, analytics, display, and images.
|
||||||
|
*
|
||||||
|
* What: Declarative field definitions for operational settings sections.
|
||||||
|
* Who calls it: PRAutoBlogger_Settings_Fields::get_fields() merges these in.
|
||||||
|
* Dependencies: PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL, PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE constants,
|
||||||
|
* PRAutoBlogger_Article_Typography (font choices), PRAutoBlogger_Image_Model_Registry.
|
||||||
|
*
|
||||||
|
* @see admin/class-settings-fields.php — Core fields and sections; calls get_extended_fields().
|
||||||
|
* @see admin/class-admin-page.php — Renders fields registered from these definitions.
|
||||||
|
* @see admin/class-image-model-registry.php — Static image model list extracted for 300-line compliance.
|
||||||
|
* @see frontend/class-article-typography.php — Reads Display settings on the frontend.
|
||||||
|
* @see CONVENTIONS.md — "How To: Add a New Admin Setting".
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Settings_Fields_Extended {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static image model list for the admin model picker.
|
||||||
|
* Delegates to Image_Model_Registry (extracted for 300-line file limit).
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public static function get_image_models(): array {
|
||||||
|
return PRAutoBlogger_Image_Model_Registry::get_models();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get schedule, publishing, analytics, and image settings fields.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public static function get_fields(): array {
|
||||||
|
return array_merge(
|
||||||
|
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' ),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── AI Models (extended) ────────────────────────────────────
|
||||||
|
array(
|
||||||
|
'id' => 'prautoblogger_reasoning_enabled',
|
||||||
|
'label' => __( 'Enable Reasoning', 'prautoblogger' ),
|
||||||
|
'type' => 'toggle',
|
||||||
|
'section' => 'prautoblogger_models',
|
||||||
|
'default' => '0',
|
||||||
|
'description' => __( 'Send reasoning instructions to models that support it (e.g. Grok, DeepSeek-R1). Reasoning tokens are billed as output tokens — expect higher costs per call. Models that don\'t support reasoning will ignore this.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'prautoblogger_reasoning_effort',
|
||||||
|
'label' => __( 'Reasoning Effort', 'prautoblogger' ),
|
||||||
|
'type' => 'select',
|
||||||
|
'section' => 'prautoblogger_models',
|
||||||
|
'default' => 'medium',
|
||||||
|
'options' => array(
|
||||||
|
'xhigh' => __( 'Extra High — maximum depth, highest cost', 'prautoblogger' ),
|
||||||
|
'high' => __( 'High — thorough reasoning', 'prautoblogger' ),
|
||||||
|
'medium' => __( 'Medium — balanced (recommended)', 'prautoblogger' ),
|
||||||
|
'low' => __( 'Low — light reasoning, lower cost', 'prautoblogger' ),
|
||||||
|
'minimal' => __( 'Minimal — barely any reasoning', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
'description' => __( 'How much effort the model spends reasoning before answering. Higher effort = more reasoning tokens = higher cost. Only applies when reasoning is enabled above.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Board Settings ─────────────────────────────────────────
|
||||||
|
array(
|
||||||
|
'id' => 'prautoblogger_board_poll_interval',
|
||||||
|
'label' => __( 'Board Poll Interval (seconds)', 'prautoblogger' ),
|
||||||
|
'type' => 'number',
|
||||||
|
'section' => 'prautoblogger_schedule',
|
||||||
|
'default' => 5,
|
||||||
|
'min' => 3,
|
||||||
|
'max' => 60,
|
||||||
|
'description' => __( 'How often the kanban board polls for status updates (seconds). Minimum 3s. Doubles automatically when no article is actively generating.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'prautoblogger_board_published_window_days',
|
||||||
|
'label' => __( 'Published Column Window (days)', 'prautoblogger' ),
|
||||||
|
'type' => 'number',
|
||||||
|
'section' => 'prautoblogger_schedule',
|
||||||
|
'default' => 7,
|
||||||
|
'min' => 1,
|
||||||
|
'max' => 90,
|
||||||
|
'description' => __( 'How many days back the Published column shows articles. Increase to see older published articles on the board.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Schedule & Budget ───────────────────────────────────────
|
||||||
|
array(
|
||||||
|
'id' => 'prautoblogger_daily_article_target',
|
||||||
|
'label' => __( 'Articles per Day', 'prautoblogger' ),
|
||||||
|
'type' => 'number',
|
||||||
|
'section' => 'prautoblogger_schedule',
|
||||||
|
'default' => 1,
|
||||||
|
'min' => 1,
|
||||||
|
'max' => 10,
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'prautoblogger_schedule_time',
|
||||||
|
'label' => __( 'Generation Time', 'prautoblogger' ),
|
||||||
|
'type' => 'time',
|
||||||
|
'section' => 'prautoblogger_schedule',
|
||||||
|
'default' => '03:00',
|
||||||
|
'description' => sprintf(
|
||||||
|
/* translators: %s: site timezone string, e.g. "Asia/Singapore". */
|
||||||
|
esc_html__( "Daily generation runs at this time in your site's configured timezone (Settings → General → Timezone). Current site timezone: %s.", 'prautoblogger' ),
|
||||||
|
esc_html( function_exists( 'wp_timezone_string' ) ? wp_timezone_string() : 'UTC' )
|
||||||
|
),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'prautoblogger_monthly_budget_usd',
|
||||||
|
'label' => __( 'Monthly Budget (USD)', 'prautoblogger' ),
|
||||||
|
'type' => 'number',
|
||||||
|
'section' => 'prautoblogger_schedule',
|
||||||
|
'default' => 50,
|
||||||
|
'min' => 0,
|
||||||
|
'step' => '0.01',
|
||||||
|
'description' => __( 'Hard stop when reached. 0 = unlimited.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Publishing ──────────────────────────────────────────────
|
||||||
|
array(
|
||||||
|
'id' => 'prautoblogger_auto_publish',
|
||||||
|
'label' => __( 'Auto-Publish', 'prautoblogger' ),
|
||||||
|
'type' => 'toggle',
|
||||||
|
'section' => 'prautoblogger_publishing',
|
||||||
|
'default' => '0',
|
||||||
|
'description' => __( 'Automatically publish editor-approved posts. When off, all posts go to the Review Queue as drafts.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'prautoblogger_default_author',
|
||||||
|
'label' => __( 'Default Author', 'prautoblogger' ),
|
||||||
|
'type' => 'author_select',
|
||||||
|
'section' => 'prautoblogger_publishing',
|
||||||
|
'default' => 0,
|
||||||
|
'description' => __( 'WordPress user assigned as author of generated posts.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'prautoblogger_default_category',
|
||||||
|
'label' => __( 'Default Category', 'prautoblogger' ),
|
||||||
|
'type' => 'category_select',
|
||||||
|
'section' => 'prautoblogger_publishing',
|
||||||
|
'default' => 0,
|
||||||
|
'description' => __( 'Fallback category for generated posts.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'prautoblogger_log_level',
|
||||||
|
'label' => __( 'Log Level', 'prautoblogger' ),
|
||||||
|
'type' => 'select',
|
||||||
|
'section' => 'prautoblogger_publishing',
|
||||||
|
'default' => 'info',
|
||||||
|
'options' => array(
|
||||||
|
'error' => __( 'Error — only failures', 'prautoblogger' ),
|
||||||
|
'warning' => __( 'Warning — errors + warnings', 'prautoblogger' ),
|
||||||
|
'info' => __( 'Info — key events (recommended)', 'prautoblogger' ),
|
||||||
|
'debug' => __( 'Debug — everything (verbose)', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
'description' => __( 'Controls detail level in the Activity Log.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Analytics ───────────────────────────────────────────────
|
||||||
|
array(
|
||||||
|
'id' => 'prautoblogger_ga4_property_id',
|
||||||
|
'label' => __( 'GA4 Property ID', 'prautoblogger' ),
|
||||||
|
'type' => 'text',
|
||||||
|
'section' => 'prautoblogger_analytics',
|
||||||
|
'description' => __( 'Your GA4 Property ID — digits only, e.g. 123456789 (the code adds the "properties/" prefix internally). Find it in GA4 → Admin → Property details. Leave blank to skip GA4.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'prautoblogger_ga4_credentials_json',
|
||||||
|
'label' => __( 'Service Account JSON', 'prautoblogger' ),
|
||||||
|
'type' => 'password',
|
||||||
|
'section' => 'prautoblogger_analytics',
|
||||||
|
'description' => __( 'Paste the full JSON key file for a service account with Analytics read access.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Display ────────────────────────────────────────────────
|
||||||
|
array(
|
||||||
|
'id' => 'prautoblogger_article_font_family',
|
||||||
|
'label' => __( 'Article Font', 'prautoblogger' ),
|
||||||
|
'type' => 'select',
|
||||||
|
'section' => 'prautoblogger_display',
|
||||||
|
'default' => 'default',
|
||||||
|
'options' => PRAutoBlogger_Article_Typography::get_font_choices(),
|
||||||
|
'description' => __( 'Font family used for generated article body text. Serif fonts improve long-form readability.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'prautoblogger_article_font_size',
|
||||||
|
'label' => __( 'Article Font Size (px)', 'prautoblogger' ),
|
||||||
|
'type' => 'number',
|
||||||
|
'section' => 'prautoblogger_display',
|
||||||
|
'default' => 0,
|
||||||
|
'min' => 0,
|
||||||
|
'max' => 32,
|
||||||
|
'description' => __( 'Base font size for article body text. 0 uses the theme default (13px). Recommended: 16–18px for comfortable reading.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'prautoblogger_table_borders',
|
||||||
|
'label' => __( 'Table Borders', 'prautoblogger' ),
|
||||||
|
'type' => 'toggle',
|
||||||
|
'section' => 'prautoblogger_display',
|
||||||
|
'default' => '1',
|
||||||
|
'description' => __( 'Add visible borders, padding, and alternating row colors to tables in generated articles.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PRAutoBlogger_Settings_Fields_Images::get_fields()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
123
includes/admin/class-settings-fields-images.php
Normal file
123
includes/admin/class-settings-fields-images.php
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Image settings fields for PRAutoBlogger.
|
||||||
|
*
|
||||||
|
* Declarative field definitions for the Images section of the settings page.
|
||||||
|
* Extracted from PRAutoBlogger_Settings_Fields_Extended for 300-line compliance.
|
||||||
|
*
|
||||||
|
* What: Returns the images-section field array consumed by the settings registry.
|
||||||
|
* Who triggers: PRAutoBlogger_Settings_Fields_Extended::get_fields() via merge.
|
||||||
|
* Dependencies: PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL, PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE,
|
||||||
|
* PRAutoBlogger_Image_Prompt_Builder, PRAutoBlogger_Image_Model_Registry.
|
||||||
|
*
|
||||||
|
* @see admin/class-settings-fields-extended.php -- Merges these fields in get_fields().
|
||||||
|
* @see admin/class-admin-page.php -- Renders the fields.
|
||||||
|
* @see CONVENTIONS.md -- "How To: Add a New Admin Setting".
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Settings_Fields_Images {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get image generation settings fields.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public static function get_fields(): array {
|
||||||
|
return array(
|
||||||
|
|
||||||
|
// ── Images ─────────────────────────────────────────────────
|
||||||
|
array(
|
||||||
|
'id' => 'prautoblogger_image_enabled',
|
||||||
|
'label' => __( 'Enable Image Generation', 'prautoblogger' ),
|
||||||
|
'type' => 'toggle',
|
||||||
|
'section' => 'prautoblogger_images',
|
||||||
|
'default' => '0',
|
||||||
|
'description' => __( 'Generate images for each published article.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'prautoblogger_image_b_enabled',
|
||||||
|
'label' => __( 'Generate Second Image (B)', 'prautoblogger' ),
|
||||||
|
'type' => 'toggle',
|
||||||
|
'section' => 'prautoblogger_images',
|
||||||
|
'default' => '1',
|
||||||
|
'description' => __( 'Generate a second image from source data for A/B testing. Disabling saves one image generation + one LLM prompt rewrite per article.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'prautoblogger_image_model',
|
||||||
|
'label' => __( 'Image Model', 'prautoblogger' ),
|
||||||
|
'type' => 'model_select',
|
||||||
|
'section' => 'prautoblogger_images',
|
||||||
|
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL,
|
||||||
|
'capability' => 'image_generation',
|
||||||
|
'description' => __( 'Pick an image model. The provider (Runware or OpenRouter) is derived from the model registry on save, so mismatched pairs are no longer possible.', 'prautoblogger' ),
|
||||||
|
'badge' => __( 'Quality', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'prautoblogger_runware_api_key',
|
||||||
|
'label' => __( 'Runware API Key', 'prautoblogger' ),
|
||||||
|
'type' => 'password',
|
||||||
|
'section' => 'prautoblogger_images',
|
||||||
|
'description' => __( 'Required when using a Runware FLUX model. Get your key at runware.ai/signup.', 'prautoblogger' ),
|
||||||
|
'icon' => '🔑',
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'prautoblogger_image_style_template',
|
||||||
|
'label' => __( 'Style Template', 'prautoblogger' ),
|
||||||
|
'type' => 'textarea',
|
||||||
|
'section' => 'prautoblogger_images',
|
||||||
|
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE,
|
||||||
|
'description' => __( 'The full image prompt template. Must contain exactly one {{ topic_summary }} token, which is filled with a 1-2 sentence topic/mechanism summary per article. The rest is brand-locked editorial style. Replaces the old Style Suffix (now deprecated). Changing mid-run causes visible style drift.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'prautoblogger_image_prompt_instructions',
|
||||||
|
'label' => __( 'Image Prompt Instructions', 'prautoblogger' ),
|
||||||
|
'type' => 'textarea',
|
||||||
|
'section' => 'prautoblogger_images',
|
||||||
|
'default' => PRAutoBlogger_Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT,
|
||||||
|
'description' => __( 'System prompt given to the rewriter LLM that turns each article into a SCENE (a 1-2 sentence editorial topic/mechanism summary, substituted into the Style Template) plus a CAPTION (HTML text shown below the image). Changing this reshapes the look of all future images. Leave blank to use the default.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'prautoblogger_image_nsfw_retry',
|
||||||
|
'label' => __( 'Retry NSFW-Blocked Images', 'prautoblogger' ),
|
||||||
|
'type' => 'toggle',
|
||||||
|
'section' => 'prautoblogger_images',
|
||||||
|
'default' => '1',
|
||||||
|
'description' => __( 'When the provider rejects an image prompt as NSFW, retry once with a generic fallback scene built from the article title. Disable to fail fast if the filter gets trigger-happy.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'prautoblogger_image_compose_enabled',
|
||||||
|
'label' => __( 'Compose Branded Variants', 'prautoblogger' ),
|
||||||
|
'type' => 'toggle',
|
||||||
|
'section' => 'prautoblogger_images',
|
||||||
|
'default' => '1',
|
||||||
|
'description' => __( 'Render branded social variants (teal band, logo, baked caption) from each generated image via the local deterministic composer. Auto-degrades to plain resizing or pass-through if the server lacks an image library — publishing is never blocked.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'prautoblogger_image_compose_variants',
|
||||||
|
'label' => __( 'Composed Variant Set', 'prautoblogger' ),
|
||||||
|
'type' => 'text',
|
||||||
|
'section' => 'prautoblogger_images',
|
||||||
|
'default' => 'og,square',
|
||||||
|
'description' => __( 'Comma-separated variants to render for Image A. Supported: og (1200×630 social share), square (1080×1080 card). Unknown values are ignored.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'prautoblogger_image_featured_mark_enabled',
|
||||||
|
'label' => __( 'Featured Image Corner Mark', 'prautoblogger' ),
|
||||||
|
'type' => 'toggle',
|
||||||
|
'section' => 'prautoblogger_images',
|
||||||
|
'default' => '1',
|
||||||
|
'description' => __( 'Overlay a small semi-transparent Peptide Repo mark in the bottom-right corner of featured images, so scraped or re-shared images carry attribution.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'prautoblogger_runware_model_catalog',
|
||||||
|
'label' => __( 'Runware Model Catalog', 'prautoblogger' ),
|
||||||
|
'type' => 'runware_catalog_sync',
|
||||||
|
'section' => 'prautoblogger_images',
|
||||||
|
'description' => __( 'Live model catalog sync status and on-demand refresh.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,14 +2,19 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
* Declarative settings fields and sections for the PRAutoBlogger admin page.
|
* Declarative settings fields and sections for the PRAutoBlogger admin page.
|
||||||
*
|
*
|
||||||
* Centralizes all field and section definitions in one place, making it trivial
|
* Centralizes all field and section definitions in one place, making it trivial
|
||||||
* to add new settings (just add one array entry). Decoupled from page rendering logic.
|
* to add new settings (just add one array entry). Decoupled from page rendering logic.
|
||||||
|
* Core fields (API, Models, Content, Sources) live here; operational fields
|
||||||
|
* (Schedule, Publishing, Analytics, Images) are in Settings_Fields_Extended.
|
||||||
*
|
*
|
||||||
* Triggered by: PRAutoBlogger_Admin_Page::on_register_settings() calls static methods here.
|
* Triggered by: PRAutoBlogger_Admin_Page::on_register_settings() calls static methods here.
|
||||||
* Dependencies: None — contains only data definitions and static methods.
|
* Dependencies: PRAutoBlogger_Settings_Fields_Extended for operational field definitions.
|
||||||
*
|
*
|
||||||
|
* @see admin/class-settings-fields-extended.php — Schedule, publishing, analytics, images fields.
|
||||||
* @see admin/class-admin-page.php — Calls get_sections() and get_fields() to register settings.
|
* @see admin/class-admin-page.php — Calls get_sections() and get_fields() to register settings.
|
||||||
* @see CONVENTIONS.md — "How To: Add a New Admin Setting".
|
* @see CONVENTIONS.md — "How To: Add a New Admin Setting".
|
||||||
*/
|
*/
|
||||||
@@ -21,75 +26,89 @@ class PRAutoBlogger_Settings_Fields {
|
|||||||
* @return array<string, array{title: string, icon: string, description: string}>
|
* @return array<string, array{title: string, icon: string, description: string}>
|
||||||
*/
|
*/
|
||||||
public static function get_sections(): array {
|
public static function get_sections(): array {
|
||||||
return [
|
return array(
|
||||||
'prautoblogger_api' => [
|
'prautoblogger_api' => array(
|
||||||
'title' => __( 'API Keys', 'prautoblogger' ),
|
'title' => __( 'API Keys', 'prautoblogger' ),
|
||||||
'icon' => 'dashicons-admin-network',
|
'icon' => 'dashicons-admin-network',
|
||||||
'description' => __( 'Connect your external services. Keys are encrypted at rest.', 'prautoblogger' ),
|
'description' => __( 'Connect your external services. Keys are encrypted at rest.', 'prautoblogger' ),
|
||||||
],
|
),
|
||||||
'prautoblogger_models' => [
|
'prautoblogger_models' => array(
|
||||||
'title' => __( 'AI Models', 'prautoblogger' ),
|
'title' => __( 'AI Models', 'prautoblogger' ),
|
||||||
'icon' => 'dashicons-superhero-alt',
|
'icon' => 'dashicons-superhero-alt',
|
||||||
'description' => __( 'Choose which models power each stage of the pipeline.', 'prautoblogger' ),
|
'description' => __( 'Choose which models power each stage of the pipeline.', 'prautoblogger' ),
|
||||||
],
|
),
|
||||||
'prautoblogger_content' => [
|
'prautoblogger_content' => array(
|
||||||
'title' => __( 'Content', 'prautoblogger' ),
|
'title' => __( 'Content', 'prautoblogger' ),
|
||||||
'icon' => 'dashicons-edit-large',
|
'icon' => 'dashicons-edit-large',
|
||||||
'description' => __( 'Control tone, length, pipeline mode, and topic guardrails.', 'prautoblogger' ),
|
'description' => __( 'Control tone, length, pipeline mode, and topic guardrails.', 'prautoblogger' ),
|
||||||
],
|
),
|
||||||
'prautoblogger_sources' => [
|
'prautoblogger_sources' => array(
|
||||||
'title' => __( 'Sources', 'prautoblogger' ),
|
'title' => __( 'Sources', 'prautoblogger' ),
|
||||||
'icon' => 'dashicons-rss',
|
'icon' => 'dashicons-rss',
|
||||||
'description' => __( 'Configure where PRAutoBlogger finds trending topics.', 'prautoblogger' ),
|
'description' => __( 'Configure where PRAutoBlogger finds trending topics.', 'prautoblogger' ),
|
||||||
],
|
),
|
||||||
'prautoblogger_schedule' => [
|
'prautoblogger_schedule' => array(
|
||||||
'title' => __( 'Schedule & Budget', 'prautoblogger' ),
|
'title' => __( 'Schedule & Budget', 'prautoblogger' ),
|
||||||
'icon' => 'dashicons-calendar-alt',
|
'icon' => 'dashicons-calendar-alt',
|
||||||
'description' => __( 'Set daily generation schedule, volume, and spending limits.', 'prautoblogger' ),
|
'description' => __( 'Set daily generation schedule, volume, and spending limits.', 'prautoblogger' ),
|
||||||
],
|
),
|
||||||
'prautoblogger_publishing' => [
|
'prautoblogger_publishing' => array(
|
||||||
'title' => __( 'Publishing', 'prautoblogger' ),
|
'title' => __( 'Publishing', 'prautoblogger' ),
|
||||||
'icon' => 'dashicons-megaphone',
|
'icon' => 'dashicons-megaphone',
|
||||||
'description' => __( 'Control how generated content is published.', 'prautoblogger' ),
|
'description' => __( 'Control how generated content is published.', 'prautoblogger' ),
|
||||||
],
|
),
|
||||||
'prautoblogger_analytics' => [
|
'prautoblogger_analytics' => array(
|
||||||
'title' => __( 'Analytics', 'prautoblogger' ),
|
'title' => __( 'Analytics', 'prautoblogger' ),
|
||||||
'icon' => 'dashicons-chart-area',
|
'icon' => 'dashicons-chart-area',
|
||||||
'description' => __( 'Connect Google Analytics 4 for post performance scoring.', 'prautoblogger' ),
|
'description' => __( 'Connect Google Analytics 4 for post performance scoring.', 'prautoblogger' ),
|
||||||
],
|
),
|
||||||
'prautoblogger_images' => [
|
'prautoblogger_display' => array(
|
||||||
|
'title' => __( 'Display', 'prautoblogger' ),
|
||||||
|
'icon' => 'dashicons-editor-textcolor',
|
||||||
|
'description' => __( 'Control how generated articles look on the frontend — fonts, sizes, and table styling.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
'prautoblogger_images' => array(
|
||||||
'title' => __( 'Images', 'prautoblogger' ),
|
'title' => __( 'Images', 'prautoblogger' ),
|
||||||
'icon' => 'dashicons-format-image',
|
'icon' => 'dashicons-format-image',
|
||||||
'description' => __( 'Cloudflare Workers AI credentials and image style controls.', 'prautoblogger' ),
|
'description' => __( 'Image provider, model, and style controls. OpenRouter reuses your existing API key.', 'prautoblogger' ),
|
||||||
],
|
),
|
||||||
];
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all settings fields. Adding a new setting = adding one entry to this array.
|
* Get all settings fields. Core fields here, operational fields merged from Extended.
|
||||||
*
|
*
|
||||||
* @return array<int, array<string, mixed>>
|
* @return array<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
public static function get_fields(): array {
|
public static function get_fields(): array {
|
||||||
return [
|
return array_merge( self::get_core_fields(), PRAutoBlogger_Settings_Fields_Extended::get_fields() );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core fields: API Keys, AI Models, Content, and Sources.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private static function get_core_fields(): array {
|
||||||
|
return array(
|
||||||
// ── API Keys ────────────────────────────────────────────────
|
// ── API Keys ────────────────────────────────────────────────
|
||||||
[
|
array(
|
||||||
'id' => 'prautoblogger_openrouter_api_key',
|
'id' => 'prautoblogger_openrouter_api_key',
|
||||||
'label' => __( 'OpenRouter API Key', 'prautoblogger' ),
|
'label' => __( 'OpenRouter API Key', 'prautoblogger' ),
|
||||||
'type' => 'password',
|
'type' => 'password',
|
||||||
'section' => 'prautoblogger_api',
|
'section' => 'prautoblogger_api',
|
||||||
'description' => __( 'Get your key at openrouter.ai/keys', 'prautoblogger' ),
|
'description' => __( 'Get your key at openrouter.ai/keys', 'prautoblogger' ),
|
||||||
'icon' => '🔑',
|
'icon' => '🔑',
|
||||||
],
|
),
|
||||||
[
|
array(
|
||||||
'id' => 'prautoblogger_reddit_source_status',
|
'id' => 'prautoblogger_reddit_source_status',
|
||||||
'label' => __( 'Reddit Data Source', 'prautoblogger' ),
|
'label' => __( 'Reddit Data Source', 'prautoblogger' ),
|
||||||
'type' => 'source_status',
|
'type' => 'source_status',
|
||||||
'section' => 'prautoblogger_api',
|
'section' => 'prautoblogger_api',
|
||||||
'description' => __( 'Uses Reddit RSS (primary) and .json (fallback). No API key required.', 'prautoblogger' ),
|
'description' => __( 'Uses Reddit RSS (primary) and .json (fallback). No API key required.', 'prautoblogger' ),
|
||||||
'icon' => '📡',
|
'icon' => '📡',
|
||||||
],
|
),
|
||||||
[
|
array(
|
||||||
'id' => 'prautoblogger_ai_gateway_base_url',
|
'id' => 'prautoblogger_ai_gateway_base_url',
|
||||||
'label' => __( 'Cloudflare AI Gateway URL', 'prautoblogger' ),
|
'label' => __( 'Cloudflare AI Gateway URL', 'prautoblogger' ),
|
||||||
'type' => 'text',
|
'type' => 'text',
|
||||||
@@ -97,8 +116,8 @@ class PRAutoBlogger_Settings_Fields {
|
|||||||
'default' => '',
|
'default' => '',
|
||||||
'description' => __( 'Optional. Route OpenRouter calls through a Cloudflare AI Gateway for caching, cost logging, and rate limiting. Format: https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}/openrouter — leave blank to call OpenRouter directly.', 'prautoblogger' ),
|
'description' => __( 'Optional. Route OpenRouter calls through a Cloudflare AI Gateway for caching, cost logging, and rate limiting. Format: https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}/openrouter — leave blank to call OpenRouter directly.', 'prautoblogger' ),
|
||||||
'icon' => '☁️',
|
'icon' => '☁️',
|
||||||
],
|
),
|
||||||
[
|
array(
|
||||||
'id' => 'prautoblogger_ai_gateway_cache_ttl',
|
'id' => 'prautoblogger_ai_gateway_cache_ttl',
|
||||||
'label' => __( 'AI Gateway Cache TTL (seconds)', 'prautoblogger' ),
|
'label' => __( 'AI Gateway Cache TTL (seconds)', 'prautoblogger' ),
|
||||||
'type' => 'number',
|
'type' => 'number',
|
||||||
@@ -107,114 +126,142 @@ class PRAutoBlogger_Settings_Fields {
|
|||||||
'min' => 0,
|
'min' => 0,
|
||||||
'max' => 2592000,
|
'max' => 2592000,
|
||||||
'description' => __( 'How long Cloudflare may serve cached responses for identical LLM calls. 0 disables caching. Only used when a gateway URL is set above. Safe values: 0 for article generation (always fresh), 3600+ for repeated classification/scoring calls.', 'prautoblogger' ),
|
'description' => __( 'How long Cloudflare may serve cached responses for identical LLM calls. 0 disables caching. Only used when a gateway URL is set above. Safe values: 0 for article generation (always fresh), 3600+ for repeated classification/scoring calls.', 'prautoblogger' ),
|
||||||
],
|
),
|
||||||
|
|
||||||
// ── AI Models ───────────────────────────────────────────────
|
// ── AI Models ───────────────────────────────────────────────
|
||||||
[
|
array(
|
||||||
'id' => 'prautoblogger_analysis_model',
|
'id' => 'prautoblogger_analysis_model',
|
||||||
'label' => __( 'Analysis Model', 'prautoblogger' ),
|
'label' => __( 'Analysis Model', 'prautoblogger' ),
|
||||||
'type' => 'text',
|
'type' => 'model_select',
|
||||||
'section' => 'prautoblogger_models',
|
'section' => 'prautoblogger_models',
|
||||||
'default' => PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL,
|
'default' => PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL,
|
||||||
|
'capability' => 'text→text',
|
||||||
'description' => __( 'Used for source analysis. Pick a cheap, fast model.', 'prautoblogger' ),
|
'description' => __( 'Used for source analysis. Pick a cheap, fast model.', 'prautoblogger' ),
|
||||||
'badge' => __( 'Low cost', 'prautoblogger' ),
|
'badge' => __( 'Low cost', 'prautoblogger' ),
|
||||||
],
|
),
|
||||||
[
|
array(
|
||||||
'id' => 'prautoblogger_writing_model',
|
'id' => 'prautoblogger_writing_model',
|
||||||
'label' => __( 'Writing Model', 'prautoblogger' ),
|
'label' => __( 'Writing Model', 'prautoblogger' ),
|
||||||
'type' => 'text',
|
'type' => 'model_select',
|
||||||
'section' => 'prautoblogger_models',
|
'section' => 'prautoblogger_models',
|
||||||
'default' => PRAUTOBLOGGER_DEFAULT_WRITING_MODEL,
|
'default' => PRAUTOBLOGGER_DEFAULT_WRITING_MODEL,
|
||||||
|
'capability' => 'text→text',
|
||||||
'description' => __( 'Used for article generation. Quality matters here.', 'prautoblogger' ),
|
'description' => __( 'Used for article generation. Quality matters here.', 'prautoblogger' ),
|
||||||
'badge' => __( 'Quality', 'prautoblogger' ),
|
'badge' => __( 'Quality', 'prautoblogger' ),
|
||||||
],
|
),
|
||||||
[
|
array(
|
||||||
'id' => 'prautoblogger_editor_model',
|
'id' => 'prautoblogger_editor_model',
|
||||||
'label' => __( 'Editor Model', 'prautoblogger' ),
|
'label' => __( 'Editor Model', 'prautoblogger' ),
|
||||||
'type' => 'text',
|
'type' => 'model_select',
|
||||||
'section' => 'prautoblogger_models',
|
'section' => 'prautoblogger_models',
|
||||||
'default' => PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL,
|
'default' => PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL,
|
||||||
|
'capability' => 'text→text',
|
||||||
'description' => __( 'Used for the chief editor review pass.', 'prautoblogger' ),
|
'description' => __( 'Used for the chief editor review pass.', 'prautoblogger' ),
|
||||||
'badge' => __( 'Quality', 'prautoblogger' ),
|
'badge' => __( 'Quality', 'prautoblogger' ),
|
||||||
],
|
),
|
||||||
|
|
||||||
// ── Content ─────────────────────────────────────────────────
|
// ── Content ─────────────────────────────────────────────────
|
||||||
[
|
array(
|
||||||
'id' => 'prautoblogger_niche_description',
|
'id' => 'prautoblogger_niche_description',
|
||||||
'label' => __( 'Niche Description', 'prautoblogger' ),
|
'label' => __( 'Niche Description', 'prautoblogger' ),
|
||||||
'type' => 'textarea',
|
'type' => 'textarea',
|
||||||
'section' => 'prautoblogger_content',
|
'section' => 'prautoblogger_content',
|
||||||
'description' => __( 'Describe your site\'s niche. Guides content analysis and generation.', 'prautoblogger' ),
|
'description' => __( 'Describe your site\'s niche. Guides content analysis and generation.', 'prautoblogger' ),
|
||||||
],
|
),
|
||||||
[
|
array(
|
||||||
|
'id' => 'prautoblogger_analysis_instructions',
|
||||||
|
'label' => __( 'Analysis Instructions', 'prautoblogger' ),
|
||||||
|
'type' => 'textarea',
|
||||||
|
'section' => 'prautoblogger_content',
|
||||||
|
'default' => '',
|
||||||
|
'description' => __( 'Custom instructions for the topic analysis LLM. Steers how source data is evaluated and which ideas get surfaced — e.g. "Prioritize questions over news. Ignore price-related discussions."', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
'id' => 'prautoblogger_tone',
|
'id' => 'prautoblogger_tone',
|
||||||
'label' => __( 'Content Tone', 'prautoblogger' ),
|
'label' => __( 'Content Tone', 'prautoblogger' ),
|
||||||
'type' => 'select',
|
'type' => 'select',
|
||||||
'section' => 'prautoblogger_content',
|
'section' => 'prautoblogger_content',
|
||||||
'default' => 'informational',
|
'default' => 'informational',
|
||||||
'options' => [
|
'options' => array(
|
||||||
'informational' => __( 'Informational', 'prautoblogger' ),
|
'informational' => __( 'Informational', 'prautoblogger' ),
|
||||||
'conversational' => __( 'Conversational', 'prautoblogger' ),
|
'conversational' => __( 'Conversational', 'prautoblogger' ),
|
||||||
'professional' => __( 'Professional', 'prautoblogger' ),
|
'professional' => __( 'Professional', 'prautoblogger' ),
|
||||||
'casual' => __( 'Casual', 'prautoblogger' ),
|
'casual' => __( 'Casual', 'prautoblogger' ),
|
||||||
'authoritative' => __( 'Authoritative', 'prautoblogger' ),
|
'authoritative' => __( 'Authoritative', 'prautoblogger' ),
|
||||||
],
|
),
|
||||||
],
|
),
|
||||||
[
|
array(
|
||||||
'id' => 'prautoblogger_writing_pipeline',
|
'id' => 'prautoblogger_writing_pipeline',
|
||||||
'label' => __( 'Writing Pipeline', 'prautoblogger' ),
|
'label' => __( 'Writing Pipeline', 'prautoblogger' ),
|
||||||
'type' => 'select',
|
'type' => 'select',
|
||||||
'section' => 'prautoblogger_content',
|
'section' => 'prautoblogger_content',
|
||||||
'default' => 'multi_step',
|
'default' => 'multi_step',
|
||||||
'options' => [
|
'options' => array(
|
||||||
'multi_step' => __( 'Multi-step (outline → draft → polish)', 'prautoblogger' ),
|
'multi_step' => __( 'Multi-step (outline → draft → polish)', 'prautoblogger' ),
|
||||||
'single_pass' => __( 'Single-pass (one LLM call)', 'prautoblogger' ),
|
'single_pass' => __( 'Single-pass (one LLM call)', 'prautoblogger' ),
|
||||||
],
|
),
|
||||||
],
|
),
|
||||||
[
|
array(
|
||||||
'id' => 'prautoblogger_min_word_count',
|
'id' => 'prautoblogger_min_word_count',
|
||||||
'label' => __( 'Min Word Count', 'prautoblogger' ),
|
'label' => __( 'Min Word Count', 'prautoblogger' ),
|
||||||
'type' => 'number',
|
'type' => 'number',
|
||||||
'section' => 'prautoblogger_content',
|
'section' => 'prautoblogger_content',
|
||||||
'default' => 800,
|
'default' => 800,
|
||||||
'min' => 200,
|
'min' => 200,
|
||||||
],
|
),
|
||||||
[
|
array(
|
||||||
'id' => 'prautoblogger_max_word_count',
|
'id' => 'prautoblogger_max_word_count',
|
||||||
'label' => __( 'Max Word Count', 'prautoblogger' ),
|
'label' => __( 'Max Word Count', 'prautoblogger' ),
|
||||||
'type' => 'number',
|
'type' => 'number',
|
||||||
'section' => 'prautoblogger_content',
|
'section' => 'prautoblogger_content',
|
||||||
'default' => 2000,
|
'default' => 2000,
|
||||||
'min' => 500,
|
'min' => 500,
|
||||||
],
|
),
|
||||||
[
|
array(
|
||||||
|
'id' => 'prautoblogger_writing_instructions',
|
||||||
|
'label' => __( 'Writing Instructions', 'prautoblogger' ),
|
||||||
|
'type' => 'textarea',
|
||||||
|
'section' => 'prautoblogger_content',
|
||||||
|
'default' => '',
|
||||||
|
'description' => __( 'Custom instructions appended to the LLM\'s system prompt when writing articles. Use this to steer style, structure, voice, and formatting — e.g. "Always open with a hook question. Use short paragraphs. Cite at least two studies."', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'prautoblogger_editor_instructions',
|
||||||
|
'label' => __( 'Editor Instructions', 'prautoblogger' ),
|
||||||
|
'type' => 'textarea',
|
||||||
|
'section' => 'prautoblogger_content',
|
||||||
|
'default' => '',
|
||||||
|
'description' => __( 'Custom instructions for the chief editor LLM review pass. Steers what the editor looks for and how it revises — e.g. "Be strict about medical disclaimers. Reject any article under 800 words."', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
'id' => 'prautoblogger_topic_exclusions',
|
'id' => 'prautoblogger_topic_exclusions',
|
||||||
'label' => __( 'Topic Exclusions', 'prautoblogger' ),
|
'label' => __( 'Topic Exclusions', 'prautoblogger' ),
|
||||||
'type' => 'textarea',
|
'type' => 'textarea',
|
||||||
'section' => 'prautoblogger_content',
|
'section' => 'prautoblogger_content',
|
||||||
'description' => __( 'Comma-separated topics to never write about.', 'prautoblogger' ),
|
'description' => __( 'Comma-separated topics to never write about.', 'prautoblogger' ),
|
||||||
],
|
),
|
||||||
|
|
||||||
// ── Sources ─────────────────────────────────────────────────
|
// ── Sources ─────────────────────────────────────────────────
|
||||||
[
|
array(
|
||||||
'id' => 'prautoblogger_target_subreddits',
|
'id' => 'prautoblogger_target_subreddits',
|
||||||
'label' => __( 'Target Subreddits', 'prautoblogger' ),
|
'label' => __( 'Target Subreddits', 'prautoblogger' ),
|
||||||
'type' => 'textarea',
|
'type' => 'textarea',
|
||||||
'section' => 'prautoblogger_sources',
|
'section' => 'prautoblogger_sources',
|
||||||
'description' => __( 'Comma-separated, without r/. E.g.: peptides, Nootropics, biohackers', 'prautoblogger' ),
|
'description' => __( 'Comma-separated, without r/. E.g.: peptides, Nootropics, biohackers', 'prautoblogger' ),
|
||||||
],
|
),
|
||||||
[
|
array(
|
||||||
'id' => 'prautoblogger_enabled_sources',
|
'id' => 'prautoblogger_enabled_sources',
|
||||||
'label' => __( 'Enabled Sources', 'prautoblogger' ),
|
'label' => __( 'Enabled Sources', 'prautoblogger' ),
|
||||||
'type' => 'checkboxes',
|
'type' => 'checkboxes',
|
||||||
'section' => 'prautoblogger_sources',
|
'section' => 'prautoblogger_sources',
|
||||||
'options' => [
|
'options' => array(
|
||||||
'reddit' => __( 'Reddit (RSS + .json)', 'prautoblogger' ),
|
'reddit' => __( 'Reddit (RSS + .json)', 'prautoblogger' ),
|
||||||
],
|
'llm_research' => __( 'LLM Deep Research (reasoning model)', 'prautoblogger' ),
|
||||||
|
),
|
||||||
'default' => '["reddit"]',
|
'default' => '["reddit"]',
|
||||||
'description' => __( 'Select which platforms to monitor for topics.', 'prautoblogger' ),
|
'description' => __( 'Select which platforms to monitor for topics.', 'prautoblogger' ),
|
||||||
],
|
),
|
||||||
[
|
array(
|
||||||
'id' => 'prautoblogger_pullpush_cache_ttl',
|
'id' => 'prautoblogger_pullpush_cache_ttl',
|
||||||
'label' => __( 'Research Cache (hours)', 'prautoblogger' ),
|
'label' => __( 'Research Cache (hours)', 'prautoblogger' ),
|
||||||
'type' => 'number',
|
'type' => 'number',
|
||||||
@@ -223,21 +270,21 @@ class PRAutoBlogger_Settings_Fields {
|
|||||||
'min' => 1,
|
'min' => 1,
|
||||||
'max' => 72,
|
'max' => 72,
|
||||||
'description' => __( 'How long to cache Reddit research results before re-fetching. Saves bandwidth on low-traffic sites.', 'prautoblogger' ),
|
'description' => __( 'How long to cache Reddit research results before re-fetching. Saves bandwidth on low-traffic sites.', 'prautoblogger' ),
|
||||||
],
|
),
|
||||||
[
|
array(
|
||||||
'id' => 'prautoblogger_reddit_time_filter',
|
'id' => 'prautoblogger_reddit_time_filter',
|
||||||
'label' => __( 'Reddit Time Window', 'prautoblogger' ),
|
'label' => __( 'Reddit Time Window', 'prautoblogger' ),
|
||||||
'type' => 'select',
|
'type' => 'select',
|
||||||
'section' => 'prautoblogger_sources',
|
'section' => 'prautoblogger_sources',
|
||||||
'default' => 'day',
|
'default' => 'day',
|
||||||
'options' => [
|
'options' => array(
|
||||||
'day' => __( 'Past 24 hours', 'prautoblogger' ),
|
'day' => __( 'Past 24 hours', 'prautoblogger' ),
|
||||||
'week' => __( 'Past week', 'prautoblogger' ),
|
'week' => __( 'Past week', 'prautoblogger' ),
|
||||||
'month' => __( 'Past month', 'prautoblogger' ),
|
'month' => __( 'Past month', 'prautoblogger' ),
|
||||||
],
|
),
|
||||||
'description' => __( 'How far back to search for trending posts and comments.', 'prautoblogger' ),
|
'description' => __( 'How far back to search for trending posts and comments.', 'prautoblogger' ),
|
||||||
],
|
),
|
||||||
[
|
array(
|
||||||
'id' => 'prautoblogger_reddit_posts_per_subreddit',
|
'id' => 'prautoblogger_reddit_posts_per_subreddit',
|
||||||
'label' => __( 'Posts per Subreddit', 'prautoblogger' ),
|
'label' => __( 'Posts per Subreddit', 'prautoblogger' ),
|
||||||
'type' => 'number',
|
'type' => 'number',
|
||||||
@@ -246,141 +293,7 @@ class PRAutoBlogger_Settings_Fields {
|
|||||||
'min' => 5,
|
'min' => 5,
|
||||||
'max' => 100,
|
'max' => 100,
|
||||||
'description' => __( 'Maximum posts to fetch per subreddit per collection run.', 'prautoblogger' ),
|
'description' => __( 'Maximum posts to fetch per subreddit per collection run.', 'prautoblogger' ),
|
||||||
],
|
),
|
||||||
|
);
|
||||||
// ── Schedule & Budget ───────────────────────────────────────
|
|
||||||
[
|
|
||||||
'id' => 'prautoblogger_daily_article_target',
|
|
||||||
'label' => __( 'Articles per Day', 'prautoblogger' ),
|
|
||||||
'type' => 'number',
|
|
||||||
'section' => 'prautoblogger_schedule',
|
|
||||||
'default' => 1,
|
|
||||||
'min' => 1,
|
|
||||||
'max' => 10,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'prautoblogger_schedule_time',
|
|
||||||
'label' => __( 'Generation Time', 'prautoblogger' ),
|
|
||||||
'type' => 'time',
|
|
||||||
'section' => 'prautoblogger_schedule',
|
|
||||||
'default' => '03:00',
|
|
||||||
'description' => __( 'Daily generation runs at this time (server timezone).', 'prautoblogger' ),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'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 ──────────────────────────────────────────────
|
|
||||||
[
|
|
||||||
'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' ),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'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' ),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'prautoblogger_default_category',
|
|
||||||
'label' => __( 'Default Category', 'prautoblogger' ),
|
|
||||||
'type' => 'category_select',
|
|
||||||
'section' => 'prautoblogger_publishing',
|
|
||||||
'default' => 0,
|
|
||||||
'description' => __( 'Fallback category for generated posts.', 'prautoblogger' ),
|
|
||||||
],
|
|
||||||
|
|
||||||
// ── Logging ─────────────────────────────────────────────────
|
|
||||||
[
|
|
||||||
'id' => 'prautoblogger_log_level',
|
|
||||||
'label' => __( 'Log Level', 'prautoblogger' ),
|
|
||||||
'type' => 'select',
|
|
||||||
'section' => 'prautoblogger_publishing',
|
|
||||||
'default' => 'info',
|
|
||||||
'options' => [
|
|
||||||
'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 ───────────────────────────────────────────────
|
|
||||||
[
|
|
||||||
'id' => 'prautoblogger_ga4_property_id',
|
|
||||||
'label' => __( 'GA4 Property ID', 'prautoblogger' ),
|
|
||||||
'type' => 'text',
|
|
||||||
'section' => 'prautoblogger_analytics',
|
|
||||||
'description' => __( 'Format: properties/XXXXXXXXX. Leave blank to skip GA4.', 'prautoblogger' ),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'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' ),
|
|
||||||
],
|
|
||||||
|
|
||||||
// ── Images ─────────────────────────────────────────────────
|
|
||||||
[
|
|
||||||
'id' => 'prautoblogger_image_enabled',
|
|
||||||
'label' => __( 'Enable Image Generation', 'prautoblogger' ),
|
|
||||||
'type' => 'toggle',
|
|
||||||
'section' => 'prautoblogger_images',
|
|
||||||
'default' => '0',
|
|
||||||
'description' => __( 'When enabled, generate two images (A/B) for each published article. Requires Cloudflare credentials.', 'prautoblogger' ),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'prautoblogger_cloudflare_ai_token',
|
|
||||||
'label' => __( 'Cloudflare API Token', 'prautoblogger' ),
|
|
||||||
'type' => 'password',
|
|
||||||
'section' => 'prautoblogger_images',
|
|
||||||
'description' => __( 'Workers AI token with Read + Edit scope. Create at dash.cloudflare.com → AI → API Tokens.', 'prautoblogger' ),
|
|
||||||
'icon' => '🔑',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'prautoblogger_cloudflare_account_id',
|
|
||||||
'label' => __( 'Cloudflare Account ID', 'prautoblogger' ),
|
|
||||||
'type' => 'text',
|
|
||||||
'section' => 'prautoblogger_images',
|
|
||||||
'default' => '',
|
|
||||||
'description' => __( 'The Account ID shown on your Cloudflare dashboard sidebar. Used to build the Workers AI URL.', 'prautoblogger' ),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'prautoblogger_image_model',
|
|
||||||
'label' => __( 'Image Model', 'prautoblogger' ),
|
|
||||||
'type' => 'select',
|
|
||||||
'section' => 'prautoblogger_images',
|
|
||||||
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL,
|
|
||||||
'options' => [
|
|
||||||
'flux-1-schnell' => __( 'FLUX.1 [schnell] — fast + cheap (default)', 'prautoblogger' ),
|
|
||||||
'flux-1-dev' => __( 'FLUX.1 [dev] — higher quality, ~4× cost', 'prautoblogger' ),
|
|
||||||
],
|
|
||||||
'description' => __( 'Schnell is the normal choice. Only switch to [dev] when a specific post needs higher quality.', 'prautoblogger' ),
|
|
||||||
'badge' => __( 'Low cost', 'prautoblogger' ),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'prautoblogger_image_style_suffix',
|
|
||||||
'label' => __( 'Style Suffix', 'prautoblogger' ),
|
|
||||||
'type' => 'textarea',
|
|
||||||
'section' => 'prautoblogger_images',
|
|
||||||
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX,
|
|
||||||
'description' => __( 'Appended to every image prompt. Controls the visual look across all placements. Changing this mid-run will cause visible style drift — prefer changing during quiet periods.', 'prautoblogger' ),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
107
includes/admin/class-settings-sanitizer.php
Normal file
107
includes/admin/class-settings-sanitizer.php
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Settings field sanitizer for PRAutoBlogger.
|
||||||
|
*
|
||||||
|
* Centralises all option sanitization logic: encryption for API keys, JSON
|
||||||
|
* encoding for multi-value fields, numeric coercion, image-model registry
|
||||||
|
* validation. Extracted from PRAutoBlogger_Admin_Page for 300-line compliance.
|
||||||
|
*
|
||||||
|
* What: Static utility called by register_setting() sanitize_callback.
|
||||||
|
* Who triggers: WordPress Settings API on option save; PRAutoBlogger_Admin_Page::sanitize_field().
|
||||||
|
* Dependencies: PRAutoBlogger_Encryption, PRAutoBlogger_Image_Model_Registry,
|
||||||
|
* PRAutoBlogger_Image_Template_Filler.
|
||||||
|
*
|
||||||
|
* @see admin/class-admin-page.php -- Registers settings and delegates here.
|
||||||
|
* @see admin/class-image-model-registry.php -- Validates image model slug on save.
|
||||||
|
* @see CONVENTIONS.md -- "How To: Add a New Admin Setting".
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Settings_Sanitizer {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize a settings field value.
|
||||||
|
*
|
||||||
|
* @param mixed $value The submitted value.
|
||||||
|
* @return mixed Sanitized value.
|
||||||
|
*/
|
||||||
|
public static function sanitize_field( $value ) {
|
||||||
|
$option_name = '';
|
||||||
|
$filter = current_filter();
|
||||||
|
if ( 0 === strpos( $filter, 'sanitize_option_' ) ) {
|
||||||
|
$option_name = substr( $filter, strlen( 'sanitize_option_' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$encrypted = array( 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json', 'prautoblogger_runware_api_key' );
|
||||||
|
if ( in_array( $option_name, $encrypted, true ) ) {
|
||||||
|
// Empty value means password field wasn't touched — keep existing.
|
||||||
|
if ( '' === $value ) {
|
||||||
|
return get_option( $option_name, '' );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already encrypted (has "enc:" prefix) — return as-is.
|
||||||
|
// This is the primary defence against double-encryption:
|
||||||
|
// PRAutoBlogger_Encryption::encrypt() also checks for this prefix,
|
||||||
|
// so even if this callback is called multiple times, the value
|
||||||
|
// is encrypted exactly once and never re-encrypted.
|
||||||
|
if ( PRAutoBlogger_Encryption::is_encrypted( $value ) ) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// New plaintext value — encrypt it (adds "enc:" prefix).
|
||||||
|
return PRAutoBlogger_Encryption::encrypt( sanitize_text_field( $value ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$json_fields = array( 'prautoblogger_target_subreddits', 'prautoblogger_topic_exclusions', 'prautoblogger_enabled_sources' );
|
||||||
|
if ( in_array( $option_name, $json_fields, true ) ) {
|
||||||
|
// PHP array from checkboxes — sanitize each item and re-encode.
|
||||||
|
if ( is_array( $value ) ) {
|
||||||
|
return wp_json_encode( array_values( array_map( 'sanitize_text_field', $value ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$trimmed = trim( (string) $value );
|
||||||
|
|
||||||
|
// Already JSON-encoded array — decode, sanitize each item, re-encode.
|
||||||
|
// This prevents sanitize_text_field() from mangling the JSON string,
|
||||||
|
// which caused json_decode() on read to return null → empty subreddits.
|
||||||
|
if ( '[' === substr( $trimmed, 0, 1 ) ) {
|
||||||
|
$decoded = json_decode( $trimmed, true );
|
||||||
|
if ( is_array( $decoded ) ) {
|
||||||
|
return wp_json_encode( array_values( array_map( 'sanitize_text_field', $decoded ) ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comma-separated plain text — split, sanitize, encode as JSON array.
|
||||||
|
$items = array_filter( array_map( 'trim', explode( ',', $trimmed ) ) );
|
||||||
|
return wp_json_encode( array_values( array_map( 'sanitize_text_field', $items ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$numeric = array( 'prautoblogger_daily_article_target', 'prautoblogger_monthly_budget_usd', 'prautoblogger_min_word_count', 'prautoblogger_max_word_count', 'prautoblogger_default_author', 'prautoblogger_default_category', 'prautoblogger_pullpush_cache_ttl', 'prautoblogger_reddit_posts_per_subreddit', 'prautoblogger_board_poll_interval', 'prautoblogger_board_published_window_days', 'prautoblogger_board_column_limit', 'prautoblogger_ideas_per_page' );
|
||||||
|
if ( in_array( $option_name, $numeric, true ) ) {
|
||||||
|
return is_numeric( $value ) ? $value : 0;
|
||||||
|
}
|
||||||
|
if ( 'prautoblogger_image_model' === $option_name ) {
|
||||||
|
return self::sanitize_image_model( (string) $value );
|
||||||
|
}
|
||||||
|
if ( 'prautoblogger_image_style_template' === $option_name ) {
|
||||||
|
// Multi-line template: validation + textarea-safe sanitisation
|
||||||
|
// (incl. the single-token check, brief A5) lives in the filler.
|
||||||
|
return PRAutoBlogger_Image_Template_Filler::sanitize_for_save( (string) $value );
|
||||||
|
}
|
||||||
|
return sanitize_text_field( (string) $value );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate model id against the registry and persist the derived provider. */
|
||||||
|
private static function sanitize_image_model( string $value ): string {
|
||||||
|
$candidate = sanitize_text_field( $value );
|
||||||
|
$provider = PRAutoBlogger_Image_Model_Registry::provider_for( $candidate );
|
||||||
|
if ( '' !== $provider ) {
|
||||||
|
update_option( 'prautoblogger_image_provider', $provider );
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
add_settings_error( 'prautoblogger_image_model', 'prautoblogger_image_model_unknown', sprintf( esc_html__( 'Image model "%s" is not in the registry. Keeping the previous selection.', 'prautoblogger' ), esc_html( $candidate ) ) );
|
||||||
|
return (string) get_option( 'prautoblogger_image_model', PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL );
|
||||||
|
}
|
||||||
|
}
|
||||||
103
includes/admin/class-wp-cli-commands.php
Normal file
103
includes/admin/class-wp-cli-commands.php
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WP-CLI commands for PRAutoBlogger.
|
||||||
|
*
|
||||||
|
* Registers custom wp-cli commands exposed to the plugin.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_WP_CLI_Commands {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register WP-CLI commands.
|
||||||
|
*
|
||||||
|
* Called on plugins_loaded hook.
|
||||||
|
*/
|
||||||
|
public static function register(): void {
|
||||||
|
if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command: wp prautoblogger opik:eval
|
||||||
|
\WP_CLI::add_command(
|
||||||
|
'prautoblogger generate',
|
||||||
|
array( self::class, 'generate_command' ),
|
||||||
|
array(
|
||||||
|
'shortdesc' => 'Queue a new article generation run on chained-cron checkpoints',
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
\WP_CLI::add_command(
|
||||||
|
'prautoblogger opik:eval',
|
||||||
|
array( self::class, 'opik_eval_command' ),
|
||||||
|
array(
|
||||||
|
'shortdesc' => 'Run Opik evals on the frozen dataset',
|
||||||
|
'synopsis' => array(
|
||||||
|
array(
|
||||||
|
'type' => 'assoc',
|
||||||
|
'name' => 'limit',
|
||||||
|
'description' => 'Max items to run (0 = all)',
|
||||||
|
'optional' => true,
|
||||||
|
'default' => '0',
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'type' => 'flag',
|
||||||
|
'name' => 'dry-run',
|
||||||
|
'description' => 'Skip Opik API push; score locally only',
|
||||||
|
'optional' => true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run eval on the frozen dataset.
|
||||||
|
*
|
||||||
|
* @param array $args Positional args (unused).
|
||||||
|
* @param array $assoc_args Associative args: limit, dry-run.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function opik_eval_command( array $args, array $assoc_args ): void {
|
||||||
|
// Check if Opik is enabled.
|
||||||
|
if ( empty( PRAUTOBLOGGER_OPIK_API_KEY ) || empty( PRAUTOBLOGGER_OPIK_WORKSPACE ) ) {
|
||||||
|
if ( ! isset( $assoc_args['dry-run'] ) ) {
|
||||||
|
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_echo -- CLI output
|
||||||
|
echo "Warning: Opik is not configured. Running with --dry-run only.\n";
|
||||||
|
$assoc_args['dry-run'] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = absint( $assoc_args['limit'] ?? 0 );
|
||||||
|
$dry_run = isset( $assoc_args['dry-run'] );
|
||||||
|
|
||||||
|
$runner = new PRAutoBlogger_Opik_Eval_Runner();
|
||||||
|
$result = $runner->run( $limit, $dry_run );
|
||||||
|
|
||||||
|
exit( $result['items_run'] > 0 ? 0 : 1 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue a new article generation run on chained-cron checkpoints.
|
||||||
|
*
|
||||||
|
* Routes through PRAutoBlogger_Generation_Checkpoint_Runner::kick_off()
|
||||||
|
* (same path as the board "New Article" button). Does NOT execute
|
||||||
|
* synchronously -- check the board or Activity Log for progress.
|
||||||
|
* This retires the `do_action("prautoblogger_manual_generation")` workaround.
|
||||||
|
*
|
||||||
|
* ## OPTIONS
|
||||||
|
*
|
||||||
|
* ## EXAMPLES
|
||||||
|
*
|
||||||
|
* wp prautoblogger generate
|
||||||
|
*
|
||||||
|
* @param array $args Positional args (unused).
|
||||||
|
* @param array $assoc_args Associative args (unused).
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function generate_command( array $args, array $assoc_args ): void {
|
||||||
|
PRAutoBlogger_Generation_Checkpoint_Runner::kick_off();
|
||||||
|
\WP_CLI::success( 'Generation queued on chained-cron checkpoints. Check the board or Activity Log for progress.' );
|
||||||
|
}
|
||||||
|
}
|
||||||
163
includes/admin/fields/class-open-router-model-field.php
Normal file
163
includes/admin/fields/class-open-router-model-field.php
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Renders the interactive model-picker field for OpenRouter models.
|
||||||
|
*
|
||||||
|
* What: a button showing the current model's name + pricing. Clicking it
|
||||||
|
* opens a JS-powered popup (model-picker.js) with a searchable list.
|
||||||
|
* Includes an estimated cost preview below based on historical usage.
|
||||||
|
* Who calls it: PRAutoBlogger_Admin_Page::render_field() delegates here
|
||||||
|
* for fields with type 'model_select'.
|
||||||
|
* Dependencies: PRAutoBlogger_Model_Registry_Interface (cached registry lookup),
|
||||||
|
* PRAutoBlogger_Cost_Tracker (historical token averages).
|
||||||
|
*
|
||||||
|
* @see admin/class-admin-page.php — Calls render() from the main field switch.
|
||||||
|
* @see assets/js/model-picker.js — JS popup that fetches + displays models.
|
||||||
|
* @see services/interface-model-registry.php — Registry interface for find_model().
|
||||||
|
* @see core/class-cost-tracker.php — get_avg_tokens_for_stages() for cost preview.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_OpenRouter_Model_Field {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the model picker trigger button and hidden input.
|
||||||
|
*
|
||||||
|
* @param string $id Field HTML id/name attribute.
|
||||||
|
* @param string $value Current model id (e.g. 'google/gemini-2.5-flash-lite').
|
||||||
|
* @param array<string, mixed> $args Field definition from settings fields.
|
||||||
|
*/
|
||||||
|
public static function render( string $id, string $value, array $args ): void {
|
||||||
|
$capability = $args['capability'] ?? 'text→text';
|
||||||
|
$display_name = '' !== $value ? $value : __( '— Select a model —', 'prautoblogger' );
|
||||||
|
$display_price = '';
|
||||||
|
if ( 'image_generation' === $capability ) {
|
||||||
|
// Resolve from the static image model list defined in settings.
|
||||||
|
$image_models = PRAutoBlogger_Settings_Fields_Extended::get_image_models();
|
||||||
|
foreach ( $image_models as $im ) {
|
||||||
|
if ( ( $im['id'] ?? '' ) === $value ) {
|
||||||
|
$display_name = $im['name'] ?? $value;
|
||||||
|
$display_price = '$' . number_format( (float) ( $im['cost_per_image'] ?? 0 ), 4 ) . '/image';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Resolve from the OpenRouter model registry cache.
|
||||||
|
$registry = prautoblogger()->get_executor()->get_model_registry();
|
||||||
|
$model = $registry->find_model( $value );
|
||||||
|
if ( null !== $model ) {
|
||||||
|
$display_name = $model['name'] ?? $value;
|
||||||
|
$in = (float) ( $model['input_price_per_m'] ?? 0 );
|
||||||
|
$out = (float) ( $model['output_price_per_m'] ?? 0 );
|
||||||
|
$display_price = self::format_price( $in ) . ' / ' . self::format_price( $out );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hidden input carries the actual value for the form submission.
|
||||||
|
printf(
|
||||||
|
'<input type="hidden" id="%s" name="%s" value="%s" />',
|
||||||
|
esc_attr( $id ),
|
||||||
|
esc_attr( $id ),
|
||||||
|
esc_attr( $value )
|
||||||
|
);
|
||||||
|
|
||||||
|
// Trigger button — opens the model picker popup via JS.
|
||||||
|
printf(
|
||||||
|
'<button type="button" class="ab-mp-trigger" data-field-id="%s" data-capability="%s">'
|
||||||
|
. '<span class="ab-mp-display-name">%s</span>'
|
||||||
|
. '<span class="ab-mp-display-price">%s</span>'
|
||||||
|
. '<span class="ab-mp-display-arrow">▾</span>'
|
||||||
|
. '</button>',
|
||||||
|
esc_attr( $id ),
|
||||||
|
esc_attr( $capability ),
|
||||||
|
esc_html( $display_name ),
|
||||||
|
esc_html( $display_price )
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cost preview panel — only for text→text models (not image).
|
||||||
|
if ( 'image_generation' !== $capability && '' !== $value ) {
|
||||||
|
$cost_preview = self::get_cost_preview( $id, $value );
|
||||||
|
if ( $cost_preview ) {
|
||||||
|
printf(
|
||||||
|
'<div class="ab-mp-cost-preview">%s</div>',
|
||||||
|
wp_kses_post( $cost_preview )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate and render the estimated cost preview for a model + setting combo.
|
||||||
|
*
|
||||||
|
* @param string $field_id Setting ID (e.g. 'prautoblogger_writing_model').
|
||||||
|
* @param string $model_id Model ID (e.g. 'anthropic/claude-3.5-haiku').
|
||||||
|
*
|
||||||
|
* @return string HTML snippet for the cost preview, or empty if no history.
|
||||||
|
*/
|
||||||
|
private static function get_cost_preview( string $field_id, string $model_id ): string {
|
||||||
|
$stages = self::get_stages_for_setting( $field_id );
|
||||||
|
if ( empty( $stages ) ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$registry = prautoblogger()->get_executor()->get_model_registry();
|
||||||
|
$model = $registry->find_model( $model_id );
|
||||||
|
if ( null === $model ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$tracker = new PRAutoBlogger_Cost_Tracker();
|
||||||
|
$tokens = $tracker->get_avg_tokens_for_stages( $stages, 30 );
|
||||||
|
|
||||||
|
if ( 0 === $tokens['sample_size'] ) {
|
||||||
|
return '<small>' . esc_html__( 'Estimated cost: — (insufficient history)', 'prautoblogger' ) . '</small>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$in_price = (float) ( $model['input_price_per_m'] ?? 0 );
|
||||||
|
$out_price = (float) ( $model['output_price_per_m'] ?? 0 );
|
||||||
|
$avg_tokens_in = $tokens['avg_prompt_tokens'];
|
||||||
|
$avg_tokens_out = $tokens['avg_completion_tokens'];
|
||||||
|
|
||||||
|
// Cost per generation = (avg_in_tokens × in_price + avg_out_tokens × out_price) / 1_000_000
|
||||||
|
$cost_per_gen = ( ( $avg_tokens_in * $in_price ) + ( $avg_tokens_out * $out_price ) ) / 1_000_000;
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'<small>%s %s</small>',
|
||||||
|
esc_html__( 'Estimated cost per generation:', 'prautoblogger' ),
|
||||||
|
esc_html( '$' . number_format( $cost_per_gen, 4 ) )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a setting ID to its constituent pipeline stages.
|
||||||
|
*
|
||||||
|
* @param string $field_id Setting ID.
|
||||||
|
*
|
||||||
|
* @return string[] Stage names, e.g. ['outline', 'draft', 'polish'] for writing model.
|
||||||
|
*/
|
||||||
|
private static function get_stages_for_setting( string $field_id ): array {
|
||||||
|
$map = array(
|
||||||
|
'prautoblogger_analysis_model' => array( 'analysis' ),
|
||||||
|
'prautoblogger_writing_model' => array( 'outline', 'draft', 'polish' ),
|
||||||
|
'prautoblogger_editor_model' => array( 'review' ),
|
||||||
|
);
|
||||||
|
return $map[ $field_id ] ?? array();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a price-per-million-tokens value for display.
|
||||||
|
*
|
||||||
|
* @param float $price Price per 1M tokens.
|
||||||
|
* @return string Formatted price string like "$0.25" or "Free".
|
||||||
|
*/
|
||||||
|
private static function format_price( float $price ): string {
|
||||||
|
if ( $price <= 0 ) {
|
||||||
|
return __( 'Free', 'prautoblogger' );
|
||||||
|
}
|
||||||
|
if ( $price < 0.01 ) {
|
||||||
|
return '$' . number_format( $price, 4 );
|
||||||
|
}
|
||||||
|
return '$' . number_format( $price, 2 );
|
||||||
|
}
|
||||||
|
}
|
||||||
68
includes/admin/fields/class-source-status-field.php
Normal file
68
includes/admin/fields/class-source-status-field.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Renders the Reddit source status indicator in the admin settings.
|
||||||
|
*
|
||||||
|
* What: shows RSS (primary) and .json (fallback) availability with
|
||||||
|
* last-collection timestamp.
|
||||||
|
* Who calls it: PRAutoBlogger_Admin_Page::render_field() delegates here
|
||||||
|
* for fields with type 'source_status'.
|
||||||
|
* Dependencies: None — reads wp_options only.
|
||||||
|
*
|
||||||
|
* @see admin/class-admin-page.php — Calls render() from the main field switch.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Source_Status_Field {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the Reddit source status indicator.
|
||||||
|
*
|
||||||
|
* Side effects: reads wp_options for last collection time.
|
||||||
|
*/
|
||||||
|
public static function render(): void {
|
||||||
|
$last_collection = get_option( 'prautoblogger_last_collection_time', '' );
|
||||||
|
$last_source_used = get_option( 'prautoblogger_last_source_used', '' );
|
||||||
|
|
||||||
|
echo '<div class="ab-source-status">';
|
||||||
|
|
||||||
|
// Reddit RSS — primary source.
|
||||||
|
printf(
|
||||||
|
'<div class="ab-source-row">'
|
||||||
|
. '<span class="ab-source-dot ab-status-ok"></span>'
|
||||||
|
. '<strong>%s</strong> <span class="ab-source-badge">%s</span>'
|
||||||
|
. '<span class="ab-source-label">%s</span>'
|
||||||
|
. '</div>',
|
||||||
|
esc_html__( 'Reddit RSS', 'prautoblogger' ),
|
||||||
|
esc_html__( 'Primary', 'prautoblogger' ),
|
||||||
|
esc_html__( 'Reliable from all IPs', 'prautoblogger' )
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reddit .json — fallback source.
|
||||||
|
printf(
|
||||||
|
'<div class="ab-source-row">'
|
||||||
|
. '<span class="ab-source-dot ab-status-ok"></span>'
|
||||||
|
. '<strong>%s</strong> <span class="ab-source-badge ab-badge-secondary">%s</span>'
|
||||||
|
. '<span class="ab-source-label">%s</span>'
|
||||||
|
. '</div>',
|
||||||
|
esc_html__( 'Reddit .json', 'prautoblogger' ),
|
||||||
|
esc_html__( 'Fallback + Comments', 'prautoblogger' ),
|
||||||
|
esc_html__( 'May be IP-blocked on some hosts', 'prautoblogger' )
|
||||||
|
);
|
||||||
|
|
||||||
|
// Last collection info.
|
||||||
|
if ( '' !== $last_collection ) {
|
||||||
|
$time_ago = human_time_diff( (int) $last_collection, time() );
|
||||||
|
printf(
|
||||||
|
'<div class="ab-source-meta">%s <strong>%s</strong> %s</div>',
|
||||||
|
esc_html__( 'Last collection:', 'prautoblogger' ),
|
||||||
|
/* translators: %s is a human-readable time difference like "2 hours" */
|
||||||
|
esc_html( sprintf( __( '%s ago', 'prautoblogger' ), $time_ago ) ),
|
||||||
|
'' !== $last_source_used ? esc_html( sprintf( __( 'via %s', 'prautoblogger' ), $last_source_used ) ) : ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
82
includes/ajax/class-model-registry-refresh.php
Normal file
82
includes/ajax/class-model-registry-refresh.php
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX handler to manually refresh the OpenRouter model registry.
|
||||||
|
*
|
||||||
|
* What: Allows admins to trigger an immediate refresh of the cached model
|
||||||
|
* list via a button in the admin UI, without waiting for the daily
|
||||||
|
* cron. Useful for testing new models or after adding an API key.
|
||||||
|
* Who calls: Admin-facing JavaScript (model-picker.js) via wp_ajax.
|
||||||
|
* Dependencies: PRAutoBlogger_OpenRouter_Model_Registry, capability gating.
|
||||||
|
*
|
||||||
|
* Triggered by: AJAX POST to 'wp-admin/admin-ajax.php?action=prautoblogger_refresh_models'
|
||||||
|
* Side effects: HTTP GET to OpenRouter /api/v1/models (optional, if 12h window passed);
|
||||||
|
* wp_options write; transient set.
|
||||||
|
*
|
||||||
|
* @see includes/class-ajax-handlers.php — Instantiation and wiring.
|
||||||
|
* @see assets/js/model-picker.js — Client-side trigger.
|
||||||
|
* @see services/class-open-router-model-registry.php — Registry that performs the work.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Model_Registry_Refresh {
|
||||||
|
|
||||||
|
/** @var PRAutoBlogger_OpenRouter_Model_Registry Lazy-loaded singleton. */
|
||||||
|
private PRAutoBlogger_OpenRouter_Model_Registry $registry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param PRAutoBlogger_OpenRouter_Model_Registry $registry Shared model registry.
|
||||||
|
*/
|
||||||
|
public function __construct( PRAutoBlogger_OpenRouter_Model_Registry $registry ) {
|
||||||
|
$this->registry = $registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the AJAX refresh request.
|
||||||
|
*
|
||||||
|
* Security: nonce-gated via wp-nonce, capability gated to manage_options.
|
||||||
|
* Response format: JSON { success: bool, data: { count: int, fetched_at: int, message: string } }
|
||||||
|
*
|
||||||
|
* @side-effect Writes HTTP GET request, database options, transient cache.
|
||||||
|
* @return void (echoes JSON)
|
||||||
|
*/
|
||||||
|
public function handle(): void {
|
||||||
|
// Nonce check.
|
||||||
|
if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'prautoblogger_refresh_models' ) ) {
|
||||||
|
wp_send_json_error(
|
||||||
|
array(
|
||||||
|
'message' => __( 'Security check failed. Please refresh the page and try again.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
403
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capability check.
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
wp_send_json_error(
|
||||||
|
array(
|
||||||
|
'message' => __( 'Insufficient permissions. Only administrators can refresh the model list.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
403
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform the refresh (force=true bypasses the idempotency window).
|
||||||
|
$result = $this->registry->refresh( true );
|
||||||
|
|
||||||
|
$count = (int) ( $result['count'] ?? 0 );
|
||||||
|
$fetched_at = (int) ( $result['fetched_at'] ?? 0 );
|
||||||
|
$mins_ago = $fetched_at > 0 ? intval( ( time() - $fetched_at ) / 60 ) : 0;
|
||||||
|
|
||||||
|
wp_send_json_success(
|
||||||
|
array(
|
||||||
|
'count' => $count,
|
||||||
|
'fetched_at' => $fetched_at,
|
||||||
|
'message' => sprintf(
|
||||||
|
__( 'Model registry refreshed: %1$d models loaded, last updated %2$s', 'prautoblogger' ),
|
||||||
|
$count,
|
||||||
|
0 === $mins_ago ? __( 'just now', 'prautoblogger' ) : sprintf( __( '%d minutes ago', 'prautoblogger' ), $mins_ago )
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
306
includes/class-activator.php
Executable file → Normal file
306
includes/class-activator.php
Executable file → Normal file
@@ -2,6 +2,8 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
* Handles plugin activation: creates database tables, sets default options,
|
* Handles plugin activation: creates database tables, sets default options,
|
||||||
* and schedules the initial cron job.
|
* and schedules the initial cron job.
|
||||||
*
|
*
|
||||||
@@ -28,121 +30,23 @@ class PRAutoBlogger_Activator {
|
|||||||
self::create_tables();
|
self::create_tables();
|
||||||
self::set_default_options();
|
self::set_default_options();
|
||||||
self::schedule_cron();
|
self::schedule_cron();
|
||||||
|
self::backpopulate_run_id_meta_v081();
|
||||||
|
PRAutoBlogger_Migrate_Prompt_Seed_V0180::run();
|
||||||
|
|
||||||
// Store the DB version so we can run migrations on future updates.
|
// Store the DB version so we can run migrations on future updates.
|
||||||
update_option( 'prautoblogger_db_version', PRAUTOBLOGGER_DB_VERSION, false );
|
update_option( 'prautoblogger_db_version', PRAUTOBLOGGER_DB_VERSION, false );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create all custom database tables using dbDelta.
|
* Delegate to the schema installers — see those classes.
|
||||||
*
|
*
|
||||||
* Side effects: creates/updates four database tables.
|
* `Schema_Installer` owns the v1.1.0 tables; `Pipeline_Schema_Installer`
|
||||||
*
|
* owns the v0.18.0 / db-1.2.0 Pipeline v2 substrate tables. Both are
|
||||||
* @return void
|
* dbDelta-idempotent and safe to re-run on any version mismatch.
|
||||||
*/
|
*/
|
||||||
private static function create_tables(): void {
|
private static function create_tables(): void {
|
||||||
global $wpdb;
|
PRAutoBlogger_Schema_Installer::install();
|
||||||
|
PRAutoBlogger_Pipeline_Schema_Installer::install();
|
||||||
$charset_collate = $wpdb->get_charset_collate();
|
|
||||||
$prefix = $wpdb->prefix . 'prautoblogger_';
|
|
||||||
|
|
||||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
|
||||||
|
|
||||||
// Source data — raw posts/comments from social platforms.
|
|
||||||
$sql_source_data = "CREATE TABLE {$prefix}source_data (
|
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
source_type VARCHAR(50) NOT NULL,
|
|
||||||
source_id VARCHAR(255) NOT NULL,
|
|
||||||
subreddit VARCHAR(255) DEFAULT NULL,
|
|
||||||
title TEXT DEFAULT NULL,
|
|
||||||
content LONGTEXT DEFAULT NULL,
|
|
||||||
author VARCHAR(255) DEFAULT NULL,
|
|
||||||
score INT DEFAULT 0,
|
|
||||||
comment_count INT DEFAULT 0,
|
|
||||||
permalink VARCHAR(500) DEFAULT NULL,
|
|
||||||
collected_at DATETIME NOT NULL,
|
|
||||||
metadata_json LONGTEXT DEFAULT NULL,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
UNIQUE KEY source_unique (source_type, source_id),
|
|
||||||
KEY source_collected (source_type, collected_at)
|
|
||||||
) {$charset_collate};";
|
|
||||||
|
|
||||||
// Analysis results — detected patterns from source data.
|
|
||||||
$sql_analysis = "CREATE TABLE {$prefix}analysis_results (
|
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
analysis_type VARCHAR(50) NOT NULL,
|
|
||||||
topic VARCHAR(500) NOT NULL,
|
|
||||||
summary TEXT DEFAULT NULL,
|
|
||||||
frequency INT DEFAULT 1,
|
|
||||||
relevance_score FLOAT DEFAULT 0,
|
|
||||||
source_ids_json LONGTEXT DEFAULT NULL,
|
|
||||||
analyzed_at DATETIME NOT NULL,
|
|
||||||
metadata_json LONGTEXT DEFAULT NULL,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
KEY type_relevance (analysis_type, relevance_score),
|
|
||||||
KEY analyzed_at (analyzed_at)
|
|
||||||
) {$charset_collate};";
|
|
||||||
|
|
||||||
// Generation log — every API call with cost tracking.
|
|
||||||
// run_id groups all log entries from a single pipeline execution so
|
|
||||||
// link_generation_logs() can accurately attribute costs to a post.
|
|
||||||
$sql_generation_log = "CREATE TABLE {$prefix}generation_log (
|
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
post_id BIGINT UNSIGNED DEFAULT NULL,
|
|
||||||
run_id VARCHAR(36) DEFAULT NULL,
|
|
||||||
stage VARCHAR(50) NOT NULL,
|
|
||||||
provider VARCHAR(50) NOT NULL,
|
|
||||||
model VARCHAR(100) NOT NULL,
|
|
||||||
prompt_tokens INT DEFAULT 0,
|
|
||||||
completion_tokens INT DEFAULT 0,
|
|
||||||
estimated_cost DECIMAL(10,6) DEFAULT 0,
|
|
||||||
request_json LONGTEXT DEFAULT NULL,
|
|
||||||
response_status VARCHAR(20) NOT NULL DEFAULT 'success',
|
|
||||||
error_message TEXT DEFAULT NULL,
|
|
||||||
created_at DATETIME NOT NULL,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
KEY post_id (post_id),
|
|
||||||
KEY run_id (run_id),
|
|
||||||
KEY created_at (created_at),
|
|
||||||
KEY stage (stage)
|
|
||||||
) {$charset_collate};";
|
|
||||||
|
|
||||||
// Content scores — post performance metrics for self-improvement.
|
|
||||||
$sql_content_scores = "CREATE TABLE {$prefix}content_scores (
|
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
post_id BIGINT UNSIGNED NOT NULL,
|
|
||||||
pageviews INT DEFAULT 0,
|
|
||||||
avg_time_on_page FLOAT DEFAULT 0,
|
|
||||||
bounce_rate FLOAT DEFAULT 0,
|
|
||||||
comment_count INT DEFAULT 0,
|
|
||||||
composite_score FLOAT DEFAULT 0,
|
|
||||||
score_factors_json LONGTEXT DEFAULT NULL,
|
|
||||||
measured_at DATETIME NOT NULL,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
KEY post_id (post_id),
|
|
||||||
KEY measured_at (measured_at),
|
|
||||||
KEY composite_score (composite_score)
|
|
||||||
) {$charset_collate};";
|
|
||||||
|
|
||||||
// Event log — structured application log entries for the admin log viewer.
|
|
||||||
$sql_event_log = "CREATE TABLE {$prefix}event_log (
|
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
level VARCHAR(10) NOT NULL DEFAULT 'info',
|
|
||||||
context VARCHAR(100) DEFAULT NULL,
|
|
||||||
message TEXT NOT NULL,
|
|
||||||
meta_json LONGTEXT DEFAULT NULL,
|
|
||||||
created_at DATETIME NOT NULL,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
KEY level (level),
|
|
||||||
KEY created_at (created_at),
|
|
||||||
KEY level_created (level, created_at)
|
|
||||||
) {$charset_collate};";
|
|
||||||
|
|
||||||
dbDelta( $sql_source_data );
|
|
||||||
dbDelta( $sql_analysis );
|
|
||||||
dbDelta( $sql_generation_log );
|
|
||||||
dbDelta( $sql_content_scores );
|
|
||||||
dbDelta( $sql_event_log );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -155,7 +59,7 @@ class PRAutoBlogger_Activator {
|
|||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
private static function set_default_options(): void {
|
private static function set_default_options(): void {
|
||||||
$defaults = [
|
$defaults = array(
|
||||||
'prautoblogger_analysis_model' => PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL,
|
'prautoblogger_analysis_model' => PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL,
|
||||||
'prautoblogger_writing_model' => PRAUTOBLOGGER_DEFAULT_WRITING_MODEL,
|
'prautoblogger_writing_model' => PRAUTOBLOGGER_DEFAULT_WRITING_MODEL,
|
||||||
'prautoblogger_editor_model' => PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL,
|
'prautoblogger_editor_model' => PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL,
|
||||||
@@ -164,14 +68,23 @@ class PRAutoBlogger_Activator {
|
|||||||
'prautoblogger_niche_description' => '',
|
'prautoblogger_niche_description' => '',
|
||||||
'prautoblogger_target_subreddits' => '[]',
|
'prautoblogger_target_subreddits' => '[]',
|
||||||
'prautoblogger_monthly_budget_usd' => 50.00,
|
'prautoblogger_monthly_budget_usd' => 50.00,
|
||||||
|
'prautoblogger_per_run_cost_ceiling_usd' => PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD,
|
||||||
|
'prautoblogger_request_json_retention_days' => PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS,
|
||||||
'prautoblogger_tone' => 'informational',
|
'prautoblogger_tone' => 'informational',
|
||||||
'prautoblogger_min_word_count' => 800,
|
'prautoblogger_min_word_count' => 800,
|
||||||
'prautoblogger_max_word_count' => 2000,
|
'prautoblogger_max_word_count' => 2000,
|
||||||
'prautoblogger_topic_exclusions' => '[]',
|
'prautoblogger_topic_exclusions' => '[]',
|
||||||
'prautoblogger_enabled_sources' => '["reddit"]',
|
'prautoblogger_enabled_sources' => '["reddit"]',
|
||||||
|
'prautoblogger_article_font_family' => 'default',
|
||||||
|
'prautoblogger_article_font_size' => 0,
|
||||||
|
'prautoblogger_table_borders' => '1',
|
||||||
'prautoblogger_schedule_time' => '03:00',
|
'prautoblogger_schedule_time' => '03:00',
|
||||||
'prautoblogger_log_level' => 'info',
|
'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,
|
||||||
|
);
|
||||||
|
|
||||||
foreach ( $defaults as $key => $value ) {
|
foreach ( $defaults as $key => $value ) {
|
||||||
add_option( $key, $value );
|
add_option( $key, $value );
|
||||||
@@ -192,14 +105,8 @@ class PRAutoBlogger_Activator {
|
|||||||
*/
|
*/
|
||||||
private static function schedule_cron(): void {
|
private static function schedule_cron(): void {
|
||||||
if ( ! wp_next_scheduled( 'prautoblogger_daily_generation' ) ) {
|
if ( ! wp_next_scheduled( 'prautoblogger_daily_generation' ) ) {
|
||||||
// Schedule for the configured time tomorrow.
|
$timestamp = self::next_daily_generation_timestamp();
|
||||||
$time_str = get_option( 'prautoblogger_schedule_time', '03:00' );
|
if ( $timestamp > 0 ) {
|
||||||
$parts = explode( ':', $time_str );
|
|
||||||
$hour = isset( $parts[0] ) ? absint( $parts[0] ) : 3;
|
|
||||||
$minute = isset( $parts[1] ) ? absint( $parts[1] ) : 0;
|
|
||||||
$timestamp = strtotime( "tomorrow {$hour}:{$minute}" );
|
|
||||||
|
|
||||||
if ( false !== $timestamp ) {
|
|
||||||
wp_schedule_event( $timestamp, 'daily', 'prautoblogger_daily_generation' );
|
wp_schedule_event( $timestamp, 'daily', 'prautoblogger_daily_generation' );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,18 +116,177 @@ class PRAutoBlogger_Activator {
|
|||||||
// in class-prautoblogger.php may not be hooked yet.
|
// in class-prautoblogger.php may not be hooked yet.
|
||||||
$schedules = wp_get_schedules();
|
$schedules = wp_get_schedules();
|
||||||
if ( ! isset( $schedules['prautoblogger_six_hours'] ) ) {
|
if ( ! isset( $schedules['prautoblogger_six_hours'] ) ) {
|
||||||
add_filter( 'cron_schedules', static function ( array $scheds ): array {
|
add_filter(
|
||||||
$scheds['prautoblogger_six_hours'] = [
|
'cron_schedules',
|
||||||
|
static function ( array $scheds ): array {
|
||||||
|
$scheds['prautoblogger_six_hours'] = array(
|
||||||
'interval' => 6 * HOUR_IN_SECONDS,
|
'interval' => 6 * HOUR_IN_SECONDS,
|
||||||
'display' => __( 'Every Six Hours', 'prautoblogger' ),
|
'display' => __( 'Every Six Hours', 'prautoblogger' ),
|
||||||
];
|
);
|
||||||
return $scheds;
|
return $scheds;
|
||||||
} );
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schedule a separate metrics collection job (runs every 6 hours).
|
// Schedule a separate metrics collection job (runs every 6 hours).
|
||||||
if ( ! wp_next_scheduled( 'prautoblogger_collect_metrics' ) ) {
|
if ( ! wp_next_scheduled( 'prautoblogger_collect_metrics' ) ) {
|
||||||
wp_schedule_event( time() + HOUR_IN_SECONDS, 'prautoblogger_six_hours', 'prautoblogger_collect_metrics' );
|
wp_schedule_event( time() + HOUR_IN_SECONDS, 'prautoblogger_six_hours', 'prautoblogger_collect_metrics' );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.8.1: daily reaper for orphan `llm_research` rows. Pinned to
|
||||||
|
// 03:15 so it runs 15 min after the primary generation cron, giving
|
||||||
|
// any live pipeline time to finish and call amortize_research_costs
|
||||||
|
// cleanly before the reaper scans.
|
||||||
|
if ( ! wp_next_scheduled( 'prautoblogger_reap_orphan_research_rows' ) ) {
|
||||||
|
$tomorrow = strtotime( 'tomorrow 03:15' );
|
||||||
|
if ( false !== $tomorrow ) {
|
||||||
|
wp_schedule_event( $tomorrow, 'daily', 'prautoblogger_reap_orphan_research_rows' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// v0.15.0: daily Runware model catalog sync.
|
||||||
|
if ( ! wp_next_scheduled( 'prautoblogger_sync_runware_models' ) ) {
|
||||||
|
wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'prautoblogger_sync_runware_models' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the next daily-generation timestamp in the site's configured
|
||||||
|
* timezone. Fixes the v<0.8.2 bug where `strtotime("tomorrow HH:MM")`
|
||||||
|
* evaluated in UTC (WP core sets `date_default_timezone_set('UTC')`),
|
||||||
|
* firing the cron N hours off from user intent where N is the site's
|
||||||
|
* UTC offset.
|
||||||
|
*
|
||||||
|
* @return int Unix timestamp, or 0 on any failure.
|
||||||
|
*/
|
||||||
|
public static function next_daily_generation_timestamp(): int {
|
||||||
|
$time_str = (string) get_option( 'prautoblogger_schedule_time', '03:00' );
|
||||||
|
$parts = explode( ':', $time_str );
|
||||||
|
$hour = isset( $parts[0] ) ? absint( $parts[0] ) : 3;
|
||||||
|
$minute = isset( $parts[1] ) ? absint( $parts[1] ) : 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$site_tz = function_exists( 'wp_timezone' )
|
||||||
|
? wp_timezone()
|
||||||
|
: new \DateTimeZone( 'UTC' );
|
||||||
|
$local = ( new \DateTimeImmutable( 'tomorrow', $site_tz ) )
|
||||||
|
->setTime( $hour, $minute, 0 );
|
||||||
|
return (int) $local->getTimestamp();
|
||||||
|
} catch ( \Exception $e ) {
|
||||||
|
// Defensive: never leave the site without any schedule because
|
||||||
|
// of a bad tz string. Fall back to the pre-fix behaviour.
|
||||||
|
$timestamp = strtotime( "tomorrow {$hour}:{$minute}" );
|
||||||
|
return false === $timestamp ? 0 : (int) $timestamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.8.2 one-shot migration — reschedule `prautoblogger_daily_generation`
|
||||||
|
* in the site's timezone.
|
||||||
|
*
|
||||||
|
* Existing installs had their daily cron scheduled with the v<0.8.2
|
||||||
|
* UTC-interpretation bug. This clears the stale event and reschedules it
|
||||||
|
* using the timezone-aware helper, exactly once per site (gated by
|
||||||
|
* `prautoblogger_migrated_schedule_tz_v082`). Logs an INFO line with the
|
||||||
|
* before/after next-run times so the outcome is auditable.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function reschedule_daily_in_site_timezone_v082(): void {
|
||||||
|
if ( get_option( 'prautoblogger_migrated_schedule_tz_v082' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$old_timestamp = wp_next_scheduled( 'prautoblogger_daily_generation' );
|
||||||
|
wp_clear_scheduled_hook( 'prautoblogger_daily_generation' );
|
||||||
|
|
||||||
|
$new_timestamp = self::next_daily_generation_timestamp();
|
||||||
|
if ( $new_timestamp > 0 ) {
|
||||||
|
wp_schedule_event( $new_timestamp, 'daily', 'prautoblogger_daily_generation' );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( class_exists( 'PRAutoBlogger_Logger', false ) ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
|
sprintf(
|
||||||
|
'Rescheduled daily generation in site timezone (was UTC). Old next_run: %s, new next_run: %s.',
|
||||||
|
false !== $old_timestamp ? gmdate( 'Y-m-d H:i:s \\U\\T\\C', (int) $old_timestamp ) : 'unscheduled',
|
||||||
|
$new_timestamp > 0 ? gmdate( 'Y-m-d H:i:s \\U\\T\\C', $new_timestamp ) : 'unscheduled'
|
||||||
|
),
|
||||||
|
'activator'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
update_option( 'prautoblogger_migrated_schedule_tz_v082', '1' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot v0.8.1 migration — back-populate `_prautoblogger_run_id`
|
||||||
|
* post_meta for existing posts from their gen-log rows.
|
||||||
|
*
|
||||||
|
* Lets the orphan-research-row reaper (v0.8.1+) attribute historic
|
||||||
|
* orphan rows to sibling posts via post_meta lookup, without having
|
||||||
|
* to walk the generation_log table. Gated by
|
||||||
|
* `prautoblogger_migrated_run_id_meta_v081` so it runs exactly once
|
||||||
|
* per site.
|
||||||
|
*
|
||||||
|
* Side effects: adds post_meta rows for existing PRAutoBlogger posts
|
||||||
|
* that don't already have `_prautoblogger_run_id`.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private static function backpopulate_run_id_meta_v081(): void {
|
||||||
|
if ( get_option( 'prautoblogger_migrated_run_id_meta_v081' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$gen_log = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||||
|
|
||||||
|
// For each (post_id, run_id) pair present in the gen-log, write
|
||||||
|
// the run_id meta if it's not already set. Uses the first run_id
|
||||||
|
// seen per post — in practice each post is linked to exactly one run.
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
$pairs = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
"SELECT post_id, MIN(run_id) AS run_id FROM {$gen_log}
|
||||||
|
WHERE post_id IS NOT NULL AND run_id IS NOT NULL AND run_id != ''
|
||||||
|
GROUP BY post_id"
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( is_array( $pairs ) ) {
|
||||||
|
foreach ( $pairs as $row ) {
|
||||||
|
$post_id = (int) ( $row->post_id ?? 0 );
|
||||||
|
$run_id = (string) ( $row->run_id ?? '' );
|
||||||
|
if ( $post_id > 0 && '' !== $run_id && ! metadata_exists( 'post', $post_id, '_prautoblogger_run_id' ) ) {
|
||||||
|
update_post_meta( $post_id, '_prautoblogger_run_id', $run_id );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update_option( 'prautoblogger_migrated_run_id_meta_v081', '1' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.9.0 migration: flip legacy default image model to Runware schnell
|
||||||
|
* and re-derive provider. CEO decision 2026-04-21 — 65× cheaper than
|
||||||
|
* Gemini Nano Banana. Explicit user selections are preserved.
|
||||||
|
*
|
||||||
|
* Side effects: may update `prautoblogger_image_model`,
|
||||||
|
* `prautoblogger_image_provider`, and sets the
|
||||||
|
* `prautoblogger_migrated_default_image_v090` flag.
|
||||||
|
*/
|
||||||
|
public static function migrate_default_image_v090(): void {
|
||||||
|
if ( get_option( 'prautoblogger_migrated_default_image_v090' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$curr = (string) get_option( 'prautoblogger_image_model', '' );
|
||||||
|
if ( in_array( $curr, array( '', 'google/gemini-2.5-flash-image' ), true ) ) {
|
||||||
|
update_option( 'prautoblogger_image_model', 'runware:100@1' );
|
||||||
|
}
|
||||||
|
$final = (string) get_option( 'prautoblogger_image_model', 'runware:100@1' );
|
||||||
|
$prov = PRAutoBlogger_Image_Model_Registry::provider_for( $final );
|
||||||
|
if ( '' !== $prov ) {
|
||||||
|
update_option( 'prautoblogger_image_provider', $prov );
|
||||||
|
}
|
||||||
|
update_option( 'prautoblogger_migrated_default_image_v090', '1' );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
209
includes/class-ajax-handlers.php
Normal file
209
includes/class-ajax-handlers.php
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* AJAX endpoint handlers for PRAutoBlogger admin actions.
|
||||||
|
*
|
||||||
|
* What: Handles image generation, model registry, and connection test AJAX requests.
|
||||||
|
* Who calls it: PRAutoBlogger registers wp_ajax hooks that delegate to methods here.
|
||||||
|
* Dependencies: PRAutoBlogger_Image_Pipeline, PRAutoBlogger_Source_Collector,
|
||||||
|
* PRAutoBlogger_OpenRouter_Provider, PRAutoBlogger_Reddit_Provider,
|
||||||
|
* PRAutoBlogger_OpenRouter_Model_Registry, PRAutoBlogger_Logger.
|
||||||
|
*
|
||||||
|
* @see class-prautoblogger.php — Hook registration that wires these handlers.
|
||||||
|
* @see class-executor.php — Generation-related AJAX lives there (generate_now, status).
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Ajax_Handlers {
|
||||||
|
|
||||||
|
/** @var PRAutoBlogger_OpenRouter_Model_Registry Model registry instance (shared with Executor). */
|
||||||
|
private PRAutoBlogger_OpenRouter_Model_Registry $model_registry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param PRAutoBlogger_OpenRouter_Model_Registry $model_registry Shared model registry.
|
||||||
|
*/
|
||||||
|
public function __construct( PRAutoBlogger_OpenRouter_Model_Registry $model_registry ) {
|
||||||
|
$this->model_registry = $model_registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX handler: generate images for existing posts that lack them.
|
||||||
|
*
|
||||||
|
* Accepts a `post_id` parameter. Generates Image A (article-driven) and
|
||||||
|
* sets it as the featured image. Useful for retroactively adding images
|
||||||
|
* to posts published before image generation was enabled/fixed.
|
||||||
|
*
|
||||||
|
* Side effects: image provider API call, media library write, post meta update.
|
||||||
|
*/
|
||||||
|
public function on_ajax_generate_image(): void {
|
||||||
|
check_ajax_referer( 'prautoblogger_generate_image', 'nonce' );
|
||||||
|
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image generation can take 20-30s (Runware FLUX + OpenRouter image models).
|
||||||
|
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||||
|
@set_time_limit( 120 );
|
||||||
|
|
||||||
|
$post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0;
|
||||||
|
if ( 0 === $post_id ) {
|
||||||
|
wp_send_json_error( array( 'message' => __( 'Missing post_id parameter.', 'prautoblogger' ) ) );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$post = get_post( $post_id );
|
||||||
|
if ( ! $post ) {
|
||||||
|
wp_send_json_error( array( 'message' => sprintf( __( 'Post %d not found.', 'prautoblogger' ), $post_id ) ) );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pipeline = new PRAutoBlogger_Image_Pipeline();
|
||||||
|
$article_data = array(
|
||||||
|
'post_title' => $post->post_title,
|
||||||
|
'post_content' => $post->post_content,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Retrieve original source IDs from post meta so Image B can generate
|
||||||
|
// a source-driven prompt even on retroactive regeneration.
|
||||||
|
$source_ids_json = get_post_meta( $post_id, '_prautoblogger_source_ids', true );
|
||||||
|
$source_ids = is_string( $source_ids_json ) ? json_decode( $source_ids_json, true ) : array();
|
||||||
|
$source_data = ( new PRAutoBlogger_Source_Collector() )->get_source_data_for_image(
|
||||||
|
is_array( $source_ids ) ? array_map( 'absint', $source_ids ) : array()
|
||||||
|
);
|
||||||
|
|
||||||
|
// The pipeline sets featured image and Image B meta internally.
|
||||||
|
$result = $pipeline->generate_and_attach_images( $post_id, $article_data, $source_data );
|
||||||
|
|
||||||
|
wp_send_json_success( $result );
|
||||||
|
} catch ( \Throwable $e ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->error(
|
||||||
|
sprintf( 'Retroactive image gen %s for post %d: %s', get_class( $e ), $post_id, $e->getMessage() ),
|
||||||
|
'image_pipeline'
|
||||||
|
);
|
||||||
|
wp_send_json_error( array( 'message' => $e->getMessage() ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX handler: return the model registry for the admin model picker.
|
||||||
|
*
|
||||||
|
* Returns the full cached model list. If the registry is empty, triggers
|
||||||
|
* a refresh first. Called by the model-picker.js popup.
|
||||||
|
*
|
||||||
|
* Side effects: may trigger an OpenRouter API call if registry is empty.
|
||||||
|
*/
|
||||||
|
public function on_ajax_get_models(): void {
|
||||||
|
check_ajax_referer( 'prautoblogger_get_models', 'nonce' );
|
||||||
|
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$models = $this->model_registry->get_models();
|
||||||
|
|
||||||
|
// Auto-refresh if the registry has never been populated.
|
||||||
|
if ( empty( $models ) ) {
|
||||||
|
$this->model_registry->refresh( true );
|
||||||
|
$models = $this->model_registry->get_models();
|
||||||
|
}
|
||||||
|
|
||||||
|
wp_send_json_success( $models );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX handler: test API connections (OpenRouter, Reddit).
|
||||||
|
*
|
||||||
|
* Accepts a `service` parameter: 'openrouter', 'reddit', or 'all'.
|
||||||
|
*
|
||||||
|
* Side effects: API calls to OpenRouter and/or Reddit to validate credentials.
|
||||||
|
*/
|
||||||
|
public function on_ajax_test_connection(): void {
|
||||||
|
check_ajax_referer( 'prautoblogger_test_connection', 'nonce' );
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = isset( $_POST['service'] ) ? sanitize_text_field( wp_unslash( $_POST['service'] ) ) : '';
|
||||||
|
$results = array();
|
||||||
|
|
||||||
|
if ( 'openrouter' === $service || 'all' === $service ) {
|
||||||
|
$results['openrouter'] = ( new PRAutoBlogger_OpenRouter_Provider() )->validate_credentials_detailed();
|
||||||
|
}
|
||||||
|
if ( 'reddit' === $service || 'all' === $service ) {
|
||||||
|
$reddit = new PRAutoBlogger_Reddit_Provider();
|
||||||
|
$is_ok = $reddit->validate_credentials();
|
||||||
|
$results['reddit'] = $is_ok
|
||||||
|
? array(
|
||||||
|
'status' => 'ok',
|
||||||
|
'message' => __( 'Reddit source available (RSS primary, .json fallback).', 'prautoblogger' ),
|
||||||
|
)
|
||||||
|
: array(
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => __( 'Reddit sources unreachable (both RSS and .json failed).', 'prautoblogger' ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
wp_send_json_success( $results );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX handler: sync Runware model catalog on demand.
|
||||||
|
*
|
||||||
|
* Nonce-protected and requires manage_options capability.
|
||||||
|
* Returns the updated cache timestamp and model count.
|
||||||
|
*
|
||||||
|
* Side effects: makes HTTP request to Runware API, writes WP options.
|
||||||
|
*/
|
||||||
|
public function on_ajax_sync_runware_models_now(): void {
|
||||||
|
check_ajax_referer( 'prautoblogger_sync_runware_models_now', 'nonce' );
|
||||||
|
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$catalog = new PRAutoBlogger_Runware_Model_Catalog();
|
||||||
|
$success = $catalog->sync();
|
||||||
|
|
||||||
|
if ( ! $success ) {
|
||||||
|
wp_send_json_error( array( 'message' => __( 'Catalog sync failed. Check logs for details.', 'prautoblogger' ) ) );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$models = $catalog->get_models();
|
||||||
|
$synced_at = $catalog->get_last_synced_at();
|
||||||
|
$synced_at_str = $synced_at
|
||||||
|
? wp_date( __( 'Y-m-d H:i:s', 'prautoblogger' ), $synced_at )
|
||||||
|
: __( 'Never', 'prautoblogger' );
|
||||||
|
|
||||||
|
wp_send_json_success(
|
||||||
|
array(
|
||||||
|
'message' => __( 'Runware model catalog synced successfully.', 'prautoblogger' ),
|
||||||
|
'model_count' => count( $models ),
|
||||||
|
'synced_at' => $synced_at_str,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch ( \Throwable $e ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->error(
|
||||||
|
sprintf( 'AJAX sync Runware models failed: %s (%s)', $e->getMessage(), get_class( $e ) ),
|
||||||
|
'runware-catalog'
|
||||||
|
);
|
||||||
|
wp_send_json_error( array( 'message' => $e->getMessage() ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the shared model registry instance.
|
||||||
|
*
|
||||||
|
* @return PRAutoBlogger_OpenRouter_Model_Registry
|
||||||
|
*/
|
||||||
|
public function get_registry(): PRAutoBlogger_OpenRouter_Model_Registry {
|
||||||
|
return $this->model_registry;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
* PSR-4-inspired autoloader for PRAutoBlogger classes.
|
* PSR-4-inspired autoloader for PRAutoBlogger classes.
|
||||||
*
|
*
|
||||||
* Maps class names like `PRAutoBlogger_Content_Generator` to file paths like
|
* Maps class names like `PRAutoBlogger_Content_Generator` to file paths like
|
||||||
@@ -19,15 +21,18 @@ class PRAutoBlogger_Autoloader {
|
|||||||
*
|
*
|
||||||
* @var string[]
|
* @var string[]
|
||||||
*/
|
*/
|
||||||
private static array $directories = [
|
private static array $directories = array(
|
||||||
'', // includes/ root
|
'', // includes/ root
|
||||||
'admin/',
|
'admin/',
|
||||||
|
'admin/fields/',
|
||||||
'core/',
|
'core/',
|
||||||
'providers/',
|
'providers/',
|
||||||
'models/',
|
'models/',
|
||||||
'frontend/',
|
'frontend/',
|
||||||
'services/',
|
'services/',
|
||||||
];
|
'services/opik/',
|
||||||
|
'ajax/',
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register the autoloader with SPL.
|
* Register the autoloader with SPL.
|
||||||
@@ -35,7 +40,7 @@ class PRAutoBlogger_Autoloader {
|
|||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public static function register(): void {
|
public static function register(): void {
|
||||||
spl_autoload_register( [ __CLASS__, 'autoload' ] );
|
spl_autoload_register( array( __CLASS__, 'autoload' ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
129
includes/class-db-migrations.php
Normal file
129
includes/class-db-migrations.php
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* One-time and version-gated DB/option migrations for PRAutoBlogger.
|
||||||
|
*
|
||||||
|
* Extracted from PRAutoBlogger (class-prautoblogger.php) in v0.19.2 so the
|
||||||
|
* main orchestrator stays under the 300-line cap. All migration logic lives
|
||||||
|
* here; the orchestrator holds thin proxy methods for backward compat.
|
||||||
|
*
|
||||||
|
* Each migration is idempotent: guarded by a stored option flag so it never
|
||||||
|
* runs more than once per site.
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger::on_check_db_version() (admin_init)
|
||||||
|
* PRAutoBlogger::on_check_db_version_for_cron() (init, cron-only).
|
||||||
|
* Dependencies: PRAutoBlogger_Activator, PRAutoBlogger_Image_Model_Registry,
|
||||||
|
* PRAutoBlogger_Encryption, PRAutoBlogger_Migrate_Remove_Cloudflare_V0100.
|
||||||
|
*
|
||||||
|
* @see class-prautoblogger.php -- Orchestrator; holds proxy methods.
|
||||||
|
* @see class-activator.php -- Schema install / activate logic called here.
|
||||||
|
* @see ARCHITECTURE.md -- DB version history and migration discipline.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_DB_Migrations {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run all pending migrations.
|
||||||
|
*
|
||||||
|
* Called on `admin_init` (and on `init` for cron contexts). Idempotent.
|
||||||
|
*
|
||||||
|
* Side effects: may update database schema and wp_options rows.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function run(): void {
|
||||||
|
$stored_version = get_option( 'prautoblogger_db_version', '0' );
|
||||||
|
if ( version_compare( $stored_version, PRAUTOBLOGGER_DB_VERSION, '<' ) ) {
|
||||||
|
PRAutoBlogger_Activator::activate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-time migration: switch to Gemini 2.5 Flash Lite for cost/speed.
|
||||||
|
if ( ! get_option( 'prautoblogger_migrated_gemini_flash_lite' ) ) {
|
||||||
|
update_option( 'prautoblogger_analysis_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL );
|
||||||
|
update_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
|
||||||
|
update_option( 'prautoblogger_editor_model', PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL );
|
||||||
|
update_option( 'prautoblogger_migrated_gemini_flash_lite', '1' );
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-time migration (v0.8.0): the admin no longer has an independent
|
||||||
|
// Image Provider dropdown; provider is derived from the image model on
|
||||||
|
// save. Auto-heal any existing site where the saved provider doesn't
|
||||||
|
// match the saved model's provider. Runs once.
|
||||||
|
if ( ! get_option( 'prautoblogger_migrated_image_provider_v080' ) ) {
|
||||||
|
$saved_model = (string) get_option( 'prautoblogger_image_model', '' );
|
||||||
|
$provider = PRAutoBlogger_Image_Model_Registry::provider_for( $saved_model );
|
||||||
|
if ( '' !== $provider ) {
|
||||||
|
update_option( 'prautoblogger_image_provider', $provider );
|
||||||
|
}
|
||||||
|
update_option( 'prautoblogger_migrated_image_provider_v080', '1' );
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-time migration (v0.8.2): reschedule the daily-generation cron in
|
||||||
|
// the site's configured timezone. Pre-v0.8.2 activator interpreted the
|
||||||
|
// admin "Generation Time" input as UTC; after v0.8.2 it honours the
|
||||||
|
// site timezone. Clears the stale UTC-scheduled event and re-queues
|
||||||
|
// using the timezone-aware helper. See class-activator.php.
|
||||||
|
PRAutoBlogger_Activator::reschedule_daily_in_site_timezone_v082();
|
||||||
|
|
||||||
|
// One-time migration (v3): switch to single-panel newspaper comic style.
|
||||||
|
if ( ! get_option( 'prautoblogger_migrated_style_suffix_v3' ) ) {
|
||||||
|
$known_old_prefixes = array(
|
||||||
|
'Style: a screengrab from a 1995', // v1: infomercial.
|
||||||
|
'Style: premium scientific lifestyle', // v2: photography.
|
||||||
|
);
|
||||||
|
$current = get_option( 'prautoblogger_image_style_suffix', '' );
|
||||||
|
$is_old = ( '' === $current );
|
||||||
|
foreach ( $known_old_prefixes as $prefix ) {
|
||||||
|
if ( false !== strpos( $current, $prefix ) ) {
|
||||||
|
$is_old = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( $is_old ) {
|
||||||
|
update_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
|
||||||
|
}
|
||||||
|
update_option( 'prautoblogger_migrated_style_suffix_v3', '1' );
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-time migration (v4): remove caption-in-image instruction from style suffix.
|
||||||
|
if ( ! get_option( 'prautoblogger_migrated_style_suffix_v4' ) ) {
|
||||||
|
$current = get_option( 'prautoblogger_image_style_suffix', '' );
|
||||||
|
if ( false !== strpos( $current, 'caption text in a clean sans-serif font below the panel' ) ) {
|
||||||
|
update_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
|
||||||
|
}
|
||||||
|
update_option( 'prautoblogger_migrated_style_suffix_v4', '1' );
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-time migration (v0.16.0): editorial pivot -- comic Style Suffix
|
||||||
|
// replaced by editorial Style Template. Old value mirrored to a
|
||||||
|
// deprecated-keyed option for one version cycle.
|
||||||
|
if ( ! get_option( 'prautoblogger_migrated_style_template_v0160' ) ) {
|
||||||
|
$old_suffix = get_option( 'prautoblogger_image_style_suffix', '' );
|
||||||
|
if ( '' !== $old_suffix && false === get_option( 'prautoblogger_image_style_suffix_deprecated', false ) ) {
|
||||||
|
update_option( 'prautoblogger_image_style_suffix_deprecated', $old_suffix );
|
||||||
|
}
|
||||||
|
if ( '' === (string) get_option( 'prautoblogger_image_style_template', '' ) ) {
|
||||||
|
update_option( 'prautoblogger_image_style_template', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE );
|
||||||
|
}
|
||||||
|
update_option( 'prautoblogger_migrated_style_template_v0160', '1' );
|
||||||
|
}
|
||||||
|
|
||||||
|
// v0.9.0 -- Runware as default image model. v0.10.0 -- remove CF Workers AI.
|
||||||
|
PRAutoBlogger_Activator::migrate_default_image_v090();
|
||||||
|
PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
|
||||||
|
|
||||||
|
// One-time migration: re-wrap existing encrypted values with "enc:" prefix.
|
||||||
|
if ( ! get_option( 'prautoblogger_migrated_enc_prefix' ) ) {
|
||||||
|
$enc_options = array( 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json', 'prautoblogger_runware_api_key' );
|
||||||
|
foreach ( $enc_options as $opt ) {
|
||||||
|
$val = get_option( $opt, '' );
|
||||||
|
if ( '' !== $val && ! PRAutoBlogger_Encryption::is_encrypted( $val ) ) {
|
||||||
|
delete_option( $opt );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
update_option( 'prautoblogger_migrated_enc_prefix', '1' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
includes/class-deactivator.php
Executable file → Normal file
12
includes/class-deactivator.php
Executable file → Normal file
@@ -2,6 +2,8 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
* Handles plugin deactivation: clears scheduled cron events and transients.
|
* Handles plugin deactivation: clears scheduled cron events and transients.
|
||||||
*
|
*
|
||||||
* Note: does NOT delete data or options — that happens in uninstall.php.
|
* Note: does NOT delete data or options — that happens in uninstall.php.
|
||||||
@@ -33,10 +35,12 @@ class PRAutoBlogger_Deactivator {
|
|||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
private static function clear_cron(): void {
|
private static function clear_cron(): void {
|
||||||
$hooks = [
|
$hooks = array(
|
||||||
'prautoblogger_daily_generation',
|
'prautoblogger_daily_generation',
|
||||||
'prautoblogger_collect_metrics',
|
'prautoblogger_collect_metrics',
|
||||||
];
|
'prautoblogger_reap_orphan_research_rows',
|
||||||
|
'prautoblogger_sync_runware_models',
|
||||||
|
);
|
||||||
|
|
||||||
foreach ( $hooks as $hook ) {
|
foreach ( $hooks as $hook ) {
|
||||||
$timestamp = wp_next_scheduled( $hook );
|
$timestamp = wp_next_scheduled( $hook );
|
||||||
@@ -54,11 +58,11 @@ class PRAutoBlogger_Deactivator {
|
|||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
private static function clear_transients(): void {
|
private static function clear_transients(): void {
|
||||||
$transients = [
|
$transients = array(
|
||||||
'prautoblogger_reddit_token',
|
'prautoblogger_reddit_token',
|
||||||
'prautoblogger_generation_lock',
|
'prautoblogger_generation_lock',
|
||||||
'prautoblogger_openrouter_models',
|
'prautoblogger_openrouter_models',
|
||||||
];
|
);
|
||||||
|
|
||||||
foreach ( $transients as $transient ) {
|
foreach ( $transients as $transient ) {
|
||||||
delete_transient( $transient );
|
delete_transient( $transient );
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
* Encrypts and decrypts sensitive option values (API keys, secrets).
|
* Encrypts and decrypts sensitive option values (API keys, secrets).
|
||||||
*
|
*
|
||||||
* Uses OpenSSL AES-256-CBC with a key derived from wp_salt('auth').
|
* Uses OpenSSL AES-256-CBC with a key derived from wp_salt('auth').
|
||||||
|
|||||||
179
includes/class-executor.php
Normal file
179
includes/class-executor.php
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Cron handlers for content generation and metrics collection.
|
||||||
|
*
|
||||||
|
* What: Daily generation, manual generation, queued-article processing,
|
||||||
|
* and metrics collection cron handlers. AJAX generation handlers
|
||||||
|
* live in PRAutoBlogger_Generation_Status_Poller (v0.18.3 split).
|
||||||
|
* Triggered by: PRAutoBlogger hook registration.
|
||||||
|
* Dependencies: Pipeline_Runner, Metrics_Collector, Generation_Lock, Logger.
|
||||||
|
*
|
||||||
|
* @see class-generation-status-poller.php — "Generate Now" AJAX + status poll.
|
||||||
|
* @see class-prautoblogger.php — Hook wiring.
|
||||||
|
* @see core/class-pipeline-runner.php — Actual generation pipeline.
|
||||||
|
* @see class-generation-lock.php — DB mutex.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Executor {
|
||||||
|
|
||||||
|
/** @var PRAutoBlogger_OpenRouter_Model_Registry|null Lazy-loaded singleton. */
|
||||||
|
private ?PRAutoBlogger_OpenRouter_Model_Registry $model_registry = null;
|
||||||
|
|
||||||
|
/** @var PRAutoBlogger_Generation_Status_Poller|null Lazy-loaded poller. */
|
||||||
|
private ?PRAutoBlogger_Generation_Status_Poller $poller = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the daily generation cron event.
|
||||||
|
*
|
||||||
|
* Uses an atomic DB mutex. If the pipeline queues additional articles,
|
||||||
|
* they fire as chained cron events and the lock is released by the
|
||||||
|
* last article job — not here.
|
||||||
|
*
|
||||||
|
* Side effects: API calls, database writes, WordPress post creation.
|
||||||
|
*/
|
||||||
|
public function on_daily_generation(): void {
|
||||||
|
do_action( 'prautoblogger_refresh_model_registry', false );
|
||||||
|
|
||||||
|
if ( ! PRAutoBlogger_Generation_Lock::acquire() ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->info( 'Daily generation skipped: already running (lock held).', 'scheduler' );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
( new PRAutoBlogger_Pipeline_Runner() )->run();
|
||||||
|
|
||||||
|
// Release lock only if no articles were queued for chained processing.
|
||||||
|
if ( ! get_option( 'prautoblogger_article_queue' ) ) {
|
||||||
|
PRAutoBlogger_Generation_Lock::release();
|
||||||
|
}
|
||||||
|
} catch ( \Throwable $e ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->error(
|
||||||
|
sprintf( 'Daily generation %s: %s', get_class( $e ), $e->getMessage() ),
|
||||||
|
'scheduler'
|
||||||
|
);
|
||||||
|
$this->mark_current_run_failed();
|
||||||
|
PRAutoBlogger_Generation_Lock::release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the metrics collection cron event.
|
||||||
|
* Side effects: API calls to GA4, database writes to ab_content_scores.
|
||||||
|
*/
|
||||||
|
public function on_collect_metrics(): void {
|
||||||
|
try {
|
||||||
|
( new PRAutoBlogger_Metrics_Collector() )->collect_all();
|
||||||
|
} catch ( \Throwable $e ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->error(
|
||||||
|
sprintf( 'Metrics collection %s: %s', get_class( $e ), $e->getMessage() ),
|
||||||
|
'metrics'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX handler: kick off manual generation as a background cron job.
|
||||||
|
* Delegates to PRAutoBlogger_Generation_Status_Poller.
|
||||||
|
*
|
||||||
|
* Side effects: schedules a WP-Cron event, writes a status transient.
|
||||||
|
*/
|
||||||
|
public function on_ajax_generate_now(): void {
|
||||||
|
$this->poller()->on_ajax_generate_now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron handler: kick off the chained-cron orchestration tick (v0.21.0, M4).
|
||||||
|
*
|
||||||
|
* Delegates to Generation_Checkpoint_Runner::on_orchestrate_tick() which
|
||||||
|
* runs Tick 1 (collect -> analyze -> score), then schedules Tick 2..N for
|
||||||
|
* article generation. This replaces the former monolithic pipeline run and
|
||||||
|
* retires the SSH-only `do_action("prautoblogger_manual_generation")` workaround
|
||||||
|
* (see ARCHITECTURE.md ss25).
|
||||||
|
*
|
||||||
|
* Lock acquisition and status transient writes are fully owned by the
|
||||||
|
* checkpoint runner; this method is a thin routing wrapper.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function on_manual_generation(): void {
|
||||||
|
PRAutoBlogger_Generation_Checkpoint_Runner::on_orchestrate_tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cron handler: process next queued article. Chained by Pipeline_Runner. */
|
||||||
|
public function on_process_article_queue(): void {
|
||||||
|
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||||
|
@ignore_user_abort( true );
|
||||||
|
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||||
|
@set_time_limit( 300 );
|
||||||
|
|
||||||
|
try {
|
||||||
|
( new PRAutoBlogger_Pipeline_Runner() )->process_next_queued_article();
|
||||||
|
} catch ( \Throwable $e ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->error(
|
||||||
|
sprintf( 'Queued article generation %s: %s', get_class( $e ), $e->getMessage() ),
|
||||||
|
'pipeline'
|
||||||
|
);
|
||||||
|
// Clean up on catastrophic failure.
|
||||||
|
delete_option( 'prautoblogger_article_queue' );
|
||||||
|
PRAutoBlogger_Generation_Lock::release();
|
||||||
|
set_transient(
|
||||||
|
PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
|
||||||
|
array(
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
),
|
||||||
|
PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AJAX: return generation status for frontend polling. Delegates to poller. */
|
||||||
|
public function on_ajax_generation_status(): void {
|
||||||
|
$this->poller()->on_ajax_generation_status();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazy-load the generation status poller.
|
||||||
|
*
|
||||||
|
* @return PRAutoBlogger_Generation_Status_Poller
|
||||||
|
*/
|
||||||
|
private function poller(): PRAutoBlogger_Generation_Status_Poller {
|
||||||
|
if ( null === $this->poller ) {
|
||||||
|
$this->poller = new PRAutoBlogger_Generation_Status_Poller();
|
||||||
|
}
|
||||||
|
return $this->poller;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark this process's run failed after a fatal pipeline error
|
||||||
|
* (no-op outside a run; a governor-halted run stays halted — final
|
||||||
|
* states are sticky).
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function mark_current_run_failed(): void {
|
||||||
|
$run_id = PRAutoBlogger_Run_Context::current_run_id();
|
||||||
|
if ( null !== $run_id ) {
|
||||||
|
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazy-load the OpenRouter model registry singleton.
|
||||||
|
* Config injected here — registry class has no knowledge of PRAUTOBLOGGER_* constants.
|
||||||
|
*
|
||||||
|
* @return PRAutoBlogger_OpenRouter_Model_Registry
|
||||||
|
*/
|
||||||
|
public function get_model_registry(): PRAutoBlogger_OpenRouter_Model_Registry {
|
||||||
|
if ( null === $this->model_registry ) {
|
||||||
|
$this->model_registry = new PRAutoBlogger_OpenRouter_Model_Registry(
|
||||||
|
'prautoblogger_openrouter_model_registry',
|
||||||
|
'prautoblogger_openrouter_model_registry_cache'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return $this->model_registry;
|
||||||
|
}
|
||||||
|
}
|
||||||
120
includes/class-generation-lock.php
Normal file
120
includes/class-generation-lock.php
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Database-level atomic mutex for content generation.
|
||||||
|
*
|
||||||
|
* What: Provides acquire/release/check semantics for a single-writer generation lock.
|
||||||
|
* Who calls it: PRAutoBlogger_Executor (daily and manual generation), status polling fallback.
|
||||||
|
* Dependencies: WordPress $wpdb for direct SQL.
|
||||||
|
*
|
||||||
|
* Uses INSERT IGNORE against the wp_options UNIQUE index on option_name to
|
||||||
|
* guarantee only one PHP process can hold the lock at a time. Expired locks
|
||||||
|
* (>1 hour) are cleaned up automatically on acquire to prevent permanent deadlock.
|
||||||
|
*
|
||||||
|
* @see class-executor.php — Acquires/releases the lock around pipeline runs.
|
||||||
|
* @see class-ajax-handlers.php — Releases stale locks in the status fallback path.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Generation_Lock {
|
||||||
|
|
||||||
|
/** @var string Option name used as the lock key in wp_options. */
|
||||||
|
private const LOCK_NAME = 'prautoblogger_generation_lock';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acquire the generation mutex.
|
||||||
|
*
|
||||||
|
* Cleans up expired locks (>1 hour) first, then attempts an atomic INSERT
|
||||||
|
* IGNORE. The UNIQUE constraint on option_name ensures only one caller wins.
|
||||||
|
*
|
||||||
|
* @return bool True if lock acquired, false if already held.
|
||||||
|
*/
|
||||||
|
public static function acquire(): bool {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
// Clean up expired locks to prevent permanent deadlock.
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||||
|
$wpdb->query(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"DELETE FROM {$wpdb->options} WHERE option_name = %s AND CAST(option_value AS UNSIGNED) < %d",
|
||||||
|
self::LOCK_NAME,
|
||||||
|
time() - HOUR_IN_SECONDS
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Atomic insert — UNIQUE constraint guarantees only one process succeeds.
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
$result = $wpdb->query(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, %s)",
|
||||||
|
self::LOCK_NAME,
|
||||||
|
(string) time(),
|
||||||
|
'no'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return $result > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the generation lock is currently held.
|
||||||
|
*
|
||||||
|
* @return bool True if the lock exists (generation in progress or stuck).
|
||||||
|
*/
|
||||||
|
public static function is_locked(): bool {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||||
|
$row = $wpdb->get_var(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
|
||||||
|
self::LOCK_NAME
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return null !== $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Unix timestamp when the lock was acquired, or null if not held.
|
||||||
|
*
|
||||||
|
* Returns null when the lock is not set. The timestamp is the value written
|
||||||
|
* by acquire() — used by the status poller to detect infrastructure-timeout
|
||||||
|
* runs (lock held longer than STATUS_TTL → background PHP process died).
|
||||||
|
*
|
||||||
|
* @return int|null Unix timestamp, or null when the lock is absent.
|
||||||
|
*/
|
||||||
|
public static function get_acquired_at(): ?int {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||||
|
$row = $wpdb->get_var(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
|
||||||
|
self::LOCK_NAME
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return null !== $row ? (int) $row : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release the generation mutex.
|
||||||
|
*
|
||||||
|
* Safe to call even if the lock is not held (no-ops gracefully).
|
||||||
|
*
|
||||||
|
* Side effects: deletes the lock row from wp_options.
|
||||||
|
*/
|
||||||
|
public static function release(): void {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||||
|
$wpdb->query(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"DELETE FROM {$wpdb->options} WHERE option_name = %s",
|
||||||
|
self::LOCK_NAME
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
260
includes/class-generation-status-poller.php
Normal file
260
includes/class-generation-status-poller.php
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* AJAX handlers for "Generate Now" + status polling.
|
||||||
|
*
|
||||||
|
* What: Two AJAX endpoints: kick-off (fire-and-forget cron spawn) and the
|
||||||
|
* status poll that drives the admin UI spinner. Extracted from
|
||||||
|
* PRAutoBlogger_Executor in v0.18.3 to keep each file under 300 lines.
|
||||||
|
*
|
||||||
|
* R2/R3 resilience (v0.18.3):
|
||||||
|
* - R2(a): on_ajax_generate_now() renews the transient with the
|
||||||
|
* full STATUS_TTL on every stage update via update_generation_stage().
|
||||||
|
* - R2(b): on_ajax_generation_status() detects lock-held-but-transient-gone
|
||||||
|
* when the lock has been held > STATUS_TTL seconds (Hostinger background-
|
||||||
|
* process death); marks the stuck run failed + surfaces a human-readable
|
||||||
|
* "infrastructure timeout" message for the Activity Log.
|
||||||
|
* - R3: on_ajax_generation_status() returns status:running (with
|
||||||
|
* started_at) when the transient is absent but the lock IS held and
|
||||||
|
* within TTL — so the button reflects reality during long runs rather
|
||||||
|
* than silently resetting to idle.
|
||||||
|
*
|
||||||
|
* Who calls it: PRAutoBlogger hook registration (wp_ajax_prautoblogger_*).
|
||||||
|
* Dependencies: PRAutoBlogger_Generation_Lock, PRAutoBlogger_Run_State,
|
||||||
|
* PRAutoBlogger_Run_Context, PRAutoBlogger_Pipeline_Runner,
|
||||||
|
* PRAutoBlogger_Logger.
|
||||||
|
*
|
||||||
|
* @see class-executor.php — Cron handlers (daily, manual, queued).
|
||||||
|
* @see class-generation-lock.php — DB mutex (acquire/release/timestamps).
|
||||||
|
* @see core/class-pipeline-status.php — Transient broadcast helpers.
|
||||||
|
* @see ARCHITECTURE.md #21 — Run lifecycle and reaper design.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Generation_Status_Poller {
|
||||||
|
|
||||||
|
/** Transient key for background generation status (shared with Pipeline_Status). */
|
||||||
|
public const STATUS_TRANSIENT = 'prautoblogger_generation_status';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long (seconds) to keep the status transient readable.
|
||||||
|
* Renewed on every stage transition so a long run doesn't expire it early.
|
||||||
|
*/
|
||||||
|
public const STATUS_TTL = 600;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX handler: kick off manual generation as a background cron job.
|
||||||
|
*
|
||||||
|
* Returns immediately so the browser never hits Hostinger's connection
|
||||||
|
* timeout. The frontend polls on_ajax_generation_status() every few
|
||||||
|
* seconds for progress and final results.
|
||||||
|
*
|
||||||
|
* Side effects: schedules a WP-Cron event, writes a status transient.
|
||||||
|
*/
|
||||||
|
public function on_ajax_generate_now(): void {
|
||||||
|
check_ajax_referer( 'prautoblogger_generate_now', 'nonce' );
|
||||||
|
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$force = isset( $_POST['force'] ) && '1' === $_POST['force'];
|
||||||
|
if ( $force ) {
|
||||||
|
PRAutoBlogger_Generation_Lock::release();
|
||||||
|
delete_transient( self::STATUS_TRANSIENT );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a run is already in progress.
|
||||||
|
$current = get_transient( self::STATUS_TRANSIENT );
|
||||||
|
if ( is_array( $current ) && 'running' === ( $current['status'] ?? '' ) ) {
|
||||||
|
wp_send_json_success(
|
||||||
|
array(
|
||||||
|
'background' => true,
|
||||||
|
'message' => __( 'Generation already in progress.', 'prautoblogger' ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write initial "running" status for the frontend to poll.
|
||||||
|
set_transient(
|
||||||
|
self::STATUS_TRANSIENT,
|
||||||
|
array(
|
||||||
|
'status' => 'running',
|
||||||
|
'stage' => __( 'Starting generation...', 'prautoblogger' ),
|
||||||
|
'started' => time(),
|
||||||
|
'last_updated' => time(),
|
||||||
|
),
|
||||||
|
self::STATUS_TTL
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schedule immediate one-shot cron event. WordPress will fire this
|
||||||
|
// on the next page load (or via wp-cron.php) in a separate PHP process
|
||||||
|
// not subject to the connection timeout.
|
||||||
|
if ( ! wp_next_scheduled( 'prautoblogger_manual_generation' ) ) {
|
||||||
|
wp_schedule_single_event( time(), 'prautoblogger_manual_generation' );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn the cron immediately via a non-blocking loopback request
|
||||||
|
// so we don't depend on the next visitor to trigger it.
|
||||||
|
spawn_cron();
|
||||||
|
|
||||||
|
// Direct non-blocking loopback to wp-cron.php — ensures the event
|
||||||
|
// fires even if DISABLE_WP_CRON is true or spawn_cron() no-ops.
|
||||||
|
wp_remote_post(
|
||||||
|
site_url( 'wp-cron.php?doing_wp_cron=' . sprintf( '%.22F', microtime( true ) ) ),
|
||||||
|
array(
|
||||||
|
'timeout' => 0.01,
|
||||||
|
'blocking' => false,
|
||||||
|
'sslverify' => false,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
wp_send_json_success(
|
||||||
|
array(
|
||||||
|
'background' => true,
|
||||||
|
'message' => __( 'Generation started in background. Polling for status...', 'prautoblogger' ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AJAX: return generation status for frontend polling. Recovers orphaned runs. */
|
||||||
|
public function on_ajax_generation_status(): void {
|
||||||
|
check_ajax_referer( 'prautoblogger_generate_now', 'nonce' );
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = get_transient( self::STATUS_TRANSIENT );
|
||||||
|
|
||||||
|
// R2(b) + R3: transient is gone — check the lock before returning idle.
|
||||||
|
if ( ! is_array( $status ) ) {
|
||||||
|
$this->handle_missing_transient();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( 'running' === $status['status'] ) {
|
||||||
|
$elapsed = time() - ( $status['started'] ?? 0 );
|
||||||
|
|
||||||
|
// Absolute timeout — give up after STATUS_TTL.
|
||||||
|
if ( $elapsed > self::STATUS_TTL ) {
|
||||||
|
$this->abort_orphaned_run( __( 'Generation timed out. Check the Activity Log.', 'prautoblogger' ) );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// After 90s, check if a queued job died and needs re-scheduling.
|
||||||
|
if ( $elapsed > 90 && get_option( 'prautoblogger_article_queue' ) ) {
|
||||||
|
if ( ! wp_next_scheduled( PRAutoBlogger_Pipeline_Runner::CRON_ACTION ) ) {
|
||||||
|
wp_schedule_single_event( time(), PRAutoBlogger_Pipeline_Runner::CRON_ACTION );
|
||||||
|
spawn_cron();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stall = 5 min since last stage update (not since start).
|
||||||
|
$last_activity = $status['last_updated'] ?? $status['started'] ?? 0;
|
||||||
|
$idle_seconds = time() - $last_activity;
|
||||||
|
if ( $idle_seconds > 300 ) {
|
||||||
|
$this->abort_orphaned_run( __( 'Generation stalled. Check Activity Log.', 'prautoblogger' ) );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wp_send_json_success( $status );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the case where the status transient is absent.
|
||||||
|
*
|
||||||
|
* R3: if lock is HELD and within TTL → return status:running so the
|
||||||
|
* button reflects reality during long background runs.
|
||||||
|
* R2(b): if lock is held but has been held > STATUS_TTL → the background
|
||||||
|
* PHP process died without releasing it; mark the run failed and
|
||||||
|
* surface "infrastructure timeout" to the UI.
|
||||||
|
*
|
||||||
|
* @return void Sends JSON and exits.
|
||||||
|
*/
|
||||||
|
private function handle_missing_transient(): void {
|
||||||
|
$acquired_at = PRAutoBlogger_Generation_Lock::get_acquired_at();
|
||||||
|
|
||||||
|
if ( null === $acquired_at ) {
|
||||||
|
// Lock not held — genuinely idle.
|
||||||
|
wp_send_json_success( array( 'status' => 'idle' ) );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lock_age = time() - $acquired_at;
|
||||||
|
|
||||||
|
if ( $lock_age > self::STATUS_TTL ) {
|
||||||
|
// Lock held longer than STATUS_TTL: background process died.
|
||||||
|
// R2(b): mark the stuck run failed + surface a clear error.
|
||||||
|
PRAutoBlogger_Logger::instance()->warning(
|
||||||
|
sprintf(
|
||||||
|
'Status transient absent and lock held for %ds (> %ds TTL) — background process died; marking run failed.',
|
||||||
|
$lock_age,
|
||||||
|
self::STATUS_TTL
|
||||||
|
),
|
||||||
|
'pipeline'
|
||||||
|
);
|
||||||
|
$this->abort_orphaned_run(
|
||||||
|
__( 'Generation failed — infrastructure timeout. Check Activity Log for details.', 'prautoblogger' )
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// R3: lock is fresh (within TTL) but transient is gone — long run in
|
||||||
|
// progress (pipeline broadcast hasn't fired yet, or transient was
|
||||||
|
// evicted by object-cache pressure). Return running so the button
|
||||||
|
// does not silently reset to idle.
|
||||||
|
wp_send_json_success(
|
||||||
|
array(
|
||||||
|
'status' => 'running',
|
||||||
|
'stage' => __( 'Generation in progress (background). Check Activity Log for details.', 'prautoblogger' ),
|
||||||
|
'started' => $acquired_at,
|
||||||
|
'last_updated' => $acquired_at,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clean up an orphaned generation run and report error. */
|
||||||
|
private function abort_orphaned_run( string $message ): void {
|
||||||
|
// Mark the stuck run as failed in the audit ledger.
|
||||||
|
$queue = get_option( 'prautoblogger_article_queue' );
|
||||||
|
if ( is_array( $queue ) && ! empty( $queue['run_id'] ) ) {
|
||||||
|
PRAutoBlogger_Run_State::mark_status( (string) $queue['run_id'], 'failed' );
|
||||||
|
}
|
||||||
|
// Also attempt to close the current active run from Run_Context.
|
||||||
|
$run_id = PRAutoBlogger_Run_Context::current_run_id();
|
||||||
|
if ( null !== $run_id ) {
|
||||||
|
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
|
||||||
|
}
|
||||||
|
delete_transient( self::STATUS_TRANSIENT );
|
||||||
|
delete_option( 'prautoblogger_article_queue' );
|
||||||
|
PRAutoBlogger_Generation_Lock::release();
|
||||||
|
wp_send_json_success(
|
||||||
|
array(
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => $message,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the generation stage text for frontend polling.
|
||||||
|
* Renews the transient TTL at every call so a long run doesn't expire it.
|
||||||
|
*
|
||||||
|
* @param string $stage Human-readable stage description.
|
||||||
|
*/
|
||||||
|
public function update_generation_stage( string $stage ): void {
|
||||||
|
$current = get_transient( self::STATUS_TRANSIENT );
|
||||||
|
if ( is_array( $current ) ) {
|
||||||
|
$current['stage'] = $stage;
|
||||||
|
$current['last_updated'] = time();
|
||||||
|
// R2(a): renew the TTL on every stage update so the transient
|
||||||
|
// outlives any single LLM call (STATUS_TTL from last activity).
|
||||||
|
set_transient( self::STATUS_TRANSIENT, $current, self::STATUS_TTL );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
59
includes/class-migrate-prompt-seed-v0180.php
Normal file
59
includes/class-migrate-prompt-seed-v0180.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* One-shot v0.18.0 migration — seed the versioned prompt registry.
|
||||||
|
*
|
||||||
|
* What: Writes the canonical hardcoded prompt texts (content, analysis,
|
||||||
|
* editor, research, illustration rewriter, image Style Template)
|
||||||
|
* into `wp_prautoblogger_prompts` as immutable version 1 and
|
||||||
|
* activates them. Idempotent twice over: gated by the
|
||||||
|
* `prautoblogger_migrated_prompt_seed_v0180` flag AND seed_v1()
|
||||||
|
* itself skips any key that already has rows. The flag is only set
|
||||||
|
* when the table was actually reachable, so a failed seed
|
||||||
|
* self-heals on the next activation / version-mismatch pass.
|
||||||
|
* Standalone class per the Migrate_Remove_Cloudflare_V0100
|
||||||
|
* precedent (keeps Activator under the 300-line cap).
|
||||||
|
* Who triggers it: PRAutoBlogger_Activator::activate() — on activation and
|
||||||
|
* on every db-version mismatch.
|
||||||
|
* Dependencies: PRAutoBlogger_Prompt_Registry(_Writer), get_option.
|
||||||
|
*
|
||||||
|
* @see class-activator.php — Sole caller.
|
||||||
|
* @see core/class-prompt-registry-writer.php — seed_v1() implementation.
|
||||||
|
* @see includes/class-migrate-remove-cloudflare-v0100.php — Pattern precedent.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Migrate_Prompt_Seed_V0180 {
|
||||||
|
|
||||||
|
/** Option flag marking the seed as complete. */
|
||||||
|
private const FLAG = 'prautoblogger_migrated_prompt_seed_v0180';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the seed once.
|
||||||
|
*
|
||||||
|
* Side effects: up to 12 INSERTs into the prompts table, one INFO log
|
||||||
|
* line, one option write.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function run(): void {
|
||||||
|
if ( get_option( self::FLAG ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( ! PRAutoBlogger_Prompt_Registry::is_available() ) {
|
||||||
|
return; // Table missing — retry on the next migration pass.
|
||||||
|
}
|
||||||
|
|
||||||
|
$seeded = PRAutoBlogger_Prompt_Registry_Writer::seed_v1( 'seed:v0.18.0' );
|
||||||
|
|
||||||
|
if ( class_exists( 'PRAutoBlogger_Logger', false ) ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
|
sprintf( 'Prompt registry seeded: %d key(s) written as v1.', $seeded ),
|
||||||
|
'activator'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
update_option( self::FLAG, '1' );
|
||||||
|
}
|
||||||
|
}
|
||||||
101
includes/class-migrate-remove-cloudflare-v0100.php
Normal file
101
includes/class-migrate-remove-cloudflare-v0100.php
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* One-shot v0.10.0 migration — remove Cloudflare Workers AI as image provider.
|
||||||
|
*
|
||||||
|
* Cloudflare Workers AI was removed as an image provider in v0.10.0.
|
||||||
|
* Runware FLUX.1 schnell is 65× cheaper at equivalent quality, so any user
|
||||||
|
* still on a `cloudflare` provider must be migrated off — otherwise the
|
||||||
|
* image pipeline throws "no provider for id cloudflare" at the first
|
||||||
|
* generation run after upgrade.
|
||||||
|
*
|
||||||
|
* Split out of class-activator.php so that file stays under the 300-line
|
||||||
|
* cap. The activator's maybe_run_db_migrations() delegates here.
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger::maybe_run_db_migrations() once per admin_init
|
||||||
|
* until the `prautoblogger_migrated_remove_cloudflare_v0100`
|
||||||
|
* flag is set.
|
||||||
|
* Dependencies: PRAutoBlogger_Image_Model_Registry::provider_for() for
|
||||||
|
* current-model provider derivation.
|
||||||
|
*
|
||||||
|
* @see class-activator.php — Parallel v0.9.0 migration.
|
||||||
|
* @see class-prautoblogger.php — Calls run() during admin_init.
|
||||||
|
* @see class-image-model-registry.php — Provider derivation source.
|
||||||
|
* @see convo/prautoblogger/threads/2026-04-cloudflare-workers-ai-removal/
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Migrate_Remove_Cloudflare_V0100 {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration flag option key. Presence = already migrated.
|
||||||
|
*/
|
||||||
|
private const FLAG = 'prautoblogger_migrated_remove_cloudflare_v0100';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the migration. Idempotent — second + later calls are no-ops.
|
||||||
|
*
|
||||||
|
* Side effects: may update `prautoblogger_image_model` +
|
||||||
|
* `prautoblogger_image_provider`; always deletes
|
||||||
|
* `prautoblogger_cloudflare_ai_token`,
|
||||||
|
* `prautoblogger_cloudflare_account_id`,
|
||||||
|
* `prautoblogger_cf_image_via_gateway`; sets the
|
||||||
|
* migration flag option.
|
||||||
|
*
|
||||||
|
* Explicitly does NOT touch `prautoblogger_ai_gateway_base_url` —
|
||||||
|
* that option is shared with the OpenRouter AI Gateway route and
|
||||||
|
* must stay intact.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function run(): void {
|
||||||
|
if ( get_option( self::FLAG ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( self::is_user_on_cloudflare() ) {
|
||||||
|
// Closest equivalent — FLUX.1 schnell, Runware-hosted, ~$0.0006/image.
|
||||||
|
update_option( 'prautoblogger_image_model', 'runware:100@1' );
|
||||||
|
update_option( 'prautoblogger_image_provider', 'runware' );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up orphaned CF Workers AI credentials + the dead gateway toggle.
|
||||||
|
delete_option( 'prautoblogger_cloudflare_ai_token' );
|
||||||
|
delete_option( 'prautoblogger_cloudflare_account_id' );
|
||||||
|
delete_option( 'prautoblogger_cf_image_via_gateway' );
|
||||||
|
|
||||||
|
update_option( self::FLAG, '1' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect whether the site is currently configured to use Cloudflare
|
||||||
|
* Workers AI for image generation.
|
||||||
|
*
|
||||||
|
* Because the v0.10.0 image-model registry no longer contains
|
||||||
|
* Cloudflare entries, `provider_for()` returns '' for legacy
|
||||||
|
* `flux-1-schnell` slugs. We therefore look at three signals and
|
||||||
|
* treat any of them as "on Cloudflare":
|
||||||
|
* 1. Current registry-derived provider === 'cloudflare'.
|
||||||
|
* 2. The legacy `prautoblogger_image_provider` option is 'cloudflare'.
|
||||||
|
* 3. The legacy CF-specific slug `flux-1-schnell` is still persisted
|
||||||
|
* as the image model (pre-registry-rename installs).
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private static function is_user_on_cloudflare(): bool {
|
||||||
|
$curr = (string) get_option( 'prautoblogger_image_model', '' );
|
||||||
|
$curr_provider = PRAutoBlogger_Image_Model_Registry::provider_for( $curr );
|
||||||
|
|
||||||
|
if ( 'cloudflare' === $curr_provider ) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$legacy_provider = (string) get_option( 'prautoblogger_image_provider', '' );
|
||||||
|
if ( 'cloudflare' === $legacy_provider ) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'flux-1-schnell' === $curr;
|
||||||
|
}
|
||||||
|
}
|
||||||
180
includes/class-pipeline-schema-installer.php
Normal file
180
includes/class-pipeline-schema-installer.php
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Creates / updates the Pipeline v2 substrate tables via `dbDelta` (db 1.3.0).
|
||||||
|
*
|
||||||
|
* What: Owns the six pipeline substrate tables — the versioned prompt
|
||||||
|
* registry (`prompts`), the audit child tables (`run_sources`,
|
||||||
|
* `run_decisions`), and the run ledger / state machine tables
|
||||||
|
* (`runs`, `run_stages`), and the immutable stage-input version
|
||||||
|
* store (`stage_inputs`, v0.20.0 / db 1.3.0 — edit + re-run forks
|
||||||
|
* and idea seeds). Kept separate from the original
|
||||||
|
* PRAutoBlogger_Schema_Installer so both classes stay under the
|
||||||
|
* 300-line cap and the v1.1.0 schema remains byte-stable.
|
||||||
|
* Who triggers it: PRAutoBlogger_Activator::activate() — on activation and
|
||||||
|
* on every `prautoblogger_db_version` mismatch (self-healing
|
||||||
|
* auto-migration via PRAutoBlogger::on_check_db_version()).
|
||||||
|
* Dependencies: WordPress $wpdb, dbDelta() from wp-admin/includes/upgrade.php.
|
||||||
|
*
|
||||||
|
* @see class-schema-installer.php — v1.1.0 tables (source data, logs, scores).
|
||||||
|
* @see class-activator.php — Sole caller.
|
||||||
|
* @see core/class-prompt-registry.php — Reads/writes the prompts table.
|
||||||
|
* @see core/class-run-state.php — Reads/writes runs + run_stages.
|
||||||
|
* @see ARCHITECTURE.md — Database schema section documents each table.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Pipeline_Schema_Installer {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create (or upgrade) the Pipeline v2 substrate tables.
|
||||||
|
*
|
||||||
|
* `dbDelta` is idempotent: it creates missing tables, adds missing
|
||||||
|
* columns and indexes, and never drops anything — matching the
|
||||||
|
* plugin's forward-only schema policy. Safe to re-run on every
|
||||||
|
* version mismatch.
|
||||||
|
*
|
||||||
|
* Side effects: up to six CREATE TABLE / ALTER TABLE statements.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function install(): void {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$charset_collate = $wpdb->get_charset_collate();
|
||||||
|
$prefix = $wpdb->prefix . 'prautoblogger_';
|
||||||
|
|
||||||
|
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||||
|
|
||||||
|
// Versioned prompt registry. Versions are immutable: writes create
|
||||||
|
// new (prompt_key, version) rows; exactly one row per key has
|
||||||
|
// active = 1. `prompt_key` (not `key`) because KEY is reserved SQL.
|
||||||
|
$sql_prompts = "CREATE TABLE {$prefix}prompts (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
prompt_key VARCHAR(64) NOT NULL,
|
||||||
|
version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
body LONGTEXT NOT NULL,
|
||||||
|
model VARCHAR(100) DEFAULT NULL,
|
||||||
|
params_json LONGTEXT DEFAULT NULL,
|
||||||
|
author VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
active TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY key_version (prompt_key, version),
|
||||||
|
KEY key_active (prompt_key, active)
|
||||||
|
) {$charset_collate};";
|
||||||
|
|
||||||
|
// Research-source audit rows: one row per source a run considered,
|
||||||
|
// with the keep/discard decision. Consolidates the old
|
||||||
|
// source_ids_json + _prautoblogger_research_sources scatter for
|
||||||
|
// new runs (no backfill of historical runs).
|
||||||
|
$sql_run_sources = "CREATE TABLE {$prefix}run_sources (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
run_id VARCHAR(36) NOT NULL,
|
||||||
|
agent_role VARCHAR(50) NOT NULL DEFAULT '',
|
||||||
|
source_url VARCHAR(500) DEFAULT NULL,
|
||||||
|
doi VARCHAR(255) DEFAULT NULL,
|
||||||
|
kept TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
reason TEXT DEFAULT NULL,
|
||||||
|
quality_score FLOAT DEFAULT NULL,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY run_id (run_id)
|
||||||
|
) {$charset_collate};";
|
||||||
|
|
||||||
|
// Stage-decision audit rows: verdict + rationale per pipeline
|
||||||
|
// stage. citation_score is nullable until the Phase-2 editorial
|
||||||
|
// loop computes it.
|
||||||
|
$sql_run_decisions = "CREATE TABLE {$prefix}run_decisions (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
run_id VARCHAR(36) NOT NULL,
|
||||||
|
stage VARCHAR(50) NOT NULL,
|
||||||
|
verdict VARCHAR(50) NOT NULL,
|
||||||
|
rationale TEXT DEFAULT NULL,
|
||||||
|
citation_score FLOAT DEFAULT NULL,
|
||||||
|
human_modified TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY run_id (run_id)
|
||||||
|
) {$charset_collate};";
|
||||||
|
|
||||||
|
// Per-run ledger + lifecycle row. reserved/settled/ceiling drive the
|
||||||
|
// Cost_Governor's atomic reserve-before-call check; pinned_prompts_json
|
||||||
|
// freezes the prompt versions a run uses at start; status feeds the
|
||||||
|
// run state machine (pending|running|done|failed|halted).
|
||||||
|
$sql_runs = "CREATE TABLE {$prefix}runs (
|
||||||
|
run_id VARCHAR(36) NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||||
|
ceiling_usd DECIMAL(10,6) NOT NULL DEFAULT 0,
|
||||||
|
reserved_usd DECIMAL(10,6) NOT NULL DEFAULT 0,
|
||||||
|
settled_usd DECIMAL(10,6) NOT NULL DEFAULT 0,
|
||||||
|
overage_usd DECIMAL(10,6) NOT NULL DEFAULT 0,
|
||||||
|
pinned_prompts_json LONGTEXT DEFAULT NULL,
|
||||||
|
started_at DATETIME NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL,
|
||||||
|
finished_at DATETIME DEFAULT NULL,
|
||||||
|
PRIMARY KEY (run_id),
|
||||||
|
KEY status (status),
|
||||||
|
KEY started_at (started_at)
|
||||||
|
) {$charset_collate};";
|
||||||
|
|
||||||
|
// Per-run per-stage state rows. The idempotency key is
|
||||||
|
// (run_id, stage, agent_role, item_key): agent_role is the Phase-2
|
||||||
|
// fan-out dimension (quorum members), item_key scopes article-level
|
||||||
|
// stages within multi-article runs ('' for run-level stages).
|
||||||
|
// meta_json holds the stage output for resume-without-recharge.
|
||||||
|
$sql_run_stages = "CREATE TABLE {$prefix}run_stages (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
run_id VARCHAR(36) NOT NULL,
|
||||||
|
stage VARCHAR(50) NOT NULL,
|
||||||
|
agent_role VARCHAR(50) NOT NULL DEFAULT '',
|
||||||
|
item_key VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||||
|
attempt SMALLINT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
cost_usd DECIMAL(10,6) NOT NULL DEFAULT 0,
|
||||||
|
meta_json LONGTEXT DEFAULT NULL,
|
||||||
|
human_modified TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
stale TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
started_at DATETIME NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL,
|
||||||
|
finished_at DATETIME DEFAULT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY run_stage_role_item (run_id, stage, agent_role, item_key),
|
||||||
|
KEY status_updated (status, updated_at)
|
||||||
|
) {$charset_collate};";
|
||||||
|
|
||||||
|
// Immutable stage-input version store (v0.20.0 / db 1.3.0, M3
|
||||||
|
// edit + re-run). Rows are INSERT-only: a human edit creates the
|
||||||
|
// next (scope, version) row -- the original input (the
|
||||||
|
// generation_log.request_json of the executed call) is never
|
||||||
|
// overwritten (CPO guardrail 1). source='seed' rows persist the
|
||||||
|
// Article_Idea payload at worker start so re-run-from-here can
|
||||||
|
// reconstruct the exact idea (item_key hash stability); 'human'
|
||||||
|
// rows are edit forks. Human fork bodies are retention-pruned
|
||||||
|
// like all heavy payloads; seed rows (~1KB) are structural and
|
||||||
|
// kept for the life of the run.
|
||||||
|
$sql_stage_inputs = "CREATE TABLE {$prefix}stage_inputs (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
run_id VARCHAR(36) NOT NULL,
|
||||||
|
stage VARCHAR(50) NOT NULL DEFAULT '',
|
||||||
|
agent_role VARCHAR(50) NOT NULL DEFAULT '',
|
||||||
|
item_key VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
source VARCHAR(20) NOT NULL DEFAULT 'human',
|
||||||
|
request_json LONGTEXT DEFAULT NULL,
|
||||||
|
author VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY scope_version (run_id, stage, agent_role, item_key, version),
|
||||||
|
KEY run_id (run_id)
|
||||||
|
) {$charset_collate};";
|
||||||
|
|
||||||
|
dbDelta( $sql_prompts );
|
||||||
|
dbDelta( $sql_run_sources );
|
||||||
|
dbDelta( $sql_run_decisions );
|
||||||
|
dbDelta( $sql_runs );
|
||||||
|
dbDelta( $sql_run_stages );
|
||||||
|
dbDelta( $sql_stage_inputs );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,403 +2,273 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
* Main orchestrator for the PRAutoBlogger plugin.
|
* Main orchestrator for the PRAutoBlogger plugin.
|
||||||
*
|
*
|
||||||
* Registers all WordPress hooks (actions and filters) and wires up dependencies.
|
* Registers all WordPress hooks and wires up dependencies.
|
||||||
* This is the ONLY place hooks are registered — individual classes contain only
|
* DB/option migrations extracted to PRAutoBlogger_DB_Migrations (v0.19.2).
|
||||||
* business logic, not hook registration.
|
|
||||||
*
|
*
|
||||||
* Triggered by: prautoblogger() singleton in prautoblogger.php, called on `plugins_loaded`.
|
* @see prautoblogger.php -- Plugin bootstrap.
|
||||||
* Dependencies: All other plugin classes (loaded via autoloader), PRAutoBlogger_Pipeline_Runner.
|
* @see class-db-migrations.php -- One-time DB/option migrations.
|
||||||
*
|
* @see ARCHITECTURE.md -- Data flow diagram.
|
||||||
* @see prautoblogger.php — Plugin bootstrap that instantiates this class.
|
|
||||||
* @see core/class-pipeline-runner.php — Executes the generation pipeline.
|
|
||||||
* @see ARCHITECTURE.md — Data flow diagram showing how classes interact.
|
|
||||||
* @see CONVENTIONS.md — Hook naming conventions.
|
|
||||||
*/
|
*/
|
||||||
class PRAutoBlogger {
|
class PRAutoBlogger {
|
||||||
|
|
||||||
private bool $initialized = false;
|
private bool $initialized = false;
|
||||||
|
|
||||||
/** @var PRAutoBlogger_OpenRouter_Model_Registry|null Lazy-loaded singleton. */
|
/** @var PRAutoBlogger_Executor Handles generation cron/AJAX, model registry. */
|
||||||
private ?PRAutoBlogger_OpenRouter_Model_Registry $model_registry = null;
|
private PRAutoBlogger_Executor $executor;
|
||||||
|
|
||||||
|
/** @var PRAutoBlogger_Ajax_Handlers Handles non-generation AJAX. */
|
||||||
|
private PRAutoBlogger_Ajax_Handlers $ajax_handlers;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register all hooks and initialize the plugin.
|
* Register all hooks and initialize the plugin.
|
||||||
*
|
* Called once on `plugins_loaded`. Idempotent.
|
||||||
* Called once on `plugins_loaded`. Idempotent — calling it twice is a no-op.
|
|
||||||
*
|
|
||||||
* Side effects: registers WordPress actions and filters.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
public function run(): void {
|
public function run(): void {
|
||||||
if ( $this->initialized ) {
|
if ( $this->initialized ) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$this->initialized = true;
|
$this->initialized = true;
|
||||||
|
$this->executor = new PRAutoBlogger_Executor();
|
||||||
|
$this->ajax_handlers = new PRAutoBlogger_Ajax_Handlers( $this->executor->get_model_registry() );
|
||||||
|
|
||||||
// Check for database migrations on admin_init.
|
add_action( 'admin_init', array( $this, 'on_check_db_version' ) );
|
||||||
add_action( 'admin_init', [ $this, 'on_check_db_version' ] );
|
// v0.18.0: cron requests must also self-heal the schema.
|
||||||
|
add_action( 'init', array( $this, 'on_check_db_version_for_cron' ) );
|
||||||
|
add_filter( 'cron_schedules', array( $this, 'filter_add_cron_schedules' ) );
|
||||||
|
|
||||||
// Register custom cron schedules.
|
|
||||||
add_filter( 'cron_schedules', [ $this, 'filter_add_cron_schedules' ] );
|
|
||||||
|
|
||||||
// Admin hooks — only load on admin pages.
|
|
||||||
if ( is_admin() ) {
|
if ( is_admin() ) {
|
||||||
$this->register_admin_hooks();
|
$this->register_admin_hooks();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cron hooks — always registered so WP-Cron can fire them.
|
|
||||||
$this->register_cron_hooks();
|
$this->register_cron_hooks();
|
||||||
|
|
||||||
// Frontend hooks — shortcode and REST API for public-facing widget.
|
|
||||||
$this->register_frontend_hooks();
|
$this->register_frontend_hooks();
|
||||||
|
|
||||||
// AJAX hooks — always registered so admin AJAX works.
|
|
||||||
$this->register_ajax_hooks();
|
$this->register_ajax_hooks();
|
||||||
|
$this->register_cli_hooks();
|
||||||
|
|
||||||
/**
|
/** Fires after PRAutoBlogger has finished registering all hooks. */
|
||||||
* Fires after PRAutoBlogger has finished registering all hooks.
|
|
||||||
*
|
|
||||||
* Useful for add-ons that need to hook into PRAutoBlogger's lifecycle.
|
|
||||||
*/
|
|
||||||
do_action( 'prautoblogger_loaded' );
|
do_action( 'prautoblogger_loaded' );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register admin-only hooks (settings page, notices, metabox, dashboard widget).
|
* Register admin-only hooks (settings, notices, metabox, board, dossier, widget).
|
||||||
*
|
*
|
||||||
* @return void
|
* Menu-ordering rule (v0.19.1): parent menu must register at priority 10 before
|
||||||
|
* any submenu (priority 11+). WordPress resolves the submenu hookname via
|
||||||
|
* get_plugin_page_hookname() at add_submenu_page() time; if the parent slot in
|
||||||
|
* $admin_page_hooks is unset, WP falls back to admin_page_* -> 404 at request
|
||||||
|
* time. See ARCHITECTURE.md §Board for the full trace.
|
||||||
*/
|
*/
|
||||||
private function register_admin_hooks(): void {
|
private function register_admin_hooks(): void {
|
||||||
$admin_page = new PRAutoBlogger_Admin_Page();
|
$admin_page = new PRAutoBlogger_Admin_Page();
|
||||||
add_action( 'admin_menu', [ $admin_page, 'on_register_menu' ] );
|
add_action( 'admin_menu', array( $admin_page, 'on_register_menu' ), 10 );
|
||||||
add_action( 'admin_init', [ $admin_page, 'on_register_settings' ] );
|
add_action( 'admin_init', array( $admin_page, 'on_register_settings' ) );
|
||||||
add_action( 'admin_enqueue_scripts', [ $admin_page, 'on_enqueue_assets' ] );
|
add_action( 'admin_enqueue_scripts', array( $admin_page, 'on_enqueue_assets' ) );
|
||||||
|
|
||||||
$notices = new PRAutoBlogger_Admin_Notices();
|
$board_page = new PRAutoBlogger_Board_Page();
|
||||||
add_action( 'admin_notices', [ $notices, 'on_display_notices' ] );
|
add_action( 'admin_menu', array( $board_page, 'on_register_menu' ), 11 );
|
||||||
|
add_action( 'admin_enqueue_scripts', array( $board_page, 'on_enqueue_assets' ) );
|
||||||
|
|
||||||
$metabox = new PRAutoBlogger_Post_Metabox();
|
// Dossier: link-accessed hidden submenu, priority 12 (after board).
|
||||||
add_action( 'add_meta_boxes', [ $metabox, 'on_register_metabox' ] );
|
$dossier_page = new PRAutoBlogger_Dossier_Page();
|
||||||
|
add_action( 'admin_menu', array( $dossier_page, 'on_register_menu' ), 12 );
|
||||||
|
add_action( 'admin_enqueue_scripts', array( $dossier_page, 'on_enqueue_assets' ) );
|
||||||
|
|
||||||
$metrics_page = new PRAutoBlogger_Metrics_Page();
|
add_action( 'admin_notices', array( new PRAutoBlogger_Admin_Notices(), 'on_display_notices' ) );
|
||||||
add_action( 'admin_menu', [ $metrics_page, 'on_register_menu' ] );
|
add_action( 'add_meta_boxes', array( new PRAutoBlogger_Post_Metabox(), 'on_register_metabox' ) );
|
||||||
|
add_action( 'admin_menu', array( new PRAutoBlogger_Metrics_Page(), 'on_register_menu' ) );
|
||||||
$dashboard_widget = new PRAutoBlogger_Dashboard_Widget();
|
add_action( 'wp_dashboard_setup', array( new PRAutoBlogger_Dashboard_Widget(), 'on_register_widget' ) );
|
||||||
add_action( 'wp_dashboard_setup', [ $dashboard_widget, 'on_register_widget' ] );
|
|
||||||
|
|
||||||
$review_queue = new PRAutoBlogger_Review_Queue();
|
$review_queue = new PRAutoBlogger_Review_Queue();
|
||||||
add_action( 'admin_menu', [ $review_queue, 'on_register_menu' ] );
|
add_action( 'admin_menu', array( $review_queue, 'on_register_menu' ) );
|
||||||
|
|
||||||
$log_viewer = new PRAutoBlogger_Log_Viewer();
|
$ideas_browser = new PRAutoBlogger_Ideas_Browser();
|
||||||
add_action( 'admin_menu', [ $log_viewer, 'on_register_menu' ] );
|
add_action( 'admin_menu', array( $ideas_browser, 'on_register_menu' ) );
|
||||||
|
add_action( 'admin_menu', array( new PRAutoBlogger_Log_Viewer(), 'on_register_menu' ) );
|
||||||
|
|
||||||
// Block false update notifications from wordpress.org — our plugin name
|
( new PRAutoBlogger_Post_List_Columns() )->register();
|
||||||
// collides with a different "PRAutoBlogger" in the plugin directory.
|
|
||||||
add_filter( 'site_transient_update_plugins', [ $this, 'filter_block_false_updates' ] );
|
add_filter( 'site_transient_update_plugins', array( $this, 'filter_block_false_updates' ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Register frontend hooks (shortcode, REST, typography). */
|
||||||
* Register frontend hooks (shortcode and REST API for posts widget).
|
|
||||||
*
|
|
||||||
* The shortcode renders a mount-point div; the React component hydrates it
|
|
||||||
* on the client side by fetching posts from the REST endpoint.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
private function register_frontend_hooks(): void {
|
private function register_frontend_hooks(): void {
|
||||||
$posts_widget = new PRAutoBlogger_Posts_Widget();
|
$posts_widget = new PRAutoBlogger_Posts_Widget();
|
||||||
add_action( 'init', [ $posts_widget, 'on_register_shortcode' ] );
|
add_action( 'init', array( $posts_widget, 'on_register_shortcode' ) );
|
||||||
add_action( 'rest_api_init', [ $posts_widget, 'on_register_rest_route' ] );
|
add_action( 'rest_api_init', array( $posts_widget, 'on_register_rest_route' ) );
|
||||||
|
|
||||||
|
$typography = new PRAutoBlogger_Article_Typography();
|
||||||
|
add_action( 'wp_head', array( $typography, 'on_wp_head' ) );
|
||||||
|
add_action( 'wp_enqueue_scripts', array( $typography, 'on_enqueue_fonts' ) );
|
||||||
|
add_filter( 'the_content', array( $typography, 'on_wrap_tables' ), 99 );
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Register cron-triggered hooks for scheduled generation and metrics. */
|
/** Register cron-triggered hooks for scheduled generation and metrics. */
|
||||||
private function register_cron_hooks(): void {
|
private function register_cron_hooks(): void {
|
||||||
add_action( 'prautoblogger_daily_generation', [ $this, 'on_daily_generation' ] );
|
add_action( 'prautoblogger_daily_generation', array( $this->executor, 'on_daily_generation' ) );
|
||||||
add_action( 'prautoblogger_collect_metrics', [ $this, 'on_collect_metrics' ] );
|
add_action( 'prautoblogger_collect_metrics', array( $this->executor, 'on_collect_metrics' ) );
|
||||||
|
add_action( 'prautoblogger_manual_generation', array( $this->executor, 'on_manual_generation' ) );
|
||||||
|
add_action(
|
||||||
|
PRAutoBlogger_Pipeline_Runner::CRON_ACTION,
|
||||||
|
array( $this->executor, 'on_process_article_queue' )
|
||||||
|
);
|
||||||
|
|
||||||
// Model registry refresh — fires inside on_daily_generation() before the
|
$registry = $this->executor->get_model_registry();
|
||||||
// generation lock, so a stuck lock doesn't freeze the registry too.
|
add_action( 'prautoblogger_refresh_model_registry', array( $registry, 'refresh' ) );
|
||||||
$registry = $this->get_model_registry();
|
add_action( 'prautoblogger_sync_runware_models', array( new PRAutoBlogger_Runware_Model_Catalog(), 'sync' ) );
|
||||||
add_action( 'prautoblogger_refresh_model_registry', [ $registry, 'refresh' ] );
|
|
||||||
|
add_action(
|
||||||
|
'prautoblogger_opik_dispatch',
|
||||||
|
static function (): void {
|
||||||
|
if ( ! defined( 'PRAUTOBLOGGER_OPIK_API_KEY' ) || empty( PRAUTOBLOGGER_OPIK_API_KEY ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$client = new PRAutoBlogger_Opik_Client(
|
||||||
|
PRAUTOBLOGGER_OPIK_API_KEY,
|
||||||
|
PRAUTOBLOGGER_OPIK_WORKSPACE,
|
||||||
|
PRAUTOBLOGGER_OPIK_URL_OVERRIDE
|
||||||
|
);
|
||||||
|
$dispatcher = new PRAutoBlogger_Opik_Dispatcher( $client, new PRAutoBlogger_Opik_Span_Queue() );
|
||||||
|
$dispatcher->dispatch();
|
||||||
|
update_option( 'prautoblogger_opik_last_dispatch', time() );
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( is_admin() ) {
|
||||||
|
new PRAutoBlogger_Opik_Settings();
|
||||||
|
}
|
||||||
|
|
||||||
|
// v0.20.0 (M3): operator re-run jobs — chained-cron, never synchronous.
|
||||||
|
$rerun_executor = new PRAutoBlogger_Rerun_Executor();
|
||||||
|
add_action( PRAutoBlogger_Rerun_Executor::REPLAY_ACTION, array( $rerun_executor, 'on_replay_job' ), 10, 5 );
|
||||||
|
add_action( PRAutoBlogger_Rerun_Executor::REBUILD_ACTION, array( $rerun_executor, 'on_rebuild_job' ), 10, 4 );
|
||||||
|
|
||||||
|
// v0.21.0 (M4): chained-cron checkpoint ticks for new article generation.
|
||||||
|
add_action( PRAutoBlogger_Generation_Checkpoint_Runner::ORCHESTRATE_ACTION, array( 'PRAutoBlogger_Generation_Checkpoint_Runner', 'on_orchestrate_tick' ) );
|
||||||
|
add_action( PRAutoBlogger_Generation_Checkpoint_Runner::GENERATE_ACTION, array( 'PRAutoBlogger_Generation_Checkpoint_Runner', 'on_generate_tick' ) );
|
||||||
|
|
||||||
|
add_action( 'prautoblogger_generate_from_idea', array( 'PRAutoBlogger_Ideas_Browser', 'on_cron_generate_from_idea' ) );
|
||||||
|
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' ) );
|
||||||
|
|
||||||
|
if ( defined( 'WP_CLI' ) && WP_CLI ) {
|
||||||
|
\WP_CLI::add_command(
|
||||||
|
'prautoblogger reap-research',
|
||||||
|
static function (): void {
|
||||||
|
$stats = PRAutoBlogger_Research_Reaper::reap();
|
||||||
|
\WP_CLI::success( sprintf( 'Reaped %d, deleted %d, skipped %d.', (int) $stats['reaped'], (int) $stats['deleted'], (int) $stats['skipped'] ) );
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Register AJAX handlers for admin actions. */
|
/** Register AJAX handlers for admin actions. */
|
||||||
private function register_ajax_hooks(): void {
|
private function register_ajax_hooks(): void {
|
||||||
add_action( 'wp_ajax_prautoblogger_generate_now', [ $this, 'on_ajax_generate_now' ] );
|
add_action( 'wp_ajax_prautoblogger_generate_now', array( $this->executor, 'on_ajax_generate_now' ) );
|
||||||
add_action( 'wp_ajax_prautoblogger_test_connection', [ $this, 'on_ajax_test_connection' ] );
|
add_action( 'wp_ajax_prautoblogger_generation_status', array( $this->executor, 'on_ajax_generation_status' ) );
|
||||||
|
|
||||||
|
add_action( 'wp_ajax_prautoblogger_generate_image', array( $this->ajax_handlers, 'on_ajax_generate_image' ) );
|
||||||
|
add_action( 'wp_ajax_prautoblogger_test_connection', array( $this->ajax_handlers, 'on_ajax_test_connection' ) );
|
||||||
|
add_action( 'wp_ajax_prautoblogger_get_models', array( $this->ajax_handlers, 'on_ajax_get_models' ) );
|
||||||
|
add_action( 'wp_ajax_prautoblogger_refresh_models', array( new PRAutoBlogger_Model_Registry_Refresh( $this->ajax_handlers->get_registry() ), 'handle' ) );
|
||||||
|
add_action( 'wp_ajax_prautoblogger_sync_runware_models_now', array( $this->ajax_handlers, 'on_ajax_sync_runware_models_now' ) );
|
||||||
|
|
||||||
$review_queue = new PRAutoBlogger_Review_Queue();
|
$review_queue = new PRAutoBlogger_Review_Queue();
|
||||||
add_action( 'wp_ajax_prautoblogger_approve_post', [ $review_queue, 'on_ajax_approve_post' ] );
|
add_action( 'wp_ajax_prautoblogger_approve_post', array( $review_queue, 'on_ajax_approve_post' ) );
|
||||||
add_action( 'wp_ajax_prautoblogger_reject_post', [ $review_queue, 'on_ajax_reject_post' ] );
|
add_action( 'wp_ajax_prautoblogger_reject_post', array( $review_queue, 'on_ajax_reject_post' ) );
|
||||||
|
|
||||||
$log_viewer = new PRAutoBlogger_Log_Viewer();
|
add_action( 'wp_ajax_prautoblogger_clear_logs', array( new PRAutoBlogger_Log_Viewer(), 'on_ajax_clear_logs' ) );
|
||||||
add_action( 'wp_ajax_prautoblogger_clear_logs', [ $log_viewer, 'on_ajax_clear_logs' ] );
|
add_action( 'wp_ajax_' . PRAutoBlogger_Board_Page::AJAX_ACTION, array( new PRAutoBlogger_Board_Page(), 'on_ajax_board_status' ) );
|
||||||
|
|
||||||
|
// v0.20.0 (M3): dossier edit + re-run endpoints (validate + queue only).
|
||||||
|
$dossier_actions = new PRAutoBlogger_Dossier_Actions();
|
||||||
|
add_action( 'wp_ajax_prautoblogger_dossier_save_input', array( $dossier_actions, 'on_save_input' ) );
|
||||||
|
add_action( 'wp_ajax_prautoblogger_dossier_rerun_stage', array( $dossier_actions, 'on_rerun_stage' ) );
|
||||||
|
add_action( 'wp_ajax_prautoblogger_dossier_rerun_from', array( $dossier_actions, 'on_rerun_from' ) );
|
||||||
|
add_action( 'wp_ajax_prautoblogger_dossier_stage_status', array( $dossier_actions, 'on_stage_status' ) );
|
||||||
|
|
||||||
|
$ideas = new PRAutoBlogger_Ideas_Browser();
|
||||||
|
add_action( 'wp_ajax_prautoblogger_generate_from_idea', array( $ideas, 'on_ajax_generate_from_idea' ) );
|
||||||
|
add_action( 'wp_ajax_prautoblogger_idea_gen_status', array( $ideas, 'on_ajax_idea_gen_status' ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if database needs migration after plugin update.
|
* Check if database needs migration. Delegates to PRAutoBlogger_DB_Migrations::run().
|
||||||
*
|
* Hooked on `admin_init`.
|
||||||
* Compares stored DB version with current PRAUTOBLOGGER_DB_VERSION.
|
|
||||||
* If different, re-runs activation to apply schema changes via dbDelta.
|
|
||||||
*
|
|
||||||
* Side effects: may update database schema and db_version option.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
public function on_check_db_version(): void {
|
public function on_check_db_version(): void {
|
||||||
$stored_version = get_option( 'prautoblogger_db_version', '0' );
|
PRAutoBlogger_DB_Migrations::run();
|
||||||
if ( version_compare( $stored_version, PRAUTOBLOGGER_DB_VERSION, '<' ) ) {
|
|
||||||
PRAutoBlogger_Activator::activate();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// One-time migration: switch to Gemini 2.5 Flash Lite for cost/speed.
|
/**
|
||||||
if ( ! get_option( 'prautoblogger_migrated_gemini_flash_lite' ) ) {
|
* Run DB migrations during cron requests (hooked on `init`).
|
||||||
update_option( 'prautoblogger_analysis_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL );
|
* Only fires when wp_doing_cron() is true.
|
||||||
update_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
|
*/
|
||||||
update_option( 'prautoblogger_editor_model', PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL );
|
public function on_check_db_version_for_cron(): void {
|
||||||
update_option( 'prautoblogger_migrated_gemini_flash_lite', '1' );
|
if ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ) {
|
||||||
}
|
$this->on_check_db_version();
|
||||||
|
|
||||||
// One-time migration: re-wrap existing encrypted values with "enc:" prefix
|
|
||||||
// to prevent the double-encryption bug permanently.
|
|
||||||
if ( ! get_option( 'prautoblogger_migrated_enc_prefix' ) ) {
|
|
||||||
$enc_options = [ 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json' ];
|
|
||||||
foreach ( $enc_options as $opt ) {
|
|
||||||
$val = get_option( $opt, '' );
|
|
||||||
// If it has a value but no "enc:" prefix, it's a legacy encrypted value.
|
|
||||||
// Clear it — user must re-enter. We can't reliably tell if it's
|
|
||||||
// single or double encrypted, so a fresh entry is safest.
|
|
||||||
if ( '' !== $val && ! PRAutoBlogger_Encryption::is_encrypted( $val ) ) {
|
|
||||||
delete_option( $opt );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
update_option( 'prautoblogger_migrated_enc_prefix', '1' );
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add custom cron schedules (six-hourly for metrics collection).
|
* Add custom cron schedules (six-hourly for metrics collection).
|
||||||
*
|
*
|
||||||
* @param array<string, array{interval: int, display: string}> $schedules Existing schedules.
|
* @param array<string, array{interval: int, display: string}> $schedules Existing.
|
||||||
*
|
* @return array<string, array{interval: int, display: string}>
|
||||||
* @return array<string, array{interval: int, display: string}> Modified schedules.
|
|
||||||
*/
|
*/
|
||||||
public function filter_add_cron_schedules( array $schedules ): array {
|
public function filter_add_cron_schedules( array $schedules ): array {
|
||||||
$schedules['prautoblogger_six_hours'] = [
|
$schedules['prautoblogger_six_hours'] = array(
|
||||||
'interval' => 6 * HOUR_IN_SECONDS,
|
'interval' => 6 * HOUR_IN_SECONDS,
|
||||||
'display' => __( 'Every Six Hours', 'prautoblogger' ),
|
'display' => __( 'Every Six Hours', 'prautoblogger' ),
|
||||||
];
|
);
|
||||||
return $schedules;
|
return $schedules;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Register WP-CLI commands. */
|
||||||
|
private function register_cli_hooks(): void {
|
||||||
|
PRAutoBlogger_WP_CLI_Commands::register();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Block false update notifications from wordpress.org.
|
* Block false update notifications from wordpress.org.
|
||||||
*
|
*
|
||||||
* Our plugin slug may collide with a different plugin in the directory.
|
|
||||||
* This filter removes any update notice that wordpress.org might push
|
|
||||||
* for our plugin basename, since we manage updates via our own CI/CD.
|
|
||||||
*
|
|
||||||
* @param object $transient The update_plugins transient data.
|
* @param object $transient The update_plugins transient data.
|
||||||
*
|
* @return object Modified transient.
|
||||||
* @return object Modified transient with our plugin removed from updates.
|
|
||||||
*/
|
*/
|
||||||
public function filter_block_false_updates( $transient ) {
|
public function filter_block_false_updates( $transient ) {
|
||||||
$plugin_basename = PRAUTOBLOGGER_PLUGIN_BASENAME;
|
if ( isset( $transient->response[ PRAUTOBLOGGER_PLUGIN_BASENAME ] ) ) {
|
||||||
if ( isset( $transient->response[ $plugin_basename ] ) ) {
|
unset( $transient->response[ PRAUTOBLOGGER_PLUGIN_BASENAME ] );
|
||||||
unset( $transient->response[ $plugin_basename ] );
|
|
||||||
}
|
}
|
||||||
return $transient;
|
return $transient;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Expose the executor for external access (e.g., model registry). */
|
||||||
* Handle the daily generation cron event.
|
public function get_executor(): PRAutoBlogger_Executor {
|
||||||
*
|
return $this->executor;
|
||||||
* Uses a database-level atomic mutex to prevent overlapping runs.
|
}
|
||||||
*
|
|
||||||
* Side effects: API calls, database writes, WordPress post creation.
|
// -- Backward-compatible proxies --
|
||||||
*
|
|
||||||
* @return void
|
/** @see PRAutoBlogger_Executor::on_daily_generation() */
|
||||||
*/
|
|
||||||
public function on_daily_generation(): void {
|
public function on_daily_generation(): void {
|
||||||
// Refresh the OpenRouter model registry (idempotent, skips if <12h old).
|
$this->executor->on_daily_generation(); }
|
||||||
// Fires BEFORE the generation lock so a stuck lock doesn't block the refresh.
|
|
||||||
do_action( 'prautoblogger_refresh_model_registry', false );
|
|
||||||
|
|
||||||
if ( ! $this->acquire_generation_lock() ) {
|
/** @see PRAutoBlogger_Executor::on_collect_metrics() */
|
||||||
PRAutoBlogger_Logger::instance()->info( 'Daily generation skipped: already running (lock held).', 'scheduler' );
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
( new PRAutoBlogger_Pipeline_Runner() )->run();
|
|
||||||
} catch ( \Exception $e ) {
|
|
||||||
PRAutoBlogger_Logger::instance()->error( 'Daily generation FAILED: ' . $e->getMessage(), 'scheduler' );
|
|
||||||
} finally {
|
|
||||||
$this->release_generation_lock();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle the metrics collection cron event.
|
|
||||||
*
|
|
||||||
* Side effects: API calls to GA4, database writes to ab_content_scores.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function on_collect_metrics(): void {
|
public function on_collect_metrics(): void {
|
||||||
try {
|
$this->executor->on_collect_metrics(); }
|
||||||
$metrics = new PRAutoBlogger_Metrics_Collector();
|
|
||||||
$metrics->collect_all();
|
|
||||||
} catch ( \Exception $e ) {
|
|
||||||
PRAutoBlogger_Logger::instance()->error( 'Metrics collection FAILED: ' . $e->getMessage(), 'metrics' );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/** @see PRAutoBlogger_Executor::on_ajax_generate_now() */
|
||||||
* AJAX handler: trigger manual generation.
|
|
||||||
*
|
|
||||||
* Verifies nonce and capability, then runs the generation pipeline.
|
|
||||||
* Uses the same atomic DB lock as the cron handler.
|
|
||||||
*
|
|
||||||
* Side effects: API calls, database writes, WordPress post creation.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function on_ajax_generate_now(): void {
|
public function on_ajax_generate_now(): void {
|
||||||
check_ajax_referer( 'prautoblogger_generate_now', 'nonce' );
|
$this->executor->on_ajax_generate_now(); }
|
||||||
|
|
||||||
if ( ! current_user_can( 'manage_options' ) ) {
|
/** @see PRAutoBlogger_Ajax_Handlers::on_ajax_test_connection() */
|
||||||
wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 );
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// LLM API calls can take 30-60+ seconds each. Extend PHP's execution
|
|
||||||
// limit so the pipeline isn't killed mid-generation. This may be
|
|
||||||
// blocked by Hostinger's hardcoded limits, but it's worth trying.
|
|
||||||
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
|
||||||
@set_time_limit( 300 );
|
|
||||||
|
|
||||||
// Allow admins to force-clear a stale lock.
|
|
||||||
$force = isset( $_POST['force'] ) && '1' === $_POST['force'];
|
|
||||||
if ( $force ) {
|
|
||||||
$this->release_generation_lock();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( ! $this->acquire_generation_lock() ) {
|
|
||||||
wp_send_json_error( [ 'message' => __( 'A generation run is already in progress. Pass force=1 to clear the lock.', 'prautoblogger' ) ] );
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Manual runs skip dedup so "Generate Now" always produces content
|
|
||||||
// even when a related article was recently published.
|
|
||||||
$results = ( new PRAutoBlogger_Pipeline_Runner() )
|
|
||||||
->set_skip_dedup( true )
|
|
||||||
->run();
|
|
||||||
wp_send_json_success( $results );
|
|
||||||
} catch ( \Exception $e ) {
|
|
||||||
PRAutoBlogger_Logger::instance()->error( 'Manual generation FAILED: ' . $e->getMessage(), 'pipeline' );
|
|
||||||
wp_send_json_error( [ 'message' => $e->getMessage() ] );
|
|
||||||
} finally {
|
|
||||||
$this->release_generation_lock();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** AJAX handler: test API connections (OpenRouter, Reddit). */
|
|
||||||
public function on_ajax_test_connection(): void {
|
public function on_ajax_test_connection(): void {
|
||||||
check_ajax_referer( 'prautoblogger_test_connection', 'nonce' );
|
$this->ajax_handlers->on_ajax_test_connection(); }
|
||||||
if ( ! current_user_can( 'manage_options' ) ) {
|
|
||||||
wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 );
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$service = isset( $_POST['service'] ) ? sanitize_text_field( wp_unslash( $_POST['service'] ) ) : '';
|
/** @see PRAutoBlogger_Executor::get_model_registry() */
|
||||||
$results = [];
|
|
||||||
|
|
||||||
if ( 'openrouter' === $service || 'all' === $service ) {
|
|
||||||
$llm = new PRAutoBlogger_OpenRouter_Provider();
|
|
||||||
$results['openrouter'] = $llm->validate_credentials_detailed();
|
|
||||||
}
|
|
||||||
if ( 'reddit' === $service || 'all' === $service ) {
|
|
||||||
$reddit = new PRAutoBlogger_Reddit_Provider();
|
|
||||||
$is_ok = $reddit->validate_credentials();
|
|
||||||
$results['reddit'] = $is_ok
|
|
||||||
? [ 'status' => 'ok', 'message' => __( 'Reddit source available (RSS primary, .json fallback).', 'prautoblogger' ) ]
|
|
||||||
: [ 'status' => 'error', 'message' => __( 'Reddit sources unreachable (both RSS and .json failed).', 'prautoblogger' ) ];
|
|
||||||
}
|
|
||||||
wp_send_json_success( $results );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Acquire a database-level atomic mutex for generation.
|
|
||||||
*
|
|
||||||
* Uses INSERT IGNORE into wp_options — option_name has a UNIQUE index so only
|
|
||||||
* one process can insert the row. Expired locks (>1 hour) are cleaned up first.
|
|
||||||
*
|
|
||||||
* @return bool True if lock acquired, false if another process holds it.
|
|
||||||
*/
|
|
||||||
private function acquire_generation_lock(): bool {
|
|
||||||
global $wpdb;
|
|
||||||
|
|
||||||
$lock_name = 'prautoblogger_generation_lock';
|
|
||||||
$now = (string) time();
|
|
||||||
|
|
||||||
// Clean up expired locks older than 1 hour — prevents permanent deadlock.
|
|
||||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
|
||||||
$wpdb->query(
|
|
||||||
$wpdb->prepare(
|
|
||||||
"DELETE FROM {$wpdb->options} WHERE option_name = %s AND CAST(option_value AS UNSIGNED) < %d",
|
|
||||||
$lock_name,
|
|
||||||
time() - HOUR_IN_SECONDS
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Atomic insert — UNIQUE constraint on option_name guarantees only one process succeeds.
|
|
||||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
|
||||||
$result = $wpdb->query(
|
|
||||||
$wpdb->prepare(
|
|
||||||
"INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, %s)",
|
|
||||||
$lock_name,
|
|
||||||
$now,
|
|
||||||
'no'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
return $result > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lazy-load the OpenRouter model registry singleton.
|
|
||||||
*
|
|
||||||
* Config (option names, transient names) is injected here — the registry
|
|
||||||
* class itself has no knowledge of PRAUTOBLOGGER_* constants.
|
|
||||||
*
|
|
||||||
* @return PRAutoBlogger_OpenRouter_Model_Registry
|
|
||||||
*/
|
|
||||||
public function get_model_registry(): PRAutoBlogger_OpenRouter_Model_Registry {
|
public function get_model_registry(): PRAutoBlogger_OpenRouter_Model_Registry {
|
||||||
if ( null === $this->model_registry ) {
|
return $this->executor->get_model_registry(); }
|
||||||
$this->model_registry = new PRAutoBlogger_OpenRouter_Model_Registry(
|
|
||||||
'prautoblogger_openrouter_model_registry',
|
|
||||||
'prautoblogger_openrouter_model_registry_cache'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return $this->model_registry;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Release the generation mutex. */
|
|
||||||
private function release_generation_lock(): void {
|
|
||||||
global $wpdb;
|
|
||||||
|
|
||||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
|
||||||
$wpdb->query(
|
|
||||||
$wpdb->prepare(
|
|
||||||
"DELETE FROM {$wpdb->options} WHERE option_name = %s",
|
|
||||||
'prautoblogger_generation_lock'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
146
includes/class-schema-installer.php
Normal file
146
includes/class-schema-installer.php
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Creates / updates the plugin's custom database tables via `dbDelta`.
|
||||||
|
*
|
||||||
|
* Extracted from `PRAutoBlogger_Activator` in v0.8.2 to keep that class
|
||||||
|
* under the 300-line cap once the timezone-aware cron migration landed.
|
||||||
|
* The Activator delegates to `install()` on activate and on db-version
|
||||||
|
* upgrade; schema definitions live here and only here.
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger_Activator::activate().
|
||||||
|
* Dependencies: WordPress $wpdb, dbDelta() from wp-admin/includes/upgrade.php.
|
||||||
|
*
|
||||||
|
* @see class-activator.php — Sole caller; delegates on activate and db-version bump.
|
||||||
|
* @see ARCHITECTURE.md — Data model section documents each table's purpose.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Schema_Installer {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create (or upgrade) all plugin tables.
|
||||||
|
*
|
||||||
|
* `dbDelta` is idempotent and will create new tables, add missing
|
||||||
|
* columns on existing tables, and add missing indexes — but it will
|
||||||
|
* not drop columns or indexes. That matches the plugin's forward-only
|
||||||
|
* schema-migration policy.
|
||||||
|
*
|
||||||
|
* Side effects: up to five `CREATE TABLE` / `ALTER TABLE` statements.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function install(): void {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$charset_collate = $wpdb->get_charset_collate();
|
||||||
|
$prefix = $wpdb->prefix . 'prautoblogger_';
|
||||||
|
|
||||||
|
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||||
|
|
||||||
|
// Source data — raw posts/comments from social platforms.
|
||||||
|
$sql_source_data = "CREATE TABLE {$prefix}source_data (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
source_type VARCHAR(50) NOT NULL,
|
||||||
|
source_id VARCHAR(255) NOT NULL,
|
||||||
|
subreddit VARCHAR(255) DEFAULT NULL,
|
||||||
|
title TEXT DEFAULT NULL,
|
||||||
|
content LONGTEXT DEFAULT NULL,
|
||||||
|
author VARCHAR(255) DEFAULT NULL,
|
||||||
|
score INT DEFAULT 0,
|
||||||
|
comment_count INT DEFAULT 0,
|
||||||
|
permalink VARCHAR(500) DEFAULT NULL,
|
||||||
|
collected_at DATETIME NOT NULL,
|
||||||
|
metadata_json LONGTEXT DEFAULT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY source_unique (source_type, source_id),
|
||||||
|
KEY source_collected (source_type, collected_at)
|
||||||
|
) {$charset_collate};";
|
||||||
|
|
||||||
|
// Analysis results — detected patterns from source data.
|
||||||
|
$sql_analysis = "CREATE TABLE {$prefix}analysis_results (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
analysis_type VARCHAR(50) NOT NULL,
|
||||||
|
topic VARCHAR(500) NOT NULL,
|
||||||
|
summary TEXT DEFAULT NULL,
|
||||||
|
frequency INT DEFAULT 1,
|
||||||
|
relevance_score FLOAT DEFAULT 0,
|
||||||
|
source_ids_json LONGTEXT DEFAULT NULL,
|
||||||
|
analyzed_at DATETIME NOT NULL,
|
||||||
|
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)
|
||||||
|
) {$charset_collate};";
|
||||||
|
|
||||||
|
// Generation log — every API call with cost tracking. `run_id`
|
||||||
|
// groups all entries from a single pipeline execution so
|
||||||
|
// `link_generation_logs()` can accurately attribute costs to a post.
|
||||||
|
// v0.18.0 adds the nullable audit columns `agent_role` and
|
||||||
|
// `prompt_version` (dbDelta adds them additively on upgrade;
|
||||||
|
// historical rows stay valid with NULL).
|
||||||
|
$sql_generation_log = "CREATE TABLE {$prefix}generation_log (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
post_id BIGINT UNSIGNED DEFAULT NULL,
|
||||||
|
run_id VARCHAR(36) DEFAULT NULL,
|
||||||
|
stage VARCHAR(50) NOT NULL,
|
||||||
|
provider VARCHAR(50) NOT NULL,
|
||||||
|
model VARCHAR(100) NOT NULL,
|
||||||
|
prompt_tokens INT DEFAULT 0,
|
||||||
|
completion_tokens INT DEFAULT 0,
|
||||||
|
estimated_cost DECIMAL(10,6) DEFAULT 0,
|
||||||
|
request_json LONGTEXT DEFAULT NULL,
|
||||||
|
response_status VARCHAR(20) NOT NULL DEFAULT 'success',
|
||||||
|
error_message TEXT DEFAULT NULL,
|
||||||
|
agent_role VARCHAR(50) DEFAULT NULL,
|
||||||
|
prompt_version VARCHAR(20) DEFAULT NULL,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
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)
|
||||||
|
) {$charset_collate};";
|
||||||
|
|
||||||
|
// Content scores — post performance metrics for self-improvement.
|
||||||
|
$sql_content_scores = "CREATE TABLE {$prefix}content_scores (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
post_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
pageviews INT DEFAULT 0,
|
||||||
|
avg_time_on_page FLOAT DEFAULT 0,
|
||||||
|
bounce_rate FLOAT DEFAULT 0,
|
||||||
|
comment_count INT DEFAULT 0,
|
||||||
|
composite_score FLOAT DEFAULT 0,
|
||||||
|
score_factors_json LONGTEXT DEFAULT NULL,
|
||||||
|
measured_at DATETIME NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY post_id (post_id),
|
||||||
|
KEY measured_at (measured_at),
|
||||||
|
KEY composite_score (composite_score)
|
||||||
|
) {$charset_collate};";
|
||||||
|
|
||||||
|
// Event log — structured application log entries for the admin log viewer.
|
||||||
|
$sql_event_log = "CREATE TABLE {$prefix}event_log (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
level VARCHAR(10) NOT NULL DEFAULT 'info',
|
||||||
|
context VARCHAR(100) DEFAULT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
meta_json LONGTEXT DEFAULT NULL,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY level (level),
|
||||||
|
KEY created_at (created_at),
|
||||||
|
KEY level_created (level, created_at)
|
||||||
|
) {$charset_collate};";
|
||||||
|
|
||||||
|
dbDelta( $sql_source_data );
|
||||||
|
dbDelta( $sql_analysis );
|
||||||
|
dbDelta( $sql_generation_log );
|
||||||
|
dbDelta( $sql_content_scores );
|
||||||
|
dbDelta( $sql_event_log );
|
||||||
|
}
|
||||||
|
}
|
||||||
146
includes/core/class-analysis-prompts.php
Normal file
146
includes/core/class-analysis-prompts.php
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Prompt construction for the content analysis LLM call.
|
||||||
|
*
|
||||||
|
* What: Builds system and user prompts for source-data analysis, including
|
||||||
|
* past-performance context for self-improvement feedback loops.
|
||||||
|
* Who calls it: PRAutoBlogger_Content_Analyzer::analyze_recent_data().
|
||||||
|
* Dependencies: PRAutoBlogger_Prompt_Registry (versioned prompt bodies),
|
||||||
|
* WordPress $wpdb (for performance lookup), no external APIs.
|
||||||
|
*
|
||||||
|
* @see core/class-content-analyzer.php — Orchestrates analysis and calls these builders.
|
||||||
|
* @see ARCHITECTURE.md — Data flow step 2.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Analysis_Prompts {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the system prompt for content analysis.
|
||||||
|
*
|
||||||
|
* @param string $niche Niche description from settings.
|
||||||
|
* @param string $performance_context Past performance data summary.
|
||||||
|
* @param int $target_count Number of ideas the LLM should produce.
|
||||||
|
* @return string System prompt text.
|
||||||
|
*/
|
||||||
|
public static function build_system_prompt( string $niche, string $performance_context, int $target_count = 6 ): string {
|
||||||
|
// Blocks carry their own separators when non-empty so the rendered
|
||||||
|
// output is byte-identical to the historical concatenation.
|
||||||
|
$performance_block = '' !== $performance_context ? $performance_context . "\n\n" : '';
|
||||||
|
|
||||||
|
$recent_context = self::get_recent_articles_context();
|
||||||
|
$recent_block = '' !== $recent_context ? $recent_context . "\n\n" : '';
|
||||||
|
|
||||||
|
$instructions = trim( (string) get_option( 'prautoblogger_analysis_instructions', '' ) );
|
||||||
|
$instructions_block = '' !== $instructions ? "Additional instructions:\n" . $instructions . "\n\n" : '';
|
||||||
|
|
||||||
|
return PRAutoBlogger_Prompt_Registry::render(
|
||||||
|
'analysis.system',
|
||||||
|
array(
|
||||||
|
'niche_clause' => '' !== $niche ? " in the {$niche} niche" : '',
|
||||||
|
'target_count' => (string) $target_count,
|
||||||
|
'performance_block' => $performance_block,
|
||||||
|
'recent_articles_block' => $recent_block,
|
||||||
|
'instructions_block' => $instructions_block,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the user prompt containing the actual source data.
|
||||||
|
*
|
||||||
|
* @param string $summary Formatted source data summary.
|
||||||
|
* @param int $target_count Number of ideas requested.
|
||||||
|
* @return string User prompt text.
|
||||||
|
*/
|
||||||
|
public static function build_user_prompt( string $summary, int $target_count = 6 ): string {
|
||||||
|
return PRAutoBlogger_Prompt_Registry::render(
|
||||||
|
'analysis.user',
|
||||||
|
array(
|
||||||
|
'target_count' => (string) $target_count,
|
||||||
|
'summary' => $summary,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get past content performance data to feed into analysis for self-improvement.
|
||||||
|
*
|
||||||
|
* Side effects: database read.
|
||||||
|
*
|
||||||
|
* @return string Summary of what topics performed well, or empty string if no data.
|
||||||
|
*/
|
||||||
|
public static function get_performance_context(): string {
|
||||||
|
global $wpdb;
|
||||||
|
$scores_table = $wpdb->prefix . 'prautoblogger_content_scores';
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
$top_posts = $wpdb->get_results(
|
||||||
|
"SELECT cs.post_id, cs.composite_score, p.post_title
|
||||||
|
FROM {$scores_table} cs
|
||||||
|
JOIN {$wpdb->posts} p ON p.ID = cs.post_id
|
||||||
|
WHERE cs.composite_score > 0
|
||||||
|
ORDER BY cs.composite_score DESC
|
||||||
|
LIMIT 5",
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( empty( $top_posts ) ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = array( 'Top performing past articles (learn from these):' );
|
||||||
|
foreach ( $top_posts as $post ) {
|
||||||
|
$lines[] = sprintf(
|
||||||
|
'- "%s" (score: %.1f)',
|
||||||
|
$post['post_title'],
|
||||||
|
(float) $post['composite_score']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode( "\n", $lines );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get titles of recently published articles so the LLM avoids repeating them.
|
||||||
|
*
|
||||||
|
* Fetches generated posts from the last 30 days. A wider window than the
|
||||||
|
* scorer's 7-day dedup because the LLM is smart enough to find genuinely
|
||||||
|
* new angles even on adjacent topics — we just need it to know what exists.
|
||||||
|
*
|
||||||
|
* Side effects: database read via WP_Query.
|
||||||
|
*
|
||||||
|
* @return string Formatted context block, or empty string if no recent articles.
|
||||||
|
*/
|
||||||
|
public static function get_recent_articles_context(): string {
|
||||||
|
$query = new \WP_Query(
|
||||||
|
array(
|
||||||
|
'post_type' => 'post',
|
||||||
|
'post_status' => array( 'publish', 'draft', 'pending' ),
|
||||||
|
'meta_key' => '_prautoblogger_generated',
|
||||||
|
'meta_value' => '1',
|
||||||
|
'date_query' => array( array( 'after' => '30 days ago' ) ),
|
||||||
|
'posts_per_page' => 100,
|
||||||
|
'fields' => 'ids',
|
||||||
|
'no_found_rows' => true,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( empty( $query->posts ) ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$titles = array();
|
||||||
|
foreach ( $query->posts as $post_id ) {
|
||||||
|
$titles[] = '- "' . get_the_title( $post_id ) . '"';
|
||||||
|
}
|
||||||
|
|
||||||
|
$block = 'IMPORTANT — Topics already covered (last 30 days). Do NOT suggest topics that overlap with these. ';
|
||||||
|
$block .= "Find genuinely NEW angles, questions, or subjects that are NOT already covered:\n";
|
||||||
|
$block .= implode( "\n", $titles );
|
||||||
|
|
||||||
|
return $block;
|
||||||
|
}
|
||||||
|
}
|
||||||
291
includes/core/class-article-worker.php
Normal file
291
includes/core/class-article-worker.php
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Generates a single article: content → editorial review → publish/draft.
|
||||||
|
*
|
||||||
|
* Extracted from Pipeline_Runner so each chained cron job has a focused,
|
||||||
|
* single-responsibility worker. One worker = one article = one PHP process.
|
||||||
|
*
|
||||||
|
* v0.18.0: per-article stage state (run_stages, item-scoped) makes resume
|
||||||
|
* idempotent — an already-published item is skipped outright, a done
|
||||||
|
* review is reused from its snapshot, and the writer reuses done LLM
|
||||||
|
* stages via Content_Generator::set_run_item(). Editorial verdicts and
|
||||||
|
* the sources that fed the article are persisted to the audit tables.
|
||||||
|
*
|
||||||
|
* Triggered by: Pipeline_Runner (directly for article 1, via cron for 2..N).
|
||||||
|
* Dependencies: Content_Generator, Chief_Editor, Publisher, Cost_Tracker,
|
||||||
|
* Run_Stage_State, Audit_Writer.
|
||||||
|
*
|
||||||
|
* Opik instrumentation: Initializes trace context at start, creates spans
|
||||||
|
* for content generation and editorial review. Feature-flag gated (default off).
|
||||||
|
*
|
||||||
|
* @see core/class-pipeline-runner.php — Orchestrates and dispatches workers.
|
||||||
|
* @see core/class-content-generator.php — LLM content generation.
|
||||||
|
* @see core/class-chief-editor.php — Editorial quality gate.
|
||||||
|
* @see core/class-publisher.php — WordPress post creation.
|
||||||
|
* @see services/opik/class-opik-trace-context.php — Opik tracing.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Article_Worker {
|
||||||
|
|
||||||
|
private PRAutoBlogger_Cost_Tracker $cost_tracker;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param PRAutoBlogger_Cost_Tracker $cost_tracker Shared cost tracker.
|
||||||
|
*/
|
||||||
|
public function __construct( PRAutoBlogger_Cost_Tracker $cost_tracker ) {
|
||||||
|
$this->cost_tracker = $cost_tracker;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate one article from an idea.
|
||||||
|
*
|
||||||
|
* Runs the full single-article pipeline: LLM content generation →
|
||||||
|
* editorial review → publish or save as draft.
|
||||||
|
*
|
||||||
|
* Initializes Opik trace context if enabled and tears it down at end.
|
||||||
|
*
|
||||||
|
* Side effects: LLM API calls, database writes, WordPress post creation,
|
||||||
|
* image generation, cost logging, Opik span queuing.
|
||||||
|
*
|
||||||
|
* @param PRAutoBlogger_Article_Idea $idea The scored idea to generate from.
|
||||||
|
*
|
||||||
|
* @return array{generated: int, published: int, rejected: int, cost: float}
|
||||||
|
*/
|
||||||
|
public function generate( PRAutoBlogger_Article_Idea $idea ): array {
|
||||||
|
$run_id = (string) ( $this->cost_tracker->get_run_id() ?? '' );
|
||||||
|
$item = PRAutoBlogger_Run_Stage_State::item_key_for_idea( $idea );
|
||||||
|
|
||||||
|
// v0.20.0: persist the EXACT idea payload once per item so
|
||||||
|
// re-run-from-here can reconstruct it (rebuilding from post
|
||||||
|
// fields would risk an item_key hash mismatch -> duplicate post).
|
||||||
|
if ( '' !== $run_id ) {
|
||||||
|
PRAutoBlogger_Stage_Input_Store::save_seed( $run_id, $item, (string) wp_json_encode( $idea->to_array() ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = array(
|
||||||
|
'generated' => 0,
|
||||||
|
'published' => 0,
|
||||||
|
'rejected' => 0,
|
||||||
|
'cost' => 0.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Idempotent resume: this item already completed its publish stage
|
||||||
|
// in this run — re-dispatching it must not create a second post or
|
||||||
|
// charge a second cent.
|
||||||
|
if ( '' !== $run_id && PRAutoBlogger_Run_Stage_State::is_done( $run_id, 'publish', '', $item ) ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
|
sprintf( 'Skipping "%s" — already completed in run %s (idempotent resume).', $idea->get_topic(), $run_id ),
|
||||||
|
'pipeline'
|
||||||
|
);
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Opik trace context for this article generation.
|
||||||
|
$opik = $this->should_trace_with_opik();
|
||||||
|
if ( $opik ) {
|
||||||
|
PRAutoBlogger_Opik_Trace_Context::current()->init_trace();
|
||||||
|
}
|
||||||
|
|
||||||
|
$llm = new PRAutoBlogger_OpenRouter_Provider();
|
||||||
|
$generator = new PRAutoBlogger_Content_Generator( $llm, $this->cost_tracker );
|
||||||
|
$generator->set_run_item( '' !== $run_id ? $run_id : null, $item );
|
||||||
|
$editor = new PRAutoBlogger_Chief_Editor( $llm, $this->cost_tracker );
|
||||||
|
$publisher = new PRAutoBlogger_Publisher();
|
||||||
|
|
||||||
|
$auto_publish = in_array(
|
||||||
|
get_option( 'prautoblogger_auto_publish', '0' ),
|
||||||
|
array( '1', 'yes' ),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Generating article draft via AI…', 'prautoblogger' ) );
|
||||||
|
$content = $generator->generate( $idea );
|
||||||
|
$result['generated'] = 1;
|
||||||
|
|
||||||
|
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Running editorial pass…', 'prautoblogger' ) );
|
||||||
|
$review = $this->review_with_state( $editor, $content, $idea, $run_id, $item );
|
||||||
|
|
||||||
|
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Saving and publishing…', 'prautoblogger' ) );
|
||||||
|
PRAutoBlogger_Run_Stage_State::start( $run_id, 'publish', (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( 'publish' ), $item );
|
||||||
|
$this->publish_or_draft(
|
||||||
|
$content,
|
||||||
|
$idea,
|
||||||
|
$review,
|
||||||
|
$publisher,
|
||||||
|
$auto_publish,
|
||||||
|
$result
|
||||||
|
);
|
||||||
|
PRAutoBlogger_Run_Stage_State::done( $run_id, 'publish', '', $item );
|
||||||
|
|
||||||
|
PRAutoBlogger_Audit_Writer::record_idea_sources( $run_id, $idea );
|
||||||
|
} catch ( \Throwable $e ) {
|
||||||
|
if ( '' !== $run_id ) {
|
||||||
|
PRAutoBlogger_Run_Stage_State::fail_open_for_item( $run_id, $item );
|
||||||
|
}
|
||||||
|
PRAutoBlogger_Logger::instance()->error(
|
||||||
|
sprintf(
|
||||||
|
'Article generation %s for "%s": %s',
|
||||||
|
get_class( $e ),
|
||||||
|
$idea->get_topic(),
|
||||||
|
$e->getMessage()
|
||||||
|
),
|
||||||
|
'pipeline'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result['cost'] = $this->cost_tracker->get_current_run_cost();
|
||||||
|
|
||||||
|
// Finalize and queue Opik trace if enabled.
|
||||||
|
if ( $opik ) {
|
||||||
|
$this->finalize_opik_trace();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editorial review with stage state: a done review is reused from its
|
||||||
|
* snapshot (never re-charged); a fresh review is checkpointed and its
|
||||||
|
* verdict recorded to the run_decisions audit table.
|
||||||
|
*
|
||||||
|
* Side effects: run_stages upserts, one run_decisions insert, and —
|
||||||
|
* when not resuming — one LLM call via Chief_Editor.
|
||||||
|
*
|
||||||
|
* @param PRAutoBlogger_Chief_Editor $editor Editor agent.
|
||||||
|
* @param string $content Generated HTML content.
|
||||||
|
* @param PRAutoBlogger_Article_Idea $idea The idea under review.
|
||||||
|
* @param string $run_id Run UUID ('' outside a run).
|
||||||
|
* @param string $item Stage item key for this idea.
|
||||||
|
* @return PRAutoBlogger_Editorial_Review
|
||||||
|
*/
|
||||||
|
private function review_with_state(
|
||||||
|
PRAutoBlogger_Chief_Editor $editor,
|
||||||
|
string $content,
|
||||||
|
PRAutoBlogger_Article_Idea $idea,
|
||||||
|
string $run_id,
|
||||||
|
string $item
|
||||||
|
): PRAutoBlogger_Editorial_Review {
|
||||||
|
if ( '' !== $run_id ) {
|
||||||
|
$snapshot = PRAutoBlogger_Run_Stage_State::get_output( $run_id, 'review', '', $item );
|
||||||
|
if ( null !== $snapshot ) {
|
||||||
|
$data = json_decode( $snapshot, true );
|
||||||
|
if ( is_array( $data ) && isset( $data['verdict'] ) ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
|
sprintf( 'Reusing completed review for "%s" (idempotent resume).', $idea->get_topic() ),
|
||||||
|
'pipeline'
|
||||||
|
);
|
||||||
|
return new PRAutoBlogger_Editorial_Review( $data );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PRAutoBlogger_Run_Stage_State::start( $run_id, 'review', (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( 'review' ), $item );
|
||||||
|
}
|
||||||
|
|
||||||
|
$review = $editor->review( $content, $idea );
|
||||||
|
|
||||||
|
if ( '' !== $run_id ) {
|
||||||
|
$snapshot = (string) wp_json_encode(
|
||||||
|
array(
|
||||||
|
'verdict' => $review->get_verdict(),
|
||||||
|
'notes' => $review->get_notes(),
|
||||||
|
'revised_content' => $review->get_revised_content(),
|
||||||
|
'quality_score' => $review->get_quality_score(),
|
||||||
|
'seo_score' => $review->get_seo_score(),
|
||||||
|
'issues' => $review->get_issues(),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
PRAutoBlogger_Run_Stage_State::done( $run_id, 'review', '', $item, $snapshot );
|
||||||
|
PRAutoBlogger_Audit_Writer::record_decision( $run_id, 'review', $review->get_verdict(), $review->get_notes() );
|
||||||
|
}
|
||||||
|
|
||||||
|
return $review;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish approved content or save as draft.
|
||||||
|
*
|
||||||
|
* @param string $content Generated HTML content.
|
||||||
|
* @param PRAutoBlogger_Article_Idea $idea Source idea.
|
||||||
|
* @param PRAutoBlogger_Editorial_Review $review Editor verdict.
|
||||||
|
* @param PRAutoBlogger_Publisher $publisher Publisher instance.
|
||||||
|
* @param bool $auto_publish Whether to auto-publish.
|
||||||
|
* @param array &$result Result counters (by ref).
|
||||||
|
*/
|
||||||
|
private function publish_or_draft(
|
||||||
|
string $content,
|
||||||
|
PRAutoBlogger_Article_Idea $idea,
|
||||||
|
PRAutoBlogger_Editorial_Review $review,
|
||||||
|
PRAutoBlogger_Publisher $publisher,
|
||||||
|
bool $auto_publish,
|
||||||
|
array &$result
|
||||||
|
): void {
|
||||||
|
$verdict = $review->get_verdict();
|
||||||
|
$run_id = $this->cost_tracker->get_run_id();
|
||||||
|
|
||||||
|
if ( 'approved' === $verdict || 'revised' === $verdict ) {
|
||||||
|
$final = 'revised' === $verdict
|
||||||
|
? ( $review->get_revised_content() ?? $content )
|
||||||
|
: $content;
|
||||||
|
|
||||||
|
if ( $auto_publish ) {
|
||||||
|
$publisher->publish( $final, $idea, $review, $run_id, $this->cost_tracker );
|
||||||
|
++$result['published'];
|
||||||
|
} else {
|
||||||
|
$publisher->save_as_draft( $final, $idea, $review, $run_id, $this->cost_tracker );
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$publisher->save_as_draft( $content, $idea, $review, $run_id, $this->cost_tracker );
|
||||||
|
++$result['rejected'];
|
||||||
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
|
'Article rejected by editor: ' . $idea->get_topic(),
|
||||||
|
'pipeline'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Opik tracing is enabled and has credentials.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private function should_trace_with_opik(): bool {
|
||||||
|
if ( ! get_option( 'prautoblogger_opik_enabled', false ) ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return defined( 'PRAUTOBLOGGER_OPIK_API_KEY' ) &&
|
||||||
|
defined( 'PRAUTOBLOGGER_OPIK_WORKSPACE' ) &&
|
||||||
|
! empty( PRAUTOBLOGGER_OPIK_API_KEY ) &&
|
||||||
|
! empty( PRAUTOBLOGGER_OPIK_WORKSPACE );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalize and queue the Opik trace.
|
||||||
|
*
|
||||||
|
* Dispatches trace to queue for async posting.
|
||||||
|
*/
|
||||||
|
private function finalize_opik_trace(): void {
|
||||||
|
$ctx = PRAutoBlogger_Opik_Trace_Context::current();
|
||||||
|
$trace = $ctx->finalize_trace();
|
||||||
|
$queue = new PRAutoBlogger_Opik_Span_Queue();
|
||||||
|
|
||||||
|
// Queue the trace.
|
||||||
|
$queue->enqueue( $trace, 'trace' );
|
||||||
|
|
||||||
|
// Queue all spans.
|
||||||
|
foreach ( $ctx->get_spans() as $span ) {
|
||||||
|
$queue->enqueue( $span, 'span' );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule async dispatch if not already scheduled.
|
||||||
|
if ( ! wp_next_scheduled( 'prautoblogger_opik_dispatch' ) ) {
|
||||||
|
wp_schedule_single_event( time(), 'prautoblogger_opik_dispatch' );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Teardown context for next request.
|
||||||
|
PRAutoBlogger_Opik_Trace_Context::teardown();
|
||||||
|
}
|
||||||
|
}
|
||||||
163
includes/core/class-audit-writer.php
Normal file
163
includes/core/class-audit-writer.php
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Writes run-scoped audit rows: sources considered + stage decisions.
|
||||||
|
*
|
||||||
|
* What: Thin, self-healing insert layer over the v0.18.0 audit child
|
||||||
|
* tables. `record_decision()` persists a stage verdict (Phase 1:
|
||||||
|
* the chief editor's review verdict; Phase 2 adds curate/editorial/
|
||||||
|
* seo decisions and citation_score). `record_idea_sources()`
|
||||||
|
* consolidates which collected sources fed a generated article —
|
||||||
|
* the run_sources replacement for the historical source_ids_json /
|
||||||
|
* `_prautoblogger_research_sources` scatter — for NEW runs only (no
|
||||||
|
* backfill). Every method no-ops when the tables are missing.
|
||||||
|
* Who triggers it: Article_Worker (decision after review, sources after
|
||||||
|
* publish), Phase-2 agents.
|
||||||
|
* Dependencies: WordPress $wpdb, Run_State (availability probe pattern).
|
||||||
|
*
|
||||||
|
* @see core/class-article-worker.php — Phase-1 call sites.
|
||||||
|
* @see ARCHITECTURE.md #21 — Audit substrate design.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Audit_Writer {
|
||||||
|
|
||||||
|
/** @var bool|null Per-request "audit tables exist" probe result. */
|
||||||
|
private static ?bool $tables_ok = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the audit child tables exist. Cached per request; never throws.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function is_available(): bool {
|
||||||
|
if ( null !== self::$tables_ok ) {
|
||||||
|
return self::$tables_ok;
|
||||||
|
}
|
||||||
|
global $wpdb;
|
||||||
|
if ( null === $wpdb ) {
|
||||||
|
return false; // Not cached: $wpdb may appear later in boot.
|
||||||
|
}
|
||||||
|
$table = $wpdb->prefix . 'prautoblogger_run_decisions';
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||||
|
$found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
|
||||||
|
self::$tables_ok = ( $found === $table );
|
||||||
|
return self::$tables_ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a stage decision for a run.
|
||||||
|
*
|
||||||
|
* @param string $run_id Run UUID.
|
||||||
|
* @param string $stage Deciding stage (see Stage_Display_Map).
|
||||||
|
* @param string $verdict e.g. 'approved', 'revised', 'rejected'.
|
||||||
|
* @param string $rationale Why (editor notes, governor message, …).
|
||||||
|
* @param float|null $citation_score Phase-2 citation score (null in Phase 1).
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function record_decision( string $run_id, string $stage, string $verdict, string $rationale = '', ?float $citation_score = null ): void {
|
||||||
|
if ( '' === $run_id || ! self::is_available() ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
global $wpdb;
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
$wpdb->insert(
|
||||||
|
$wpdb->prefix . 'prautoblogger_run_decisions',
|
||||||
|
array(
|
||||||
|
'run_id' => $run_id,
|
||||||
|
'stage' => $stage,
|
||||||
|
'verdict' => $verdict,
|
||||||
|
'rationale' => $rationale,
|
||||||
|
'citation_score' => $citation_score,
|
||||||
|
'created_at' => current_time( 'mysql' ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record the collected sources that fed a generated article (kept = 1;
|
||||||
|
* Phase-2 curation adds discard rows with reasons + quality scores).
|
||||||
|
*
|
||||||
|
* Resolves the idea's source_data ids to permalinks in one query.
|
||||||
|
*
|
||||||
|
* @param string $run_id Run UUID.
|
||||||
|
* @param PRAutoBlogger_Article_Idea $idea The generated idea.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function record_idea_sources( string $run_id, PRAutoBlogger_Article_Idea $idea ): void {
|
||||||
|
if ( '' === $run_id || ! self::is_available() ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$source_ids = array_filter( array_map( 'intval', $idea->get_source_ids() ) );
|
||||||
|
if ( empty( $source_ids ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$source_table = $wpdb->prefix . 'prautoblogger_source_data';
|
||||||
|
$placeholders = implode( ',', array_fill( 0, count( $source_ids ), '%d' ) );
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared
|
||||||
|
$rows = $wpdb->get_results(
|
||||||
|
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $placeholders is a fixed %d list.
|
||||||
|
$wpdb->prepare( "SELECT id, source_type, permalink FROM {$source_table} WHERE id IN ({$placeholders})", $source_ids ),
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
if ( ! is_array( $rows ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ( $rows as $row ) {
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
$wpdb->insert(
|
||||||
|
$wpdb->prefix . 'prautoblogger_run_sources',
|
||||||
|
array(
|
||||||
|
'run_id' => $run_id,
|
||||||
|
'agent_role' => 'analyst',
|
||||||
|
'source_url' => (string) ( $row['permalink'] ?? '' ),
|
||||||
|
'doi' => null,
|
||||||
|
'kept' => 1,
|
||||||
|
'reason' => sprintf( 'Cited by analyzer for "%s" (%s #%d)', $idea->get_topic(), (string) $row['source_type'], (int) $row['id'] ),
|
||||||
|
'quality_score' => null,
|
||||||
|
'created_at' => current_time( 'mysql' ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flag the run_decisions rows of given stages as human-modified
|
||||||
|
* (v0.20.0 / CPO guardrail 2). Used in two cases: the decision rows of
|
||||||
|
* a stage whose edited input enters execution, and decisions recorded
|
||||||
|
* during a re-run-from-here window on an item that carries a human
|
||||||
|
* edit (derived-from-edited-content). Self-healing no-op when the
|
||||||
|
* column/table is missing.
|
||||||
|
*
|
||||||
|
* @param string $run_id Run UUID.
|
||||||
|
* @param array<string> $stages Stage names to flag.
|
||||||
|
* @param string|null $since Optional MySQL datetime — only flag rows created at/after it.
|
||||||
|
* @return int Rows flagged.
|
||||||
|
*/
|
||||||
|
public static function flag_decisions_human_modified( string $run_id, array $stages, ?string $since = null ): int {
|
||||||
|
if ( '' === $run_id || empty( $stages ) || ! self::is_available() ) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
global $wpdb;
|
||||||
|
$table = $wpdb->prefix . 'prautoblogger_run_decisions';
|
||||||
|
$placeholders = implode( ',', array_fill( 0, count( $stages ), '%s' ) );
|
||||||
|
$sql = "UPDATE {$table} SET human_modified = 1 WHERE run_id = %s AND stage IN ({$placeholders})";
|
||||||
|
$params = array_merge( array( $run_id ), array_values( $stages ) );
|
||||||
|
if ( null !== $since ) {
|
||||||
|
$sql .= ' AND created_at >= %s';
|
||||||
|
$params[] = $since;
|
||||||
|
}
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared -- $placeholders is a fixed %s list.
|
||||||
|
$updated = $wpdb->query( $wpdb->prepare( $sql, $params ) );
|
||||||
|
return is_numeric( $updated ) ? (int) $updated : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset per-request caches (tests). */
|
||||||
|
public static function flush_cache(): void {
|
||||||
|
self::$tables_ok = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
187
includes/core/class-chief-editor.php
Executable file → Normal file
187
includes/core/class-chief-editor.php
Executable file → Normal file
@@ -2,18 +2,14 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chief Editor agent — LLM-powered editorial review of generated content.
|
* Chief Editor agent — LLM-powered editorial review.
|
||||||
*
|
*
|
||||||
* Reviews articles for quality, accuracy, SEO, and tone before publishing.
|
* Reviews generated articles for quality, accuracy, SEO, tone.
|
||||||
* Can approve, request revisions (and produce them), or reject content.
|
* Approves, requests revisions, or rejects content.
|
||||||
* Rejected content is saved as a draft for human review.
|
* Opik instrumentation: spans per review LLM call.
|
||||||
*
|
*
|
||||||
* Triggered by: PRAutoBlogger::run_generation_pipeline() (step 5).
|
* Triggered by: PRAutoBlogger_Article_Worker.
|
||||||
* Dependencies: LLM_Provider_Interface, Cost_Tracker.
|
* Dependencies: LLM_Provider_Interface, Cost_Tracker, Prompt_Registry.
|
||||||
*
|
|
||||||
* @see core/class-content-generator.php — Produces the content we review.
|
|
||||||
* @see core/class-publisher.php — Publishes approved content.
|
|
||||||
* @see ARCHITECTURE.md — Data flow step 5.
|
|
||||||
*/
|
*/
|
||||||
class PRAutoBlogger_Chief_Editor {
|
class PRAutoBlogger_Chief_Editor {
|
||||||
|
|
||||||
@@ -31,15 +27,8 @@ class PRAutoBlogger_Chief_Editor {
|
|||||||
/**
|
/**
|
||||||
* Review generated content and return an editorial verdict.
|
* Review generated content and return an editorial verdict.
|
||||||
*
|
*
|
||||||
* The editor evaluates: content quality, factual tone, SEO elements,
|
|
||||||
* readability, and alignment with the article idea. It can approve,
|
|
||||||
* revise (and return revised content), or reject.
|
|
||||||
*
|
|
||||||
* Side effects: LLM API call(s), cost logging.
|
|
||||||
*
|
|
||||||
* @param string $content The generated HTML content.
|
* @param string $content The generated HTML content.
|
||||||
* @param PRAutoBlogger_Article_Idea $idea The original article idea.
|
* @param PRAutoBlogger_Article_Idea $idea The original article idea.
|
||||||
*
|
|
||||||
* @return PRAutoBlogger_Editorial_Review
|
* @return PRAutoBlogger_Editorial_Review
|
||||||
*/
|
*/
|
||||||
public function review( string $content, PRAutoBlogger_Article_Idea $idea ): PRAutoBlogger_Editorial_Review {
|
public function review( string $content, PRAutoBlogger_Article_Idea $idea ): PRAutoBlogger_Editorial_Review {
|
||||||
@@ -49,17 +38,45 @@ class PRAutoBlogger_Chief_Editor {
|
|||||||
$system_prompt = $this->build_system_prompt( $niche );
|
$system_prompt = $this->build_system_prompt( $niche );
|
||||||
$user_prompt = $this->build_review_prompt( $content, $idea );
|
$user_prompt = $this->build_review_prompt( $content, $idea );
|
||||||
|
|
||||||
|
$ctx = PRAutoBlogger_Opik_Trace_Context::current();
|
||||||
|
$span_id = $ctx->start_span(
|
||||||
|
array(
|
||||||
|
'name' => 'editorial_review',
|
||||||
|
'type' => 'llm',
|
||||||
|
'model' => $model,
|
||||||
|
'provider' => 'openrouter',
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
$response = $this->llm->send_chat_completion(
|
$response = $this->llm->send_chat_completion(
|
||||||
[
|
array(
|
||||||
[ 'role' => 'system', 'content' => $system_prompt ],
|
array(
|
||||||
[ 'role' => 'user', 'content' => $user_prompt ],
|
'role' => 'system',
|
||||||
],
|
'content' => $system_prompt,
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'role' => 'user',
|
||||||
|
'content' => $user_prompt,
|
||||||
|
),
|
||||||
|
),
|
||||||
$model,
|
$model,
|
||||||
[
|
array(
|
||||||
'temperature' => 0.3,
|
'temperature' => 0.3,
|
||||||
'max_tokens' => 5000,
|
'max_tokens' => 5000,
|
||||||
'response_format' => [ 'type' => 'json_object' ],
|
'response_format' => array( 'type' => 'json_object' ),
|
||||||
]
|
'stage' => 'review',
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$ctx->end_span(
|
||||||
|
$span_id,
|
||||||
|
array(
|
||||||
|
'usage' => array(
|
||||||
|
'prompt_tokens' => $response['prompt_tokens'],
|
||||||
|
'completion_tokens' => $response['completion_tokens'],
|
||||||
|
'total_tokens' => $response['prompt_tokens'] + $response['completion_tokens'],
|
||||||
|
),
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->cost_tracker->log_api_call(
|
$this->cost_tracker->log_api_call(
|
||||||
@@ -75,101 +92,87 @@ class PRAutoBlogger_Chief_Editor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param string $niche
|
* Build system prompt for editorial review.
|
||||||
*
|
*
|
||||||
* @return string
|
* v0.18.0: renders from the versioned registry ('editor.system') with a
|
||||||
|
* byte-identical in-code fallback; the admin's extra editor
|
||||||
|
* instructions stay a setting and are injected as a token value.
|
||||||
|
*
|
||||||
|
* @param string $niche Niche description from settings.
|
||||||
|
* @return string System prompt text.
|
||||||
*/
|
*/
|
||||||
private function build_system_prompt( string $niche ): string {
|
private function build_system_prompt( string $niche ): string {
|
||||||
$prompt = "You are a senior blog editor";
|
$instructions = trim( (string) get_option( 'prautoblogger_editor_instructions', '' ) );
|
||||||
if ( '' !== $niche ) {
|
$instructions_block = '' !== $instructions ? "\nAdditional instructions:\n" . $instructions . "\n" : '';
|
||||||
$prompt .= " specializing in {$niche} content";
|
|
||||||
}
|
|
||||||
$prompt .= ". Your job is to review article drafts before publication.\n\n";
|
|
||||||
|
|
||||||
$prompt .= "Evaluate the article on:\n";
|
return PRAutoBlogger_Prompt_Registry::render(
|
||||||
$prompt .= "1. QUALITY: Is the writing clear, engaging, and well-structured?\n";
|
'editor.system',
|
||||||
$prompt .= "2. ACCURACY: Does the content avoid making unsupported health/medical claims? Is the tone appropriately cautious for the topic?\n";
|
array(
|
||||||
$prompt .= "3. SEO: Are headings properly structured? Are target keywords naturally used?\n";
|
'niche_clause' => '' !== $niche ? " specializing in {$niche} content" : '',
|
||||||
$prompt .= "4. COMPLETENESS: Does it cover the key points? Does it have a proper intro and conclusion?\n";
|
'instructions_block' => $instructions_block,
|
||||||
$prompt .= "5. READABILITY: Is sentence variety good? Are paragraphs appropriate length?\n\n";
|
)
|
||||||
|
|
||||||
$prompt .= "You must respond with JSON in this format:\n";
|
|
||||||
$prompt .= "{\n";
|
|
||||||
$prompt .= ' "verdict": "approved" | "revised" | "rejected",' . "\n";
|
|
||||||
$prompt .= ' "quality_score": 0.0-1.0,' . "\n";
|
|
||||||
$prompt .= ' "seo_score": 0.0-1.0,' . "\n";
|
|
||||||
$prompt .= ' "issues": ["issue 1", "issue 2"],' . "\n";
|
|
||||||
$prompt .= ' "notes": "Brief editorial notes",' . "\n";
|
|
||||||
$prompt .= ' "revised_content": "Full revised HTML if verdict is revised, null otherwise"' . "\n";
|
|
||||||
$prompt .= "}\n\n";
|
|
||||||
|
|
||||||
$prompt .= "Rules:\n";
|
|
||||||
$prompt .= "- APPROVE if quality_score >= 0.7 and seo_score >= 0.6 and no critical issues\n";
|
|
||||||
$prompt .= "- REVISE if fixable issues exist — you MUST provide the full revised HTML\n";
|
|
||||||
$prompt .= "- REJECT only if the content is fundamentally flawed or off-topic\n";
|
|
||||||
$prompt .= "- When revising, fix the issues yourself. Don't just describe them.\n";
|
|
||||||
|
|
||||||
return $prompt;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param string $content
|
|
||||||
* @param PRAutoBlogger_Article_Idea $idea
|
|
||||||
*
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
private function build_review_prompt( string $content, PRAutoBlogger_Article_Idea $idea ): string {
|
|
||||||
return sprintf(
|
|
||||||
"Review this article draft:\n\n" .
|
|
||||||
"ORIGINAL BRIEF:\n" .
|
|
||||||
"- Title: %s\n" .
|
|
||||||
"- Topic: %s\n" .
|
|
||||||
"- Type: %s\n" .
|
|
||||||
"- Key points: %s\n" .
|
|
||||||
"- Target keywords: %s\n\n" .
|
|
||||||
"ARTICLE CONTENT:\n%s",
|
|
||||||
$idea->get_suggested_title(),
|
|
||||||
$idea->get_topic(),
|
|
||||||
$idea->get_article_type(),
|
|
||||||
implode( ', ', $idea->get_key_points() ),
|
|
||||||
implode( ', ', $idea->get_target_keywords() ),
|
|
||||||
$content
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse the LLM's editorial review response.
|
* Build user prompt for editorial review.
|
||||||
*
|
*
|
||||||
* @param string $content JSON response from LLM.
|
* v0.18.0: renders from the versioned registry ('editor.review').
|
||||||
*
|
*
|
||||||
* @return PRAutoBlogger_Editorial_Review
|
* @param string $content The generated HTML content.
|
||||||
|
* @param PRAutoBlogger_Article_Idea $idea The original article idea.
|
||||||
|
* @return string User prompt text.
|
||||||
|
*/
|
||||||
|
private function build_review_prompt( string $content, PRAutoBlogger_Article_Idea $idea ): string {
|
||||||
|
return PRAutoBlogger_Prompt_Registry::render(
|
||||||
|
'editor.review',
|
||||||
|
array(
|
||||||
|
'title' => $idea->get_suggested_title(),
|
||||||
|
'topic' => $idea->get_topic(),
|
||||||
|
'article_type' => $idea->get_article_type(),
|
||||||
|
'key_points' => implode( ', ', $idea->get_key_points() ),
|
||||||
|
'keywords' => implode( ', ', $idea->get_target_keywords() ),
|
||||||
|
'content' => $content,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse LLM editorial review response.
|
||||||
*/
|
*/
|
||||||
private function parse_review_response( string $content ): PRAutoBlogger_Editorial_Review {
|
private function parse_review_response( string $content ): PRAutoBlogger_Editorial_Review {
|
||||||
$data = json_decode( $content, true );
|
$data = PRAutoBlogger_Json_Extractor::decode( $content );
|
||||||
|
|
||||||
if ( ! is_array( $data ) || ! isset( $data['verdict'] ) ) {
|
if ( ! is_array( $data ) || ! isset( $data['verdict'] ) ) {
|
||||||
PRAutoBlogger_Logger::instance()->error( 'Chief editor response was not valid JSON. Defaulting to rejected.', 'editor' );
|
PRAutoBlogger_Logger::instance()->error(
|
||||||
return new PRAutoBlogger_Editorial_Review( [
|
'Chief editor response unparseable (first 200 chars): ' . mb_substr( $content, 0, 200 ),
|
||||||
|
'editor'
|
||||||
|
);
|
||||||
|
return new PRAutoBlogger_Editorial_Review(
|
||||||
|
array(
|
||||||
'verdict' => 'rejected',
|
'verdict' => 'rejected',
|
||||||
'notes' => 'Editor response could not be parsed.',
|
'notes' => 'Editor response unparseable',
|
||||||
'quality_score' => 0.0,
|
'quality_score' => 0.0,
|
||||||
'seo_score' => 0.0,
|
'seo_score' => 0.0,
|
||||||
'issues' => [ 'Unparseable editor response' ],
|
'issues' => array( 'Unparseable editor response' ),
|
||||||
] );
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$verdict = sanitize_text_field( $data['verdict'] ?? 'rejected' );
|
$verdict = sanitize_text_field( $data['verdict'] ?? 'rejected' );
|
||||||
if ( ! in_array( $verdict, [ 'approved', 'revised', 'rejected' ], true ) ) {
|
if ( ! in_array( $verdict, array( 'approved', 'revised', 'rejected' ), true ) ) {
|
||||||
$verdict = 'rejected';
|
$verdict = 'rejected';
|
||||||
}
|
}
|
||||||
|
|
||||||
return new PRAutoBlogger_Editorial_Review( [
|
return new PRAutoBlogger_Editorial_Review(
|
||||||
|
array(
|
||||||
'verdict' => $verdict,
|
'verdict' => $verdict,
|
||||||
'notes' => sanitize_textarea_field( $data['notes'] ?? '' ),
|
'notes' => sanitize_textarea_field( $data['notes'] ?? '' ),
|
||||||
'revised_content' => isset( $data['revised_content'] ) ? wp_kses_post( $data['revised_content'] ) : null,
|
'revised_content' => isset( $data['revised_content'] ) ? wp_kses_post( $data['revised_content'] ) : null,
|
||||||
'quality_score' => (float) ( $data['quality_score'] ?? 0.0 ),
|
'quality_score' => (float) ( $data['quality_score'] ?? 0.0 ),
|
||||||
'seo_score' => (float) ( $data['seo_score'] ?? 0.0 ),
|
'seo_score' => (float) ( $data['seo_score'] ?? 0.0 ),
|
||||||
'issues' => array_map( 'sanitize_text_field', $data['issues'] ?? [] ),
|
'issues' => array_map( 'sanitize_text_field', $data['issues'] ?? array() ),
|
||||||
] );
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,22 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
* Analyzes collected source data using an LLM to detect recurring patterns:
|
* Analyzes collected source data using an LLM to detect recurring patterns:
|
||||||
* questions people ask, complaints/pain points, and product comparisons.
|
* questions people ask, complaints/pain points, and product comparisons.
|
||||||
*
|
*
|
||||||
* Uses a cheap model (configurable) to keep analysis costs low.
|
* Uses a cheap model (configurable) to keep analysis costs low.
|
||||||
* Batches source data into manageable chunks for the LLM context window.
|
* Batches source data into manageable chunks for the LLM context window.
|
||||||
|
* Prompt construction is delegated to PRAutoBlogger_Analysis_Prompts.
|
||||||
*
|
*
|
||||||
* Triggered by: PRAutoBlogger::run_generation_pipeline() (step 2).
|
* Triggered by: PRAutoBlogger::run_generation_pipeline() (step 2).
|
||||||
* Dependencies: LLM_Provider_Interface, Cost_Tracker, WordPress $wpdb.
|
* Dependencies: LLM_Provider_Interface, Cost_Tracker, PRAutoBlogger_Analysis_Prompts, WordPress $wpdb.
|
||||||
*
|
*
|
||||||
|
* @see core/class-analysis-prompts.php — System/user prompt builders + performance context.
|
||||||
* @see providers/interface-llm-provider.php — LLM used for analysis.
|
* @see providers/interface-llm-provider.php — LLM used for analysis.
|
||||||
* @see core/class-cost-tracker.php — Logs API costs.
|
* @see core/class-cost-tracker.php — Logs API costs.
|
||||||
|
* @see core/class-json-extractor.php — Tolerant JSON parsing for LLM output.
|
||||||
* @see core/class-idea-scorer.php — Consumes analysis results.
|
* @see core/class-idea-scorer.php — Consumes analysis results.
|
||||||
* @see ARCHITECTURE.md — Data flow step 2.
|
* @see ARCHITECTURE.md — Data flow step 2.
|
||||||
*/
|
*/
|
||||||
@@ -37,46 +42,46 @@ class PRAutoBlogger_Content_Analyzer {
|
|||||||
*
|
*
|
||||||
* Side effects: LLM API call, database reads/writes.
|
* Side effects: LLM API call, database reads/writes.
|
||||||
*
|
*
|
||||||
* @return PRAutoBlogger_Analysis_Result[] Detected patterns.
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* @param int $target_idea_count How many distinct topic ideas the LLM should produce.
|
* @param int $target_idea_count How many distinct topic ideas the LLM should produce.
|
||||||
* Defaults to 6 so the scorer has enough to pick from.
|
* Defaults to 6 so the scorer has enough to pick from.
|
||||||
|
* @return PRAutoBlogger_Analysis_Result[] Detected patterns.
|
||||||
*/
|
*/
|
||||||
public function analyze_recent_data( int $target_idea_count = 6 ): array {
|
public function analyze_recent_data( int $target_idea_count = 6 ): array {
|
||||||
$source_items = $this->fetch_recent_source_data();
|
$source_items = $this->fetch_recent_source_data();
|
||||||
|
|
||||||
if ( empty( $source_items ) ) {
|
if ( empty( $source_items ) ) {
|
||||||
PRAutoBlogger_Logger::instance()->info( 'No new source data to analyze.', 'analyzer' );
|
PRAutoBlogger_Logger::instance()->info( 'No new source data to analyze.', 'analyzer' );
|
||||||
return [];
|
return array();
|
||||||
}
|
}
|
||||||
|
|
||||||
$niche = get_option( 'prautoblogger_niche_description', '' );
|
$niche = get_option( 'prautoblogger_niche_description', '' );
|
||||||
$model = get_option( 'prautoblogger_analysis_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL );
|
$model = get_option( 'prautoblogger_analysis_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL );
|
||||||
|
|
||||||
// Build the source data summary for the LLM (trim to avoid huge contexts).
|
|
||||||
$summary = $this->build_source_summary( $source_items );
|
$summary = $this->build_source_summary( $source_items );
|
||||||
|
$performance_context = PRAutoBlogger_Analysis_Prompts::get_performance_context();
|
||||||
// Include past performance data for self-improvement.
|
$system_prompt = PRAutoBlogger_Analysis_Prompts::build_system_prompt( $niche, $performance_context, $target_idea_count );
|
||||||
$performance_context = $this->get_performance_context();
|
$user_prompt = PRAutoBlogger_Analysis_Prompts::build_user_prompt( $summary, $target_idea_count );
|
||||||
|
|
||||||
$system_prompt = $this->build_system_prompt( $niche, $performance_context, $target_idea_count );
|
|
||||||
$user_prompt = $this->build_user_prompt( $summary, $target_idea_count );
|
|
||||||
|
|
||||||
$response = $this->llm->send_chat_completion(
|
$response = $this->llm->send_chat_completion(
|
||||||
[
|
array(
|
||||||
[ 'role' => 'system', 'content' => $system_prompt ],
|
array(
|
||||||
[ 'role' => 'user', 'content' => $user_prompt ],
|
'role' => 'system',
|
||||||
],
|
'content' => $system_prompt,
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'role' => 'user',
|
||||||
|
'content' => $user_prompt,
|
||||||
|
),
|
||||||
|
),
|
||||||
$model,
|
$model,
|
||||||
[
|
array(
|
||||||
'temperature' => 0.5,
|
'temperature' => 0.5,
|
||||||
'max_tokens' => 8000,
|
'max_tokens' => 8000,
|
||||||
'response_format' => [ 'type' => 'json_object' ],
|
'response_format' => array( 'type' => 'json_object' ),
|
||||||
]
|
'stage' => 'analysis',
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Log the cost.
|
|
||||||
$this->cost_tracker->log_api_call(
|
$this->cost_tracker->log_api_call(
|
||||||
null,
|
null,
|
||||||
'analysis',
|
'analysis',
|
||||||
@@ -86,15 +91,38 @@ class PRAutoBlogger_Content_Analyzer {
|
|||||||
$response['completion_tokens']
|
$response['completion_tokens']
|
||||||
);
|
);
|
||||||
|
|
||||||
// Parse LLM response into Analysis_Result objects.
|
|
||||||
$results = $this->parse_analysis_response( $response['content'], $source_items );
|
$results = $this->parse_analysis_response( $response['content'], $source_items );
|
||||||
|
|
||||||
// Store in database.
|
|
||||||
$this->store_results( $results );
|
$this->store_results( $results );
|
||||||
|
|
||||||
return $results;
|
return $results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Backward-compatible proxies (prompt building moved to Analysis_Prompts) ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use PRAutoBlogger_Analysis_Prompts::build_system_prompt() directly.
|
||||||
|
* @see core/class-analysis-prompts.php
|
||||||
|
*/
|
||||||
|
private function build_system_prompt( string $niche, string $performance_context, int $target_count = 6 ): string {
|
||||||
|
return PRAutoBlogger_Analysis_Prompts::build_system_prompt( $niche, $performance_context, $target_count );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use PRAutoBlogger_Analysis_Prompts::build_user_prompt() directly.
|
||||||
|
* @see core/class-analysis-prompts.php
|
||||||
|
*/
|
||||||
|
private function build_user_prompt( string $summary, int $target_count = 6 ): string {
|
||||||
|
return PRAutoBlogger_Analysis_Prompts::build_user_prompt( $summary, $target_count );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use PRAutoBlogger_Analysis_Prompts::get_performance_context() directly.
|
||||||
|
* @see core/class-analysis-prompts.php
|
||||||
|
*/
|
||||||
|
private function get_performance_context(): string {
|
||||||
|
return PRAutoBlogger_Analysis_Prompts::get_performance_context();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch source data collected in the last 24 hours.
|
* Fetch source data collected in the last 24 hours.
|
||||||
*
|
*
|
||||||
@@ -106,12 +134,12 @@ class PRAutoBlogger_Content_Analyzer {
|
|||||||
|
|
||||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
return $wpdb->get_results(
|
return $wpdb->get_results(
|
||||||
$wpdb->prepare(
|
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
"SELECT * FROM {$table} WHERE collected_at >= %s ORDER BY score DESC LIMIT 200",
|
"SELECT * FROM {$table} WHERE collected_at >= %s ORDER BY score DESC LIMIT 200",
|
||||||
gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS )
|
gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS )
|
||||||
),
|
),
|
||||||
ARRAY_A
|
ARRAY_A
|
||||||
) ?: [];
|
) ?? array();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -120,21 +148,22 @@ class PRAutoBlogger_Content_Analyzer {
|
|||||||
* Prioritizes high-engagement content and keeps total text under ~8000 tokens.
|
* Prioritizes high-engagement content and keeps total text under ~8000 tokens.
|
||||||
*
|
*
|
||||||
* @param array<int, array<string, mixed>> $items Source data rows.
|
* @param array<int, array<string, mixed>> $items Source data rows.
|
||||||
*
|
|
||||||
* @return string Formatted text summary.
|
* @return string Formatted text summary.
|
||||||
*/
|
*/
|
||||||
private function build_source_summary( array $items ): string {
|
private function build_source_summary( array $items ): string {
|
||||||
$parts = [];
|
$parts = array();
|
||||||
$char_limit = 30000; // Roughly ~7500 tokens.
|
$char_limit = 30000; // Roughly ~7500 tokens.
|
||||||
$current_chars = 0;
|
$current_chars = 0;
|
||||||
|
|
||||||
foreach ( $items as $item ) {
|
foreach ( $items as $item ) {
|
||||||
|
// LLM research findings have no subreddit; use a cleaner label.
|
||||||
|
$source_label = 'llm_research' === $item['source_type']
|
||||||
|
? sprintf( '[LLM Research] Relevance: %d', (int) $item['score'] )
|
||||||
|
: sprintf( '[%s] r/%s | Score: %d | Comments: %d', $item['source_type'], $item['subreddit'] ?? 'unknown', (int) $item['score'], (int) $item['comment_count'] );
|
||||||
|
|
||||||
$entry = sprintf(
|
$entry = sprintf(
|
||||||
"[%s] r/%s | Score: %d | Comments: %d\nTitle: %s\n%s\n---",
|
"%s\nTitle: %s\n%s\n---",
|
||||||
$item['source_type'],
|
$source_label,
|
||||||
$item['subreddit'] ?? 'unknown',
|
|
||||||
(int) $item['score'],
|
|
||||||
(int) $item['comment_count'],
|
|
||||||
$item['title'] ?? '(comment)',
|
$item['title'] ?? '(comment)',
|
||||||
mb_substr( $item['content'] ?? '', 0, 500 )
|
mb_substr( $item['content'] ?? '', 0, 500 )
|
||||||
);
|
);
|
||||||
@@ -150,123 +179,30 @@ class PRAutoBlogger_Content_Analyzer {
|
|||||||
return implode( "\n", $parts );
|
return implode( "\n", $parts );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get past content performance data to feed into analysis for self-improvement.
|
|
||||||
*
|
|
||||||
* @return string Summary of what topics performed well/poorly.
|
|
||||||
*/
|
|
||||||
private function get_performance_context(): string {
|
|
||||||
global $wpdb;
|
|
||||||
$scores_table = $wpdb->prefix . 'prautoblogger_content_scores';
|
|
||||||
|
|
||||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
|
||||||
$top_posts = $wpdb->get_results(
|
|
||||||
"SELECT cs.post_id, cs.composite_score, p.post_title
|
|
||||||
FROM {$scores_table} cs
|
|
||||||
JOIN {$wpdb->posts} p ON p.ID = cs.post_id
|
|
||||||
WHERE cs.composite_score > 0
|
|
||||||
ORDER BY cs.composite_score DESC
|
|
||||||
LIMIT 5",
|
|
||||||
ARRAY_A
|
|
||||||
);
|
|
||||||
|
|
||||||
if ( empty( $top_posts ) ) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$lines = [ 'Top performing past articles (learn from these):' ];
|
|
||||||
foreach ( $top_posts as $post ) {
|
|
||||||
$lines[] = sprintf(
|
|
||||||
'- "%s" (score: %.1f)',
|
|
||||||
$post['post_title'],
|
|
||||||
(float) $post['composite_score']
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return implode( "\n", $lines );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the system prompt for content analysis.
|
|
||||||
*
|
|
||||||
* @param string $niche Niche description from settings.
|
|
||||||
* @param string $performance_context Past performance data.
|
|
||||||
*
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
private function build_system_prompt( string $niche, string $performance_context, int $target_count = 6 ): string {
|
|
||||||
$prompt = "You are a content strategist analyzing social media discussions to find article ideas for a blog";
|
|
||||||
if ( '' !== $niche ) {
|
|
||||||
$prompt .= " in the {$niche} niche";
|
|
||||||
}
|
|
||||||
$prompt .= ".\n\n";
|
|
||||||
|
|
||||||
$prompt .= "IMPORTANT: You must identify exactly {$target_count} DISTINCT article ideas. ";
|
|
||||||
$prompt .= "Each idea must cover a substantially different topic — no two ideas should overlap ";
|
|
||||||
$prompt .= "in their main subject. Aim for diversity across these categories:\n";
|
|
||||||
$prompt .= "1. QUESTIONS: Recurring questions people ask (\"How do I...\", \"What is...\", \"Is it safe to...\")\n";
|
|
||||||
$prompt .= "2. COMPLAINTS: Pain points, frustrations, or problems people report\n";
|
|
||||||
$prompt .= "3. COMPARISONS: Product/method comparisons people discuss (\"X vs Y\", \"Which is better\")\n";
|
|
||||||
$prompt .= "4. NEWS: Recent developments, rule changes, or trends people are discussing\n";
|
|
||||||
$prompt .= "5. GUIDES: How-to topics, best practices, or educational content people need\n\n";
|
|
||||||
|
|
||||||
$prompt .= "Spread your {$target_count} ideas across multiple categories. ";
|
|
||||||
$prompt .= "Be specific — \"peptide dosing for BPC-157\" is better than \"peptide information\". ";
|
|
||||||
$prompt .= "Each idea should be narrow enough to be a single focused blog post.\n\n";
|
|
||||||
|
|
||||||
$prompt .= "For each idea, provide:\n";
|
|
||||||
$prompt .= "- type: 'question', 'complaint', 'comparison', 'news', or 'guide'\n";
|
|
||||||
$prompt .= "- topic: A clear, specific, narrow topic description\n";
|
|
||||||
$prompt .= "- summary: 1-2 sentence summary of why this topic matters\n";
|
|
||||||
$prompt .= "- frequency: How many of the provided posts relate to this topic\n";
|
|
||||||
$prompt .= "- relevance_score: 0.0 to 1.0 indicating how relevant and article-worthy this is\n";
|
|
||||||
$prompt .= "- suggested_title: A compelling, unique blog post title\n";
|
|
||||||
$prompt .= "- key_points: Array of 3-5 key points the article should cover\n";
|
|
||||||
$prompt .= "- target_keywords: Array of SEO keywords for this topic\n\n";
|
|
||||||
|
|
||||||
if ( '' !== $performance_context ) {
|
|
||||||
$prompt .= $performance_context . "\n\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
$prompt .= "Respond with valid JSON containing exactly {$target_count} patterns:\n";
|
|
||||||
$prompt .= '{"patterns": [{"type": "...", "topic": "...", "summary": "...", "frequency": N, "relevance_score": 0.X, "suggested_title": "...", "key_points": [...], "target_keywords": [...]}]}';
|
|
||||||
|
|
||||||
return $prompt;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the user prompt containing the actual source data.
|
|
||||||
*
|
|
||||||
* @param string $summary Formatted source data summary.
|
|
||||||
*
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
private function build_user_prompt( string $summary, int $target_count = 6 ): string {
|
|
||||||
return "Here are the recent social media posts and comments to analyze. "
|
|
||||||
. "Find {$target_count} distinct, diverse article ideas from this data:\n\n"
|
|
||||||
. $summary;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse the LLM's JSON response into Analysis_Result objects.
|
* Parse the LLM's JSON response into Analysis_Result objects.
|
||||||
*
|
*
|
||||||
* @param string $content LLM response content (JSON).
|
* @param string $content LLM response content (JSON).
|
||||||
* @param array<int, array<string, mixed>> $source_items Original source data for ID mapping.
|
* @param array<int, array<string, mixed>> $source_items Original source data for ID mapping.
|
||||||
*
|
|
||||||
* @return PRAutoBlogger_Analysis_Result[]
|
* @return PRAutoBlogger_Analysis_Result[]
|
||||||
*/
|
*/
|
||||||
private function parse_analysis_response( string $content, array $source_items ): array {
|
private function parse_analysis_response( string $content, array $source_items ): array {
|
||||||
$data = json_decode( $content, true );
|
$data = PRAutoBlogger_Json_Extractor::decode( $content );
|
||||||
if ( ! isset( $data['patterns'] ) || ! is_array( $data['patterns'] ) ) {
|
if ( ! isset( $data['patterns'] ) || ! is_array( $data['patterns'] ) ) {
|
||||||
PRAutoBlogger_Logger::instance()->error( 'Analysis response was not valid JSON or missing patterns key.', 'analyzer' );
|
PRAutoBlogger_Logger::instance()->error(
|
||||||
return [];
|
'Analysis response was not valid JSON or missing patterns key. Raw (first 500 chars): '
|
||||||
|
. mb_substr( $content, 0, 500 ),
|
||||||
|
'analyzer'
|
||||||
|
);
|
||||||
|
return array();
|
||||||
}
|
}
|
||||||
|
|
||||||
$source_ids = array_column( $source_items, 'id' );
|
$source_ids = array_column( $source_items, 'id' );
|
||||||
$results = [];
|
$results = array();
|
||||||
|
|
||||||
foreach ( $data['patterns'] as $pattern ) {
|
foreach ( $data['patterns'] as $pattern ) {
|
||||||
$results[] = new PRAutoBlogger_Analysis_Result( [
|
$results[] = new PRAutoBlogger_Analysis_Result(
|
||||||
|
array(
|
||||||
'analysis_type' => sanitize_text_field( $pattern['type'] ?? 'question' ),
|
'analysis_type' => sanitize_text_field( $pattern['type'] ?? 'question' ),
|
||||||
'topic' => sanitize_text_field( $pattern['topic'] ?? '' ),
|
'topic' => sanitize_text_field( $pattern['topic'] ?? '' ),
|
||||||
'summary' => sanitize_textarea_field( $pattern['summary'] ?? '' ),
|
'summary' => sanitize_textarea_field( $pattern['summary'] ?? '' ),
|
||||||
@@ -274,12 +210,13 @@ class PRAutoBlogger_Content_Analyzer {
|
|||||||
'relevance_score' => (float) ( $pattern['relevance_score'] ?? 0.0 ),
|
'relevance_score' => (float) ( $pattern['relevance_score'] ?? 0.0 ),
|
||||||
'source_ids' => $source_ids,
|
'source_ids' => $source_ids,
|
||||||
'analyzed_at' => current_time( 'mysql' ),
|
'analyzed_at' => current_time( 'mysql' ),
|
||||||
'metadata' => [
|
'metadata' => array(
|
||||||
'suggested_title' => $pattern['suggested_title'] ?? '',
|
'suggested_title' => $pattern['suggested_title'] ?? '',
|
||||||
'key_points' => $pattern['key_points'] ?? [],
|
'key_points' => $pattern['key_points'] ?? array(),
|
||||||
'target_keywords' => $pattern['target_keywords'] ?? [],
|
'target_keywords' => $pattern['target_keywords'] ?? array(),
|
||||||
],
|
),
|
||||||
] );
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $results;
|
return $results;
|
||||||
@@ -291,8 +228,6 @@ class PRAutoBlogger_Content_Analyzer {
|
|||||||
* Side effects: database inserts.
|
* Side effects: database inserts.
|
||||||
*
|
*
|
||||||
* @param PRAutoBlogger_Analysis_Result[] $results
|
* @param PRAutoBlogger_Analysis_Result[] $results
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
private function store_results( array $results ): void {
|
private function store_results( array $results ): void {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
|
|||||||
417
includes/core/class-content-generator.php
Executable file → Normal file
417
includes/core/class-content-generator.php
Executable file → Normal file
@@ -4,22 +4,27 @@ declare(strict_types=1);
|
|||||||
/**
|
/**
|
||||||
* Writer agent — generates blog post content from article ideas.
|
* Writer agent — generates blog post content from article ideas.
|
||||||
*
|
*
|
||||||
* Supports two configurable pipeline modes:
|
* Supports: single_pass (one LLM call) or multi_step (outline → draft → polish).
|
||||||
* - single_pass: One LLM call produces the complete article (cheaper, faster).
|
* Opik instrumentation: spans per LLM call when feature-flag enabled.
|
||||||
* - multi_step: Outline → Draft → Polish pipeline (higher quality, more cost).
|
* v0.18.0: prompt copy renders from the versioned prompt registry (with
|
||||||
|
* byte-identical in-code fallback); every log row carries the run-pinned
|
||||||
|
* prompt_version. The four stage methods share one execute_stage() helper —
|
||||||
|
* span lifecycle, LLM dispatch, and cost logging are identical across stages.
|
||||||
*
|
*
|
||||||
* Triggered by: PRAutoBlogger::run_generation_pipeline() (step 4).
|
* Triggered by: PRAutoBlogger_Article_Worker.
|
||||||
* Dependencies: LLM_Provider_Interface, Cost_Tracker.
|
* Dependencies: LLM_Provider_Interface, Cost_Tracker, Content_Prompts.
|
||||||
*
|
|
||||||
* @see core/class-chief-editor.php — Reviews output of this class.
|
|
||||||
* @see providers/interface-llm-provider.php — LLM used for generation.
|
|
||||||
* @see ARCHITECTURE.md — Data flow step 4.
|
|
||||||
*/
|
*/
|
||||||
class PRAutoBlogger_Content_Generator {
|
class PRAutoBlogger_Content_Generator {
|
||||||
|
|
||||||
private PRAutoBlogger_LLM_Provider_Interface $llm;
|
private PRAutoBlogger_LLM_Provider_Interface $llm;
|
||||||
private PRAutoBlogger_Cost_Tracker $cost_tracker;
|
private PRAutoBlogger_Cost_Tracker $cost_tracker;
|
||||||
|
|
||||||
|
/** @var string|null Run UUID for stage-state checkpointing (null = off). */
|
||||||
|
private ?string $run_id = null;
|
||||||
|
|
||||||
|
/** @var string|null Stage item key scoping this article within the run. */
|
||||||
|
private ?string $item_key = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
PRAutoBlogger_LLM_Provider_Interface $llm,
|
PRAutoBlogger_LLM_Provider_Interface $llm,
|
||||||
PRAutoBlogger_Cost_Tracker $cost_tracker
|
PRAutoBlogger_Cost_Tracker $cost_tracker
|
||||||
@@ -28,20 +33,36 @@ class PRAutoBlogger_Content_Generator {
|
|||||||
$this->cost_tracker = $cost_tracker;
|
$this->cost_tracker = $cost_tracker;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable per-stage state checkpointing for one article item (v0.18.0).
|
||||||
|
*
|
||||||
|
* When set, every LLM stage (outline/draft/polish or the single-pass
|
||||||
|
* draft) records pending->running->done state and snapshots its output;
|
||||||
|
* a re-entered done stage returns the snapshot WITHOUT a new LLM call
|
||||||
|
* (never re-charged). When unset (default), behavior is exactly
|
||||||
|
* pre-v0.18.0 — used by callers outside a run (e.g. eval mode).
|
||||||
|
*
|
||||||
|
* @param string|null $run_id Run UUID, or null to disable.
|
||||||
|
* @param string|null $item_key Item key from Run_Stage_State::item_key_for_idea().
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function set_run_item( ?string $run_id, ?string $item_key ): self {
|
||||||
|
$this->run_id = $run_id;
|
||||||
|
$this->item_key = $item_key;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a blog post from an article idea.
|
* Generate a blog post from an article idea.
|
||||||
*
|
*
|
||||||
* Reads pipeline mode from settings and dispatches to the appropriate method.
|
|
||||||
*
|
|
||||||
* Side effects: LLM API calls (1-3 depending on mode), cost logging.
|
|
||||||
*
|
|
||||||
* @param PRAutoBlogger_Article_Idea $idea The scored idea to generate content for.
|
* @param PRAutoBlogger_Article_Idea $idea The scored idea to generate content for.
|
||||||
*
|
* @param bool $eval_mode True to suppress publish/image side effects.
|
||||||
* @return string Generated HTML content.
|
* @return string Generated HTML content.
|
||||||
*
|
|
||||||
* @throws \RuntimeException On generation failure.
|
|
||||||
*/
|
*/
|
||||||
public function generate( PRAutoBlogger_Article_Idea $idea ): string {
|
public function generate( PRAutoBlogger_Article_Idea $idea, bool $eval_mode = false ): string {
|
||||||
|
// In eval mode, suppress publishing and image generation side effects.
|
||||||
|
define( 'PRAUTOBLOGGER_EVAL_MODE', $eval_mode );
|
||||||
|
|
||||||
$mode = get_option( 'prautoblogger_writing_pipeline', 'multi_step' );
|
$mode = get_option( 'prautoblogger_writing_pipeline', 'multi_step' );
|
||||||
|
|
||||||
$request = new PRAutoBlogger_Content_Request(
|
$request = new PRAutoBlogger_Content_Request(
|
||||||
@@ -51,247 +72,223 @@ class PRAutoBlogger_Content_Generator {
|
|||||||
absint( get_option( 'prautoblogger_min_word_count', 800 ) ),
|
absint( get_option( 'prautoblogger_min_word_count', 800 ) ),
|
||||||
absint( get_option( 'prautoblogger_max_word_count', 2000 ) ),
|
absint( get_option( 'prautoblogger_max_word_count', 2000 ) ),
|
||||||
get_option( 'prautoblogger_niche_description', '' ),
|
get_option( 'prautoblogger_niche_description', '' ),
|
||||||
json_decode( get_option( 'prautoblogger_topic_exclusions', '[]' ), true ) ?: []
|
json_decode( get_option( 'prautoblogger_topic_exclusions', '[]' ), true ) ?? array(),
|
||||||
|
get_option( 'prautoblogger_writing_instructions', '' )
|
||||||
);
|
);
|
||||||
|
|
||||||
if ( 'single_pass' === $mode ) {
|
return 'single_pass' === $mode
|
||||||
return $this->generate_single_pass( $request );
|
? $this->generate_single_pass( $request )
|
||||||
}
|
: $this->generate_multi_step( $request );
|
||||||
|
|
||||||
return $this->generate_multi_step( $request );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single-pass generation: one LLM call produces the complete article.
|
* Single-pass: one LLM call produces complete article (Economy tier).
|
||||||
*
|
*
|
||||||
* @param PRAutoBlogger_Content_Request $request
|
* The log row's stage stays 'draft' (historical contract: a draft row
|
||||||
|
* without an outline row marks a single-pass run) but the prompt key
|
||||||
|
* is passed explicitly — the stage map would resolve 'draft' to
|
||||||
|
* 'content.draft'.
|
||||||
*
|
*
|
||||||
|
* @param PRAutoBlogger_Content_Request $request Generation request.
|
||||||
* @return string Generated HTML content.
|
* @return string Generated HTML content.
|
||||||
*/
|
*/
|
||||||
private function generate_single_pass( PRAutoBlogger_Content_Request $request ): string {
|
private function generate_single_pass( PRAutoBlogger_Content_Request $request ): string {
|
||||||
$model = get_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
|
$model = get_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
|
||||||
$idea = $request->get_idea();
|
|
||||||
|
|
||||||
$system = $this->build_writer_system_prompt( $request );
|
return $this->execute_stage(
|
||||||
$user = $this->build_single_pass_prompt( $request );
|
'draft',
|
||||||
|
'single_pass_generation',
|
||||||
$response = $this->llm->send_chat_completion(
|
PRAutoBlogger_Content_Prompts::build_single_pass( $request ),
|
||||||
[
|
$request,
|
||||||
[ 'role' => 'system', 'content' => $system ],
|
|
||||||
[ 'role' => 'user', 'content' => $user ],
|
|
||||||
],
|
|
||||||
$model,
|
$model,
|
||||||
[
|
array(
|
||||||
'temperature' => 0.7,
|
'temperature' => 0.7,
|
||||||
'max_tokens' => 4000,
|
'max_tokens' => 4000,
|
||||||
]
|
),
|
||||||
|
'content.single_pass'
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->cost_tracker->log_api_call(
|
|
||||||
null,
|
|
||||||
'draft',
|
|
||||||
$this->llm->get_provider_name(),
|
|
||||||
$response['model'],
|
|
||||||
$response['prompt_tokens'],
|
|
||||||
$response['completion_tokens']
|
|
||||||
);
|
|
||||||
|
|
||||||
return $response['content'];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Multi-step generation: Outline → Draft → Polish.
|
* Multi-step: outline → draft → polish.
|
||||||
*
|
|
||||||
* @param PRAutoBlogger_Content_Request $request
|
|
||||||
*
|
*
|
||||||
|
* @param PRAutoBlogger_Content_Request $request Generation request.
|
||||||
* @return string Generated HTML content.
|
* @return string Generated HTML content.
|
||||||
*/
|
*/
|
||||||
private function generate_multi_step( PRAutoBlogger_Content_Request $request ): string {
|
private function generate_multi_step( PRAutoBlogger_Content_Request $request ): string {
|
||||||
$model = get_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
|
$model = get_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
|
||||||
|
|
||||||
// Step 1: Generate outline.
|
$outline = $this->execute_stage(
|
||||||
$outline = $this->stage_outline( $request, $model );
|
'outline',
|
||||||
|
'outline_generation',
|
||||||
|
PRAutoBlogger_Content_Prompts::build_outline( $request ),
|
||||||
|
$request,
|
||||||
|
$model,
|
||||||
|
array(
|
||||||
|
'temperature' => 0.5,
|
||||||
|
'max_tokens' => 1500,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
// Step 2: Generate draft from outline.
|
$draft = $this->execute_stage(
|
||||||
$draft = $this->stage_draft( $request, $outline, $model );
|
'draft',
|
||||||
|
'draft_generation',
|
||||||
|
PRAutoBlogger_Content_Prompts::build_draft( $request, $outline ),
|
||||||
|
$request,
|
||||||
|
$model,
|
||||||
|
array(
|
||||||
|
'temperature' => 0.7,
|
||||||
|
'max_tokens' => 4000,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
// Step 3: Polish the draft.
|
return $this->execute_stage(
|
||||||
$polished = $this->stage_polish( $request, $draft, $model );
|
'polish',
|
||||||
|
'polish_generation',
|
||||||
return $polished;
|
PRAutoBlogger_Content_Prompts::build_polish( $draft ),
|
||||||
|
$request,
|
||||||
|
$model,
|
||||||
|
array(
|
||||||
|
'temperature' => 0.4,
|
||||||
|
'max_tokens' => 4000,
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Multi-step stage 1: Generate an article outline.
|
* Run one writing stage: Opik span → LLM call → span close → cost log.
|
||||||
*
|
*
|
||||||
* @param PRAutoBlogger_Content_Request $request
|
* Consolidates the per-stage boilerplate that was previously duplicated
|
||||||
* @param string $model
|
* across the four stage methods (v0.18.0). The system prompt is rebuilt
|
||||||
|
* per stage exactly as before.
|
||||||
*
|
*
|
||||||
* @return string The outline text.
|
* Side effects: one OpenRouter API call, one Opik span (when tracing),
|
||||||
|
* one generation_log row.
|
||||||
|
*
|
||||||
|
* @param string $stage Log stage value ('outline'|'draft'|'polish').
|
||||||
|
* @param string $span_name Opik span name.
|
||||||
|
* @param string $user_prompt Rendered user prompt for the stage.
|
||||||
|
* @param PRAutoBlogger_Content_Request $request Generation request (system prompt input).
|
||||||
|
* @param string $model Model identifier.
|
||||||
|
* @param array<string, mixed> $options LLM options (temperature, max_tokens).
|
||||||
|
* @param string|null $prompt_key Explicit registry key for prompt_version
|
||||||
|
* stamping (null = derive from stage).
|
||||||
|
* @return string Stage output content.
|
||||||
*/
|
*/
|
||||||
private function stage_outline( PRAutoBlogger_Content_Request $request, string $model ): string {
|
private function execute_stage(
|
||||||
$idea = $request->get_idea();
|
string $stage,
|
||||||
|
string $span_name,
|
||||||
|
string $user_prompt,
|
||||||
|
PRAutoBlogger_Content_Request $request,
|
||||||
|
string $model,
|
||||||
|
array $options,
|
||||||
|
?string $prompt_key = null
|
||||||
|
): string {
|
||||||
|
// Idempotent resume: a done stage returns its snapshot, no LLM call.
|
||||||
|
if ( null !== $this->run_id && null !== $this->item_key ) {
|
||||||
|
$cached = PRAutoBlogger_Run_Stage_State::get_output( $this->run_id, $stage, '', $this->item_key );
|
||||||
|
if ( null !== $cached ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
|
sprintf( 'Reusing completed %s stage output (idempotent resume).', $stage ),
|
||||||
|
'pipeline'
|
||||||
|
);
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
PRAutoBlogger_Run_Stage_State::start( $this->run_id, $stage, (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( $stage ), $this->item_key );
|
||||||
|
}
|
||||||
|
|
||||||
$prompt = sprintf(
|
// v0.18.1: stage metadata for the provider's empty-completion
|
||||||
"Create a detailed outline for a blog post titled: \"%s\"\n\n" .
|
// guard — warning logs and failed-attempt audit rows carry the
|
||||||
"Topic: %s\nArticle type: %s\n\nKey points to cover:\n%s\n\n" .
|
// real stage + registry key (never sent upstream).
|
||||||
"Target keywords: %s\n\n" .
|
$options['stage'] = $stage;
|
||||||
"The outline should have 4-6 main sections with bullet points under each. " .
|
$options['prompt_key'] = $prompt_key;
|
||||||
"Include an introduction hook and a conclusion with a call to action. " .
|
|
||||||
"Word count target: %d-%d words.",
|
$ctx = PRAutoBlogger_Opik_Trace_Context::current();
|
||||||
$idea->get_suggested_title(),
|
|
||||||
$idea->get_topic(),
|
$span_id = $ctx->start_span(
|
||||||
$idea->get_article_type(),
|
array(
|
||||||
implode( "\n- ", $idea->get_key_points() ),
|
'name' => $span_name,
|
||||||
implode( ', ', $idea->get_target_keywords() ),
|
'type' => 'llm',
|
||||||
$request->get_min_word_count(),
|
'model' => $model,
|
||||||
$request->get_max_word_count()
|
'provider' => 'openrouter',
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
$response = $this->llm->send_chat_completion(
|
$response = $this->llm->send_chat_completion(
|
||||||
[
|
array(
|
||||||
[ 'role' => 'system', 'content' => $this->build_writer_system_prompt( $request ) ],
|
array(
|
||||||
[ 'role' => 'user', 'content' => $prompt ],
|
'role' => 'system',
|
||||||
],
|
'content' => PRAutoBlogger_Content_Prompts::build_system( $request ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'role' => 'user',
|
||||||
|
'content' => $user_prompt,
|
||||||
|
),
|
||||||
|
),
|
||||||
$model,
|
$model,
|
||||||
[ 'temperature' => 0.5, 'max_tokens' => 1500 ]
|
$options
|
||||||
|
);
|
||||||
|
|
||||||
|
// v0.18.1 writer-path guard (belt over the provider seam, which
|
||||||
|
// already throws for OpenRouter): no empty article may ever leave
|
||||||
|
// a writing stage — it would flow to the editor, be rejected as
|
||||||
|
// "draft text is missing", and land as an empty draft post.
|
||||||
|
if ( '' === trim( (string) $response['content'] ) ) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
sprintf(
|
||||||
|
/* translators: 1: writing stage name, 2: model identifier. */
|
||||||
|
__( 'Writing stage "%1$s" produced empty content (model %2$s) — failing the stage instead of passing an empty article downstream.', 'prautoblogger' ),
|
||||||
|
$stage,
|
||||||
|
(string) $response['model']
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ctx->end_span(
|
||||||
|
$span_id,
|
||||||
|
array(
|
||||||
|
'usage' => array(
|
||||||
|
'prompt_tokens' => $response['prompt_tokens'],
|
||||||
|
'completion_tokens' => $response['completion_tokens'],
|
||||||
|
'total_tokens' => $response['prompt_tokens'] + $response['completion_tokens'],
|
||||||
|
),
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->cost_tracker->log_api_call(
|
$this->cost_tracker->log_api_call(
|
||||||
null, 'outline', $this->llm->get_provider_name(),
|
null,
|
||||||
$response['model'], $response['prompt_tokens'], $response['completion_tokens']
|
$stage,
|
||||||
|
$this->llm->get_provider_name(),
|
||||||
|
$response['model'],
|
||||||
|
$response['prompt_tokens'],
|
||||||
|
$response['completion_tokens'],
|
||||||
|
'success',
|
||||||
|
'',
|
||||||
|
null,
|
||||||
|
$prompt_key
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if ( null !== $this->run_id && null !== $this->item_key ) {
|
||||||
|
PRAutoBlogger_Run_Stage_State::done(
|
||||||
|
$this->run_id,
|
||||||
|
$stage,
|
||||||
|
'',
|
||||||
|
$this->item_key,
|
||||||
|
(string) $response['content'],
|
||||||
|
$this->llm->estimate_cost( $response['model'], (int) $response['prompt_tokens'], (int) $response['completion_tokens'] )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return $response['content'];
|
return $response['content'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Multi-step stage 2: Write the full draft from the outline.
|
* Generate content in eval mode (no publishing, no image generation).
|
||||||
*
|
*
|
||||||
* @param PRAutoBlogger_Content_Request $request
|
* @param PRAutoBlogger_Article_Idea $idea The scored idea to generate content for.
|
||||||
* @param string $outline
|
* @return string Generated HTML content.
|
||||||
* @param string $model
|
|
||||||
*
|
|
||||||
* @return string Draft HTML content.
|
|
||||||
*/
|
*/
|
||||||
private function stage_draft( PRAutoBlogger_Content_Request $request, string $outline, string $model ): string {
|
public function generate_eval( PRAutoBlogger_Article_Idea $idea ): string {
|
||||||
$prompt = sprintf(
|
return $this->generate( $idea, true );
|
||||||
"Using this outline, write the full blog post in HTML format.\n\n" .
|
|
||||||
"OUTLINE:\n%s\n\n" .
|
|
||||||
"Requirements:\n" .
|
|
||||||
"- Write in a %s tone\n" .
|
|
||||||
"- Target %d-%d words\n" .
|
|
||||||
"- Use proper HTML headings (h2, h3), paragraphs, and lists\n" .
|
|
||||||
"- Include an engaging introduction and strong conclusion\n" .
|
|
||||||
"- Naturally incorporate these keywords: %s\n" .
|
|
||||||
"- Do NOT include the title in the HTML (it will be set separately)\n" .
|
|
||||||
"- Do NOT wrap in <html>, <head>, or <body> tags — just the article content",
|
|
||||||
$outline,
|
|
||||||
$request->get_tone(),
|
|
||||||
$request->get_min_word_count(),
|
|
||||||
$request->get_max_word_count(),
|
|
||||||
implode( ', ', $request->get_idea()->get_target_keywords() )
|
|
||||||
);
|
|
||||||
|
|
||||||
$response = $this->llm->send_chat_completion(
|
|
||||||
[
|
|
||||||
[ 'role' => 'system', 'content' => $this->build_writer_system_prompt( $request ) ],
|
|
||||||
[ 'role' => 'user', 'content' => $prompt ],
|
|
||||||
],
|
|
||||||
$model,
|
|
||||||
[ 'temperature' => 0.7, 'max_tokens' => 4000 ]
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->cost_tracker->log_api_call(
|
|
||||||
null, 'draft', $this->llm->get_provider_name(),
|
|
||||||
$response['model'], $response['prompt_tokens'], $response['completion_tokens']
|
|
||||||
);
|
|
||||||
|
|
||||||
return $response['content'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Multi-step stage 3: Polish and refine the draft.
|
|
||||||
*
|
|
||||||
* @param PRAutoBlogger_Content_Request $request
|
|
||||||
* @param string $draft
|
|
||||||
* @param string $model
|
|
||||||
*
|
|
||||||
* @return string Polished HTML content.
|
|
||||||
*/
|
|
||||||
private function stage_polish( PRAutoBlogger_Content_Request $request, string $draft, string $model ): string {
|
|
||||||
$prompt = "Review and polish this blog post draft. Improve:\n" .
|
|
||||||
"1. Flow and readability\n" .
|
|
||||||
"2. SEO optimization (headings, keyword placement)\n" .
|
|
||||||
"3. Engagement (hooks, transitions, call-to-action)\n" .
|
|
||||||
"4. Accuracy and clarity\n" .
|
|
||||||
"5. Remove any filler or redundant sentences\n\n" .
|
|
||||||
"Return the polished HTML content only. Do not add commentary.\n\n" .
|
|
||||||
"DRAFT:\n" . $draft;
|
|
||||||
|
|
||||||
$response = $this->llm->send_chat_completion(
|
|
||||||
[
|
|
||||||
[ 'role' => 'system', 'content' => $this->build_writer_system_prompt( $request ) ],
|
|
||||||
[ 'role' => 'user', 'content' => $prompt ],
|
|
||||||
],
|
|
||||||
$model,
|
|
||||||
[ 'temperature' => 0.4, 'max_tokens' => 4000 ]
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->cost_tracker->log_api_call(
|
|
||||||
null, 'polish', $this->llm->get_provider_name(),
|
|
||||||
$response['model'], $response['prompt_tokens'], $response['completion_tokens']
|
|
||||||
);
|
|
||||||
|
|
||||||
return $response['content'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the system prompt shared across all writing stages.
|
|
||||||
*
|
|
||||||
* @param PRAutoBlogger_Content_Request $request
|
|
||||||
*
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
private function build_writer_system_prompt( PRAutoBlogger_Content_Request $request ): string {
|
|
||||||
$niche = $request->get_niche_description();
|
|
||||||
$prompt = "You are an expert blog writer";
|
|
||||||
if ( '' !== $niche ) {
|
|
||||||
$prompt .= " specializing in {$niche}";
|
|
||||||
}
|
|
||||||
$prompt .= ". Write well-researched, engaging, SEO-friendly content. ";
|
|
||||||
$prompt .= "Use a {$request->get_tone()} tone. ";
|
|
||||||
$prompt .= "Output HTML content only — no markdown, no code fences, no commentary.";
|
|
||||||
return $prompt;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the user prompt for single-pass generation.
|
|
||||||
*
|
|
||||||
* @param PRAutoBlogger_Content_Request $request
|
|
||||||
*
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
private function build_single_pass_prompt( PRAutoBlogger_Content_Request $request ): string {
|
|
||||||
$idea = $request->get_idea();
|
|
||||||
|
|
||||||
return sprintf(
|
|
||||||
"Write a complete blog post in HTML format.\n\n" .
|
|
||||||
"Title: %s\nTopic: %s\nType: %s\n\nKey points:\n- %s\n\n" .
|
|
||||||
"Keywords: %s\n\n" .
|
|
||||||
"Requirements:\n" .
|
|
||||||
"- %d-%d words\n" .
|
|
||||||
"- Proper HTML (h2, h3, p, ul/li)\n" .
|
|
||||||
"- Engaging intro, strong conclusion with CTA\n" .
|
|
||||||
"- Do NOT include the title or <html>/<body> tags\n" .
|
|
||||||
"- Output HTML only, no markdown or commentary",
|
|
||||||
$idea->get_suggested_title(),
|
|
||||||
$idea->get_topic(),
|
|
||||||
$idea->get_article_type(),
|
|
||||||
implode( "\n- ", $idea->get_key_points() ),
|
|
||||||
implode( ', ', $idea->get_target_keywords() ),
|
|
||||||
$request->get_min_word_count(),
|
|
||||||
$request->get_max_word_count()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
192
includes/core/class-content-prompts.php
Normal file
192
includes/core/class-content-prompts.php
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Builds all LLM prompts used by the content generation pipeline.
|
||||||
|
*
|
||||||
|
* Centralises prompt construction so Content_Generator stays lean and
|
||||||
|
* prompt logic (system prompt, stage prompts, linking rules) can evolve
|
||||||
|
* independently of the generation orchestration.
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger_Content_Generator (called for every stage).
|
||||||
|
* Dependencies: PRAutoBlogger_Prompt_Registry (versioned prompt bodies),
|
||||||
|
* WordPress query functions (get_posts, get_the_title, get_permalink).
|
||||||
|
*
|
||||||
|
* @see core/class-content-generator.php — Consumer of these prompts.
|
||||||
|
* @see models/class-content-request.php — Data bag passed to every builder.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Content_Prompts {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the system prompt shared across all writing stages.
|
||||||
|
*
|
||||||
|
* Includes niche, tone, mandatory style guide, internal link reference,
|
||||||
|
* peptide database links, and the "never fabricate URLs" rule.
|
||||||
|
*
|
||||||
|
* v0.18.0: the prompt copy renders from the versioned registry
|
||||||
|
* ('content.system', falls back to the identical in-code default);
|
||||||
|
* the style guide and linking rules are data injection and stay here.
|
||||||
|
*
|
||||||
|
* @param PRAutoBlogger_Content_Request $request Content request with settings.
|
||||||
|
* @return string Complete system prompt.
|
||||||
|
*/
|
||||||
|
public static function build_system( PRAutoBlogger_Content_Request $request ): string {
|
||||||
|
$niche = $request->get_niche_description();
|
||||||
|
$prompt = PRAutoBlogger_Prompt_Registry::render(
|
||||||
|
'content.system',
|
||||||
|
array(
|
||||||
|
'niche_clause' => '' !== $niche ? " specializing in {$niche}" : '',
|
||||||
|
'tone' => $request->get_tone(),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Append user-defined writing instructions as a mandatory style guide.
|
||||||
|
$instructions = trim( $request->get_writing_instructions() );
|
||||||
|
if ( '' !== $instructions ) {
|
||||||
|
$prompt .= "\n\n--- MANDATORY STYLE GUIDE ---\n";
|
||||||
|
$prompt .= "You MUST follow every requirement below. These override any conflicting defaults:\n\n";
|
||||||
|
$prompt .= $instructions;
|
||||||
|
$prompt .= "\n--- END STYLE GUIDE ---";
|
||||||
|
}
|
||||||
|
|
||||||
|
$prompt .= "\n\n" . self::build_linking_rules();
|
||||||
|
|
||||||
|
return $prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the user prompt for single-pass generation.
|
||||||
|
*
|
||||||
|
* @param PRAutoBlogger_Content_Request $request Content request with settings.
|
||||||
|
* @return string User prompt.
|
||||||
|
*/
|
||||||
|
public static function build_single_pass( PRAutoBlogger_Content_Request $request ): string {
|
||||||
|
$idea = $request->get_idea();
|
||||||
|
|
||||||
|
return PRAutoBlogger_Prompt_Registry::render(
|
||||||
|
'content.single_pass',
|
||||||
|
array(
|
||||||
|
'title' => $idea->get_suggested_title(),
|
||||||
|
'topic' => $idea->get_topic(),
|
||||||
|
'article_type' => $idea->get_article_type(),
|
||||||
|
'key_points' => implode( "\n- ", $idea->get_key_points() ),
|
||||||
|
'keywords' => implode( ', ', $idea->get_target_keywords() ),
|
||||||
|
'min_words' => (string) $request->get_min_word_count(),
|
||||||
|
'max_words' => (string) $request->get_max_word_count(),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the outline stage prompt (multi-step stage 1).
|
||||||
|
*
|
||||||
|
* @param PRAutoBlogger_Content_Request $request Content request with settings.
|
||||||
|
* @return string Outline prompt.
|
||||||
|
*/
|
||||||
|
public static function build_outline( PRAutoBlogger_Content_Request $request ): string {
|
||||||
|
$idea = $request->get_idea();
|
||||||
|
|
||||||
|
return PRAutoBlogger_Prompt_Registry::render(
|
||||||
|
'content.outline',
|
||||||
|
array(
|
||||||
|
'title' => $idea->get_suggested_title(),
|
||||||
|
'topic' => $idea->get_topic(),
|
||||||
|
'article_type' => $idea->get_article_type(),
|
||||||
|
'key_points' => implode( "\n- ", $idea->get_key_points() ),
|
||||||
|
'keywords' => implode( ', ', $idea->get_target_keywords() ),
|
||||||
|
'min_words' => (string) $request->get_min_word_count(),
|
||||||
|
'max_words' => (string) $request->get_max_word_count(),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the draft stage prompt (multi-step stage 2).
|
||||||
|
*
|
||||||
|
* @param PRAutoBlogger_Content_Request $request Content request with settings.
|
||||||
|
* @param string $outline The outline from stage 1.
|
||||||
|
* @return string Draft prompt.
|
||||||
|
*/
|
||||||
|
public static function build_draft( PRAutoBlogger_Content_Request $request, string $outline ): string {
|
||||||
|
return PRAutoBlogger_Prompt_Registry::render(
|
||||||
|
'content.draft',
|
||||||
|
array(
|
||||||
|
'outline' => $outline,
|
||||||
|
'tone' => $request->get_tone(),
|
||||||
|
'min_words' => (string) $request->get_min_word_count(),
|
||||||
|
'max_words' => (string) $request->get_max_word_count(),
|
||||||
|
'keywords' => implode( ', ', $request->get_idea()->get_target_keywords() ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the polish stage prompt (multi-step stage 3).
|
||||||
|
*
|
||||||
|
* @param string $draft The draft from stage 2.
|
||||||
|
* @return string Polish prompt.
|
||||||
|
*/
|
||||||
|
public static function build_polish( string $draft ): string {
|
||||||
|
return PRAutoBlogger_Prompt_Registry::render(
|
||||||
|
'content.polish',
|
||||||
|
array( 'draft' => $draft )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the linking rules section (internal articles + peptide database).
|
||||||
|
*
|
||||||
|
* Fetches published blog posts and peptide pages, formats them as a
|
||||||
|
* reference list so the model uses real URLs instead of fabricating them.
|
||||||
|
*
|
||||||
|
* @return string Complete linking rules block.
|
||||||
|
*/
|
||||||
|
private static function build_linking_rules(): string {
|
||||||
|
$rules = "--- LINKING RULES ---\n";
|
||||||
|
$rules .= "NEVER fabricate or invent URLs. You do NOT have access to external sources.\n";
|
||||||
|
$rules .= "Only use the internal links listed below when linking within the article.\n";
|
||||||
|
$rules .= "If no listed article is relevant to a section, do not insert a link.\n";
|
||||||
|
$rules .= "Do NOT add links for peptide names — those are injected automatically after generation.\n\n";
|
||||||
|
|
||||||
|
$rules .= self::build_article_links();
|
||||||
|
|
||||||
|
$rules .= '--- END LINKING RULES ---';
|
||||||
|
|
||||||
|
return $rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a reference list of published blog posts for internal linking.
|
||||||
|
*
|
||||||
|
* @return string Formatted list, or a fallback note.
|
||||||
|
*/
|
||||||
|
private static function build_article_links(): string {
|
||||||
|
$posts = get_posts(
|
||||||
|
array(
|
||||||
|
'post_type' => 'post',
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'posts_per_page' => 30,
|
||||||
|
'orderby' => 'date',
|
||||||
|
'order' => 'DESC',
|
||||||
|
'fields' => 'ids',
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( empty( $posts ) ) {
|
||||||
|
return "No published articles available for internal linking yet.\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = array( "Available article links (use where topically relevant):\n" );
|
||||||
|
foreach ( $posts as $post_id ) {
|
||||||
|
$title = get_the_title( $post_id );
|
||||||
|
$url = get_permalink( $post_id );
|
||||||
|
if ( $title && $url ) {
|
||||||
|
$lines[] = "- {$title}: {$url}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode( "\n", $lines ) . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
23
includes/core/class-cost-ceiling-exception.php
Normal file
23
includes/core/class-cost-ceiling-exception.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Thrown when a reserve-before-call would push a run past its cost ceiling.
|
||||||
|
*
|
||||||
|
* What: Dedicated exception type so call paths can distinguish "this run
|
||||||
|
* was halted by the per-run cost governor" from generic API
|
||||||
|
* failures. By the time it is thrown the governor has already
|
||||||
|
* marked the run `halted`, recorded the overage on the runs row,
|
||||||
|
* and logged — catchers must NOT dispatch the call they were about
|
||||||
|
* to make, and chained jobs abort via the run-status check in
|
||||||
|
* Pipeline_Runner::process_next_queued_article().
|
||||||
|
* Who triggers it: PRAutoBlogger_Cost_Governor on a failed reservation.
|
||||||
|
* Dependencies: none (extends RuntimeException).
|
||||||
|
*
|
||||||
|
* @see core/class-cost-governor.php — Sole thrower.
|
||||||
|
* @see core/class-pipeline-runner.php — Aborts queued articles of halted runs.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Cost_Ceiling_Exception extends \RuntimeException {
|
||||||
|
}
|
||||||
239
includes/core/class-cost-governor.php
Normal file
239
includes/core/class-cost-governor.php
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Per-run cost governor — reserve-before-call against the run ledger.
|
||||||
|
*
|
||||||
|
* What: Net-new enforcement (the monthly Cost_Tracker cap only bounds the
|
||||||
|
* month; `get_current_run_cost()` was an unguarded accumulator).
|
||||||
|
* Before any governed call dispatches, its worst-case cost estimate
|
||||||
|
* is atomically added to the run's `reserved_usd` via a single
|
||||||
|
* conditional UPDATE — the same atomic-conditional-write discipline
|
||||||
|
* as PRAutoBlogger_Generation_Lock (#10) — so concurrent writers
|
||||||
|
* (curl_multi fan-outs, chained jobs) cannot slip past the ceiling
|
||||||
|
* between check and call. Batches reserve their SUMMED estimate
|
||||||
|
* before dispatch. After each response the reservation settles to
|
||||||
|
* the actual cost, so a cheap call never holds its worst case.
|
||||||
|
* Breach: the run is marked `halted` (sticky), the overage is
|
||||||
|
* recorded on the runs row, the Review Queue surfaces it, and a
|
||||||
|
* Cost_Ceiling_Exception aborts the un-dispatched call. Standalone
|
||||||
|
* calls outside any run (no run context) are not run-governed —
|
||||||
|
* exactly the historical behavior; the monthly cap and the
|
||||||
|
* Cloudflare AI Gateway path are untouched everywhere.
|
||||||
|
* Who triggers it: PRAutoBlogger_OpenRouter_Provider::send_chat_completion
|
||||||
|
* (every text-LLM call path), PRAutoBlogger_Image_Pipeline (image
|
||||||
|
* curl_multi batch).
|
||||||
|
* Dependencies: Run_Context (which run is this process in),
|
||||||
|
* Run_State (ledger row + halt), OpenRouter_Pricing (#18 chain),
|
||||||
|
* WordPress $wpdb, Logger.
|
||||||
|
*
|
||||||
|
* @see class-generation-lock.php — The atomic-write pattern this mirrors.
|
||||||
|
* @see core/class-run-state.php — The ledger row reserved against.
|
||||||
|
* @see providers/class-open-router-provider.php — Text-call integration point.
|
||||||
|
* @see core/class-image-pipeline.php — Batch reservation integration point.
|
||||||
|
* @see ARCHITECTURE.md #21 — Design rationale.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Cost_Governor {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rough chars-per-token divisor for prompt-size estimation. An internal
|
||||||
|
* estimator detail (NOT a behavior setting — the enforced ceiling is
|
||||||
|
* the prautoblogger_per_run_cost_ceiling_usd SETTING).
|
||||||
|
*/
|
||||||
|
private const CHARS_PER_TOKEN = 4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Worst-case completion tokens assumed when a caller sets no
|
||||||
|
* max_tokens. Every current call site sets one; this is belt-and-braces.
|
||||||
|
*/
|
||||||
|
private const DEFAULT_MAX_COMPLETION_TOKENS = 4096;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reserve the worst-case cost of one chat-completion call for the
|
||||||
|
* process's current run. Call before dispatch.
|
||||||
|
*
|
||||||
|
* Returns null (call proceeds ungoverned, exactly as pre-v0.18.0) when
|
||||||
|
* this process is not inside a run, the run row/ledger is unavailable
|
||||||
|
* (half-migrated schema), or the run's ceiling is 0/disabled.
|
||||||
|
*
|
||||||
|
* @param string $model Model identifier.
|
||||||
|
* @param array<int, array{role: string, content: string}> $messages Chat messages (prompt-size estimate).
|
||||||
|
* @param array<string, mixed> $options Call options (max_tokens).
|
||||||
|
* @return array{run_id: string, amount: float}|null Open reservation, or null when ungoverned.
|
||||||
|
* @throws PRAutoBlogger_Cost_Ceiling_Exception When the reservation would breach the ceiling
|
||||||
|
* (the run is already halted when this throws).
|
||||||
|
*/
|
||||||
|
public static function open_chat_reservation( string $model, array $messages, array $options ): ?array {
|
||||||
|
return self::open_amount_reservation(
|
||||||
|
self::estimate_chat_cost( $model, $messages, $options ),
|
||||||
|
'llm:' . $model
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reserve a pre-computed worst-case amount (e.g. a curl_multi image
|
||||||
|
* batch's summed estimate) for the process's current run.
|
||||||
|
*
|
||||||
|
* @param float $amount_usd Worst-case cost estimate in USD.
|
||||||
|
* @param string $context Short label for logs ('llm:<model>', 'image_batch').
|
||||||
|
* @return array{run_id: string, amount: float}|null Open reservation, or null when ungoverned.
|
||||||
|
* @throws PRAutoBlogger_Cost_Ceiling_Exception When the reservation would breach the ceiling.
|
||||||
|
*/
|
||||||
|
public static function open_amount_reservation( float $amount_usd, string $context ): ?array {
|
||||||
|
$run_id = PRAutoBlogger_Run_Context::current_run_id();
|
||||||
|
if ( null === $run_id || '' === $run_id || $amount_usd <= 0 ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ( ! PRAutoBlogger_Run_State::is_available() ) {
|
||||||
|
return null; // Half-migrated schema — degrade to historical behavior.
|
||||||
|
}
|
||||||
|
|
||||||
|
PRAutoBlogger_Run_State::ensure_run( $run_id );
|
||||||
|
$run = PRAutoBlogger_Run_State::get_run( $run_id );
|
||||||
|
if ( null === $run ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ( (float) $run['ceiling_usd'] <= 0 ) {
|
||||||
|
return null; // Ceiling disabled for this run.
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( self::reserve( $run_id, $amount_usd ) ) {
|
||||||
|
return array(
|
||||||
|
'run_id' => $run_id,
|
||||||
|
'amount' => $amount_usd,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::on_breach( $run_id, $amount_usd, $context );
|
||||||
|
return null; // Unreachable — on_breach always throws.
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settle an open reservation to the call's actual cost: the hold is
|
||||||
|
* released and the actual amount moves to settled_usd.
|
||||||
|
*
|
||||||
|
* @param array{run_id: string, amount: float}|null $reservation Reservation from open_*(), or null (no-op).
|
||||||
|
* @param float $actual_usd Actual cost of the call.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function settle( ?array $reservation, float $actual_usd ): void {
|
||||||
|
if ( null === $reservation || ! PRAutoBlogger_Run_State::is_available() ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
global $wpdb;
|
||||||
|
$table = PRAutoBlogger_Run_State::table_name();
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
$wpdb->query(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"UPDATE {$table}
|
||||||
|
SET reserved_usd = GREATEST(reserved_usd - %f, 0),
|
||||||
|
settled_usd = settled_usd + %f,
|
||||||
|
updated_at = %s
|
||||||
|
WHERE run_id = %s",
|
||||||
|
$reservation['amount'],
|
||||||
|
max( $actual_usd, 0 ),
|
||||||
|
current_time( 'mysql' ),
|
||||||
|
$reservation['run_id']
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release an open reservation without settling cost (failed call).
|
||||||
|
*
|
||||||
|
* @param array{run_id: string, amount: float}|null $reservation Reservation from open_*(), or null (no-op).
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function release( ?array $reservation ): void {
|
||||||
|
self::settle( $reservation, 0.0 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Worst-case cost estimate for a chat call: estimated prompt tokens
|
||||||
|
* (message chars / 4) plus the call's max_tokens completion budget,
|
||||||
|
* priced via the #18 pricing chain (hardcoded table -> cached model
|
||||||
|
* registry -> conservative fallback).
|
||||||
|
*
|
||||||
|
* @param string $model Model identifier.
|
||||||
|
* @param array<int, array{role: string, content: string}> $messages Chat messages.
|
||||||
|
* @param array<string, mixed> $options Call options.
|
||||||
|
* @return float Estimated worst-case USD cost.
|
||||||
|
*/
|
||||||
|
public static function estimate_chat_cost( string $model, array $messages, array $options ): float {
|
||||||
|
$chars = 0;
|
||||||
|
foreach ( $messages as $message ) {
|
||||||
|
$chars += strlen( (string) ( $message['content'] ?? '' ) );
|
||||||
|
}
|
||||||
|
$prompt_tokens = (int) ceil( $chars / self::CHARS_PER_TOKEN );
|
||||||
|
$completion_tokens = (int) ( $options['max_tokens'] ?? self::DEFAULT_MAX_COMPLETION_TOKENS );
|
||||||
|
|
||||||
|
return ( new PRAutoBlogger_OpenRouter_Pricing() )->estimate_cost( $model, $prompt_tokens, $completion_tokens );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomic conditional reservation — the #10 discipline. One UPDATE
|
||||||
|
* whose WHERE clause enforces `settled + reserved + estimate <=
|
||||||
|
* ceiling` AND that the run is still open; affected-rows tells us who
|
||||||
|
* won. Concurrent reservers serialize on the row lock.
|
||||||
|
*
|
||||||
|
* @param string $run_id Run UUID.
|
||||||
|
* @param float $amount Estimate to reserve.
|
||||||
|
* @return bool True when the reservation was written.
|
||||||
|
*/
|
||||||
|
private static function reserve( string $run_id, float $amount ): bool {
|
||||||
|
global $wpdb;
|
||||||
|
$table = PRAutoBlogger_Run_State::table_name();
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
$updated = $wpdb->query(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"UPDATE {$table}
|
||||||
|
SET reserved_usd = reserved_usd + %f, updated_at = %s
|
||||||
|
WHERE run_id = %s
|
||||||
|
AND status IN ('pending','running')
|
||||||
|
AND (reserved_usd + settled_usd + %f) <= ceiling_usd",
|
||||||
|
$amount,
|
||||||
|
current_time( 'mysql' ),
|
||||||
|
$run_id,
|
||||||
|
$amount
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return 1 === (int) $updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a failed reservation: halt the run (sticky), record the
|
||||||
|
* overage, log, throw. The Review Queue lists halted runs; chained
|
||||||
|
* queue jobs abort via the run-status check.
|
||||||
|
*
|
||||||
|
* @param string $run_id Run UUID.
|
||||||
|
* @param float $amount The estimate that failed to reserve.
|
||||||
|
* @param string $context Short label for logs.
|
||||||
|
* @return void
|
||||||
|
* @throws PRAutoBlogger_Cost_Ceiling_Exception Always.
|
||||||
|
*/
|
||||||
|
private static function on_breach( string $run_id, float $amount, string $context ): void {
|
||||||
|
$run = PRAutoBlogger_Run_State::get_run( $run_id );
|
||||||
|
$ceiling = null !== $run ? (float) $run['ceiling_usd'] : 0.0;
|
||||||
|
$held = null !== $run ? (float) $run['reserved_usd'] + (float) $run['settled_usd'] : 0.0;
|
||||||
|
$overage = max( ( $held + $amount ) - $ceiling, 0.0 );
|
||||||
|
|
||||||
|
PRAutoBlogger_Run_State::mark_status( $run_id, 'halted' );
|
||||||
|
PRAutoBlogger_Run_State::record_overage( $run_id, $overage );
|
||||||
|
|
||||||
|
$message = sprintf(
|
||||||
|
/* translators: 1: blocked amount USD, 2: call context, 3: committed USD, 4: ceiling USD, 5: run id. */
|
||||||
|
__( 'Per-run cost ceiling reached: reserving $%1$.4f for %2$s would push the run to $%3$.4f against a $%4$.2f ceiling. Run %5$s halted and routed to the Review Queue.', 'prautoblogger' ),
|
||||||
|
$amount,
|
||||||
|
$context,
|
||||||
|
$held + $amount,
|
||||||
|
$ceiling,
|
||||||
|
$run_id
|
||||||
|
);
|
||||||
|
|
||||||
|
PRAutoBlogger_Logger::instance()->error( $message, 'cost-governor' );
|
||||||
|
|
||||||
|
throw new PRAutoBlogger_Cost_Ceiling_Exception( $message );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
* Provides historical cost reporting and analysis methods.
|
* Provides historical cost reporting and analysis methods.
|
||||||
*
|
*
|
||||||
* Extracted from PRAutoBlogger_Cost_Tracker, this class handles all read-only
|
* Extracted from PRAutoBlogger_Cost_Tracker, this class handles all read-only
|
||||||
@@ -34,6 +36,7 @@ class PRAutoBlogger_Cost_Reporter {
|
|||||||
$first_of_month = gmdate( 'Y-m-01 00:00:00' );
|
$first_of_month = gmdate( 'Y-m-01 00:00:00' );
|
||||||
|
|
||||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
$result = $wpdb->get_var(
|
$result = $wpdb->get_var(
|
||||||
$wpdb->prepare(
|
$wpdb->prepare(
|
||||||
"SELECT COALESCE(SUM(estimated_cost), 0) FROM {$table} WHERE created_at >= %s AND response_status = 'success'",
|
"SELECT COALESCE(SUM(estimated_cost), 0) FROM {$table} WHERE created_at >= %s AND response_status = 'success'",
|
||||||
@@ -54,7 +57,7 @@ class PRAutoBlogger_Cost_Reporter {
|
|||||||
public function get_daily_spend( int $days = 30 ): array {
|
public function get_daily_spend( int $days = 30 ): array {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
if ( null === $wpdb ) {
|
if ( null === $wpdb ) {
|
||||||
return [];
|
return array();
|
||||||
}
|
}
|
||||||
|
|
||||||
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||||
@@ -62,6 +65,7 @@ class PRAutoBlogger_Cost_Reporter {
|
|||||||
$start_date = gmdate( 'Y-m-d', time() - ( $days * DAY_IN_SECONDS ) );
|
$start_date = gmdate( 'Y-m-d', time() - ( $days * DAY_IN_SECONDS ) );
|
||||||
|
|
||||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
$results = $wpdb->get_results(
|
$results = $wpdb->get_results(
|
||||||
$wpdb->prepare(
|
$wpdb->prepare(
|
||||||
"SELECT DATE(created_at) as day, SUM(estimated_cost) as total_cost
|
"SELECT DATE(created_at) as day, SUM(estimated_cost) as total_cost
|
||||||
@@ -74,8 +78,8 @@ class PRAutoBlogger_Cost_Reporter {
|
|||||||
ARRAY_A
|
ARRAY_A
|
||||||
);
|
);
|
||||||
|
|
||||||
$daily = [];
|
$daily = array();
|
||||||
foreach ( ( $results ?: [] ) as $row ) {
|
foreach ( ( $results ?? array() ) as $row ) {
|
||||||
$daily[ $row['day'] ] = (float) $row['total_cost'];
|
$daily[ $row['day'] ] = (float) $row['total_cost'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,12 +97,13 @@ class PRAutoBlogger_Cost_Reporter {
|
|||||||
public function get_spend_by_stage( string $start_date, string $end_date ): array {
|
public function get_spend_by_stage( string $start_date, string $end_date ): array {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
if ( null === $wpdb ) {
|
if ( null === $wpdb ) {
|
||||||
return [];
|
return array();
|
||||||
}
|
}
|
||||||
|
|
||||||
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||||
|
|
||||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
$results = $wpdb->get_results(
|
$results = $wpdb->get_results(
|
||||||
$wpdb->prepare(
|
$wpdb->prepare(
|
||||||
"SELECT stage,
|
"SELECT stage,
|
||||||
@@ -114,13 +119,13 @@ class PRAutoBlogger_Cost_Reporter {
|
|||||||
ARRAY_A
|
ARRAY_A
|
||||||
);
|
);
|
||||||
|
|
||||||
$breakdown = [];
|
$breakdown = array();
|
||||||
foreach ( ( $results ?: [] ) as $row ) {
|
foreach ( ( $results ?? array() ) as $row ) {
|
||||||
$breakdown[ $row['stage'] ] = [
|
$breakdown[ $row['stage'] ] = array(
|
||||||
'cost' => (float) $row['total_cost'],
|
'cost' => (float) $row['total_cost'],
|
||||||
'tokens' => (int) $row['total_tokens'],
|
'tokens' => (int) $row['total_tokens'],
|
||||||
'calls' => (int) $row['call_count'],
|
'calls' => (int) $row['call_count'],
|
||||||
];
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $breakdown;
|
return $breakdown;
|
||||||
@@ -138,4 +143,63 @@ class PRAutoBlogger_Cost_Reporter {
|
|||||||
}
|
}
|
||||||
return ( $this->get_monthly_spend() / $budget ) * 100.0;
|
return ( $this->get_monthly_spend() / $budget ) * 100.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get average input/output token counts for given stages over a time period.
|
||||||
|
*
|
||||||
|
* Used by the model picker field renderer to calculate estimated costs
|
||||||
|
* per generation based on historical token usage. Maps setting IDs to their
|
||||||
|
* constituting stages (e.g., writing model controls outline + draft + polish).
|
||||||
|
* Moved here from Cost_Tracker in v0.18.0 (read-only reporting query;
|
||||||
|
* Cost_Tracker keeps a delegating wrapper for API compatibility).
|
||||||
|
*
|
||||||
|
* @param array<string> $stages Stage names ('analysis', 'outline', 'draft', 'polish', 'review').
|
||||||
|
* @param int $days Historical window (default 30 days).
|
||||||
|
*
|
||||||
|
* @return array{avg_prompt_tokens: float, avg_completion_tokens: float, sample_size: int}
|
||||||
|
* Returns empty counters if no history. Never throws.
|
||||||
|
*/
|
||||||
|
public function get_avg_tokens_for_stages( array $stages, int $days = 30 ): array {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
if ( empty( $stages ) ) {
|
||||||
|
return array(
|
||||||
|
'avg_prompt_tokens' => 0.0,
|
||||||
|
'avg_completion_tokens' => 0.0,
|
||||||
|
'sample_size' => 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cutoff_datetime = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) );
|
||||||
|
$stage_list = implode( ',', array_map( array( $wpdb, 'prepare' ), array_fill( 0, count( $stages ), '%s' ), $stages ) );
|
||||||
|
$table_name = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Prepared stage list via array_map + prepare
|
||||||
|
$query = $wpdb->prepare(
|
||||||
|
"SELECT AVG(prompt_tokens) as avg_prompt,
|
||||||
|
AVG(completion_tokens) as avg_completion,
|
||||||
|
COUNT(*) as sample_count
|
||||||
|
FROM $table_name
|
||||||
|
WHERE stage IN ( $stage_list )
|
||||||
|
AND created_at >= %s",
|
||||||
|
$cutoff_datetime
|
||||||
|
);
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- phpcs pragma above
|
||||||
|
$result = $wpdb->get_row( $query, ARRAY_A );
|
||||||
|
|
||||||
|
if ( ! $result || 0 === (int) ( $result['sample_count'] ?? 0 ) ) {
|
||||||
|
return array(
|
||||||
|
'avg_prompt_tokens' => 0.0,
|
||||||
|
'avg_completion_tokens' => 0.0,
|
||||||
|
'sample_size' => 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return array(
|
||||||
|
'avg_prompt_tokens' => (float) ( $result['avg_prompt'] ?? 0 ),
|
||||||
|
'avg_completion_tokens' => (float) ( $result['avg_completion'] ?? 0 ),
|
||||||
|
'sample_size' => (int) ( $result['sample_count'] ?? 0 ),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
107
includes/core/class-cost-tracker.php
Executable file → Normal file
107
includes/core/class-cost-tracker.php
Executable file → Normal file
@@ -2,6 +2,8 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
* Tracks all API costs and enforces monthly budget limits.
|
* Tracks all API costs and enforces monthly budget limits.
|
||||||
*
|
*
|
||||||
* Every LLM API call in the plugin is logged through this class. The budget
|
* Every LLM API call in the plugin is logged through this class. The budget
|
||||||
@@ -45,6 +47,14 @@ class PRAutoBlogger_Cost_Tracker {
|
|||||||
*/
|
*/
|
||||||
public function set_run_id( string $run_id ): void {
|
public function set_run_id( string $run_id ): void {
|
||||||
$this->run_id = $run_id;
|
$this->run_id = $run_id;
|
||||||
|
// v0.18.0: declaring a run id makes this PHP process part of that
|
||||||
|
// run — record the context (cost-governor guard, prompt-version
|
||||||
|
// stamping for components without a run reference), make sure the
|
||||||
|
// run ledger row exists, and pin the active prompt versions once.
|
||||||
|
// All three are self-healing no-ops on a half-migrated schema.
|
||||||
|
PRAutoBlogger_Run_Context::set_run_id( $run_id );
|
||||||
|
PRAutoBlogger_Run_State::ensure_run( $run_id );
|
||||||
|
PRAutoBlogger_Prompt_Registry::pin_for_run( $run_id );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,16 +69,24 @@ class PRAutoBlogger_Cost_Tracker {
|
|||||||
/**
|
/**
|
||||||
* Log an API call with its token usage and estimated cost.
|
* Log an API call with its token usage and estimated cost.
|
||||||
*
|
*
|
||||||
* Side effects: database insert into prab_generation_log.
|
* Side effects: database insert into prab_generation_log. Consumes the
|
||||||
|
* Request_Recorder stash (v0.20.0 / B1) into the row's request_json so
|
||||||
|
* every chat call's input is persisted (retention-pruned after R days).
|
||||||
*
|
*
|
||||||
* @param int|null $post_id WordPress post ID (null during generation, set later).
|
* @param int|null $post_id WordPress post ID (null during generation, set later).
|
||||||
* @param string $stage Pipeline stage: 'analysis', 'outline', 'draft', 'polish', 'review'.
|
* @param string $stage Pipeline stage (see PRAutoBlogger_Stage_Display_Map).
|
||||||
* @param string $provider Provider name: 'OpenRouter'.
|
* @param string $provider Provider name: 'OpenRouter'.
|
||||||
* @param string $model Model identifier used.
|
* @param string $model Model identifier used.
|
||||||
* @param int $prompt_tokens Input tokens consumed.
|
* @param int $prompt_tokens Input tokens consumed.
|
||||||
* @param int $completion_tokens Output tokens generated.
|
* @param int $completion_tokens Output tokens generated.
|
||||||
* @param string $response_status 'success', 'error', or 'timeout'.
|
* @param string $response_status 'success', 'error', or 'timeout'.
|
||||||
* @param string $error_message Error details if status is not 'success'.
|
* @param string $error_message Error details if status is not 'success'.
|
||||||
|
* @param string|null $agent_role v0.18.0 — role that made the call; derived
|
||||||
|
* from the stage map when null.
|
||||||
|
* @param string|null $prompt_key v0.18.0 — registry key the call rendered
|
||||||
|
* with (e.g. 'content.single_pass'); derived
|
||||||
|
* from the stage map when null. Resolves the
|
||||||
|
* run-pinned prompt_version stamped on the row.
|
||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
@@ -80,14 +98,17 @@ class PRAutoBlogger_Cost_Tracker {
|
|||||||
int $prompt_tokens,
|
int $prompt_tokens,
|
||||||
int $completion_tokens,
|
int $completion_tokens,
|
||||||
string $response_status = 'success',
|
string $response_status = 'success',
|
||||||
string $error_message = ''
|
string $error_message = '',
|
||||||
|
?string $agent_role = null,
|
||||||
|
?string $prompt_key = null
|
||||||
): void {
|
): void {
|
||||||
$llm = new PRAutoBlogger_OpenRouter_Provider();
|
$llm = new PRAutoBlogger_OpenRouter_Provider();
|
||||||
$cost = $llm->estimate_cost( $model, $prompt_tokens, $completion_tokens );
|
$cost = $llm->estimate_cost( $model, $prompt_tokens, $completion_tokens );
|
||||||
|
|
||||||
$this->current_run_cost += $cost;
|
$this->current_run_cost += $cost;
|
||||||
|
|
||||||
$log_entry = new PRAutoBlogger_Generation_Log( [
|
$log_entry = new PRAutoBlogger_Generation_Log(
|
||||||
|
array(
|
||||||
'post_id' => $post_id,
|
'post_id' => $post_id,
|
||||||
'run_id' => $this->run_id,
|
'run_id' => $this->run_id,
|
||||||
'stage' => $stage,
|
'stage' => $stage,
|
||||||
@@ -98,16 +119,30 @@ class PRAutoBlogger_Cost_Tracker {
|
|||||||
'estimated_cost' => $cost,
|
'estimated_cost' => $cost,
|
||||||
'response_status' => $response_status,
|
'response_status' => $response_status,
|
||||||
'error_message' => $error_message,
|
'error_message' => $error_message,
|
||||||
|
'request_json' => PRAutoBlogger_Request_Recorder::consume(),
|
||||||
|
'agent_role' => $agent_role ?? PRAutoBlogger_Stage_Display_Map::default_agent_role( $stage ),
|
||||||
|
'prompt_version' => $this->resolve_prompt_version( $stage, $prompt_key ),
|
||||||
'created_at' => current_time( 'mysql' ),
|
'created_at' => current_time( 'mysql' ),
|
||||||
] );
|
)
|
||||||
|
);
|
||||||
|
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||||
|
|
||||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
$wpdb->insert( $table, $log_entry->to_db_row() );
|
$inserted = $wpdb->insert( $table, $log_entry->to_db_row() );
|
||||||
|
|
||||||
if ( false === $wpdb->insert_id ) {
|
// Self-healing on a half-migrated schema (e.g. cron fires after a
|
||||||
|
// deploy but before any admin_init migration pass): retry without
|
||||||
|
// the v0.18.0 audit columns rather than losing the cost row.
|
||||||
|
if ( false === $inserted ) {
|
||||||
|
$legacy_row = $log_entry->to_db_row();
|
||||||
|
unset( $legacy_row['agent_role'], $legacy_row['prompt_version'] );
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
$inserted = $wpdb->insert( $table, $legacy_row );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( false === $inserted || false === $wpdb->insert_id ) {
|
||||||
PRAutoBlogger_Logger::instance()->error( 'Failed to log API call: ' . $wpdb->last_error, 'cost-tracker' );
|
PRAutoBlogger_Logger::instance()->error( 'Failed to log API call: ' . $wpdb->last_error, 'cost-tracker' );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,8 +192,7 @@ class PRAutoBlogger_Cost_Tracker {
|
|||||||
}
|
}
|
||||||
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||||
|
|
||||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
$row = array(
|
||||||
$wpdb->insert( $table, [
|
|
||||||
'post_id' => $post_id,
|
'post_id' => $post_id,
|
||||||
'run_id' => $this->run_id,
|
'run_id' => $this->run_id,
|
||||||
'stage' => $stage,
|
'stage' => $stage,
|
||||||
@@ -169,8 +203,44 @@ class PRAutoBlogger_Cost_Tracker {
|
|||||||
'estimated_cost' => $cost_usd,
|
'estimated_cost' => $cost_usd,
|
||||||
'response_status' => 'success',
|
'response_status' => 'success',
|
||||||
'error_message' => '',
|
'error_message' => '',
|
||||||
|
'agent_role' => PRAutoBlogger_Stage_Display_Map::default_agent_role( $stage ),
|
||||||
|
'prompt_version' => $this->resolve_prompt_version( $stage, null ),
|
||||||
'created_at' => current_time( 'mysql' ),
|
'created_at' => current_time( 'mysql' ),
|
||||||
] );
|
);
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
$inserted = $wpdb->insert( $table, $row );
|
||||||
|
|
||||||
|
// Self-healing on a half-migrated schema — see log_api_call().
|
||||||
|
if ( false === $inserted ) {
|
||||||
|
unset( $row['agent_role'], $row['prompt_version'] );
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
$wpdb->insert( $table, $row );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the pinned prompt version to stamp on a log row.
|
||||||
|
*
|
||||||
|
* Uses the explicit registry key when the call site passed one,
|
||||||
|
* otherwise the stage's primary key from the display map. The version
|
||||||
|
* comes from the run's pins; the run is this tracker's run_id or —
|
||||||
|
* for standalone trackers inside a pipeline process (e.g. the image
|
||||||
|
* prompt rewriter's) — the process-level run context. Null when there
|
||||||
|
* is no run, no pins, or no key (historical behavior preserved).
|
||||||
|
*
|
||||||
|
* @param string $stage Stage being logged.
|
||||||
|
* @param string|null $prompt_key Explicit registry key, or null to derive.
|
||||||
|
* @return string|null Pinned version as string, or null.
|
||||||
|
*/
|
||||||
|
private function resolve_prompt_version( string $stage, ?string $prompt_key ): ?string {
|
||||||
|
$key = $prompt_key ?? PRAutoBlogger_Stage_Display_Map::default_prompt_key( $stage );
|
||||||
|
if ( null === $key ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$run_id = $this->run_id ?? PRAutoBlogger_Run_Context::current_run_id();
|
||||||
|
$pins = PRAutoBlogger_Prompt_Registry::pins_for_run( $run_id );
|
||||||
|
return isset( $pins[ $key ] ) ? (string) $pins[ $key ] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -203,4 +273,21 @@ class PRAutoBlogger_Cost_Tracker {
|
|||||||
public function get_current_run_cost(): float {
|
public function get_current_run_cost(): float {
|
||||||
return $this->current_run_cost;
|
return $this->current_run_cost;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get average input/output token counts for given stages over a time period.
|
||||||
|
*
|
||||||
|
* Thin delegate to PRAutoBlogger_Cost_Reporter (the read-only reporting
|
||||||
|
* class) — kept here for API compatibility with the model-picker field
|
||||||
|
* and existing tests. See Cost_Reporter for the query.
|
||||||
|
*
|
||||||
|
* @param array<string> $stages Stage names ('analysis', 'outline', 'draft', 'polish', 'review').
|
||||||
|
* @param int $days Historical window (default 30 days).
|
||||||
|
*
|
||||||
|
* @return array{avg_prompt_tokens: float, avg_completion_tokens: float, sample_size: int}
|
||||||
|
* Returns empty counters if no history. Never throws.
|
||||||
|
*/
|
||||||
|
public function get_avg_tokens_for_stages( array $stages, int $days = 30 ): array {
|
||||||
|
return ( new PRAutoBlogger_Cost_Reporter() )->get_avg_tokens_for_stages( $stages, $days );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
* Google Analytics 4 API client for fetching post performance metrics.
|
* Google Analytics 4 API client for fetching post performance metrics.
|
||||||
*
|
*
|
||||||
* Handles OAuth2 service account authentication and the GA4 Data API.
|
* Handles OAuth2 service account authentication and the GA4 Data API.
|
||||||
@@ -29,22 +31,22 @@ class PRAutoBlogger_GA4_Client {
|
|||||||
public function fetch_data( array $posts ): array {
|
public function fetch_data( array $posts ): array {
|
||||||
$property_id = get_option( 'prautoblogger_ga4_property_id', '' );
|
$property_id = get_option( 'prautoblogger_ga4_property_id', '' );
|
||||||
if ( '' === $property_id ) {
|
if ( '' === $property_id ) {
|
||||||
return [];
|
return array();
|
||||||
}
|
}
|
||||||
|
|
||||||
$credentials_encrypted = get_option( 'prautoblogger_ga4_credentials_json', '' );
|
$credentials_encrypted = get_option( 'prautoblogger_ga4_credentials_json', '' );
|
||||||
if ( '' === $credentials_encrypted ) {
|
if ( '' === $credentials_encrypted ) {
|
||||||
return [];
|
return array();
|
||||||
}
|
}
|
||||||
|
|
||||||
$credentials = PRAutoBlogger_Encryption::decrypt( $credentials_encrypted );
|
$credentials = PRAutoBlogger_Encryption::decrypt( $credentials_encrypted );
|
||||||
if ( '' === $credentials ) {
|
if ( '' === $credentials ) {
|
||||||
return [];
|
return array();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build page paths for the GA4 query.
|
// Build page paths for the GA4 query.
|
||||||
$page_paths = [];
|
$page_paths = array();
|
||||||
$path_to_id = [];
|
$path_to_id = array();
|
||||||
foreach ( $posts as $post ) {
|
foreach ( $posts as $post ) {
|
||||||
$path = wp_parse_url( get_permalink( $post->ID ), PHP_URL_PATH );
|
$path = wp_parse_url( get_permalink( $post->ID ), PHP_URL_PATH );
|
||||||
if ( $path ) {
|
if ( $path ) {
|
||||||
@@ -54,7 +56,7 @@ class PRAutoBlogger_GA4_Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ( empty( $page_paths ) ) {
|
if ( empty( $page_paths ) ) {
|
||||||
return [];
|
return array();
|
||||||
}
|
}
|
||||||
|
|
||||||
// GA4 Data API request.
|
// GA4 Data API request.
|
||||||
@@ -62,40 +64,45 @@ class PRAutoBlogger_GA4_Client {
|
|||||||
$access_token = $this->get_access_token( $credentials );
|
$access_token = $this->get_access_token( $credentials );
|
||||||
if ( '' === $access_token ) {
|
if ( '' === $access_token ) {
|
||||||
PRAutoBlogger_Logger::instance()->error( 'GA4: Failed to get access token. Possible causes: invalid service account JSON, expired or revoked private key, or network error reaching oauth2.googleapis.com. Verify credentials in PRAutoBlogger → Settings.', 'ga4' );
|
PRAutoBlogger_Logger::instance()->error( 'GA4: Failed to get access token. Possible causes: invalid service account JSON, expired or revoked private key, or network error reaching oauth2.googleapis.com. Verify credentials in PRAutoBlogger → Settings.', 'ga4' );
|
||||||
return [];
|
return array();
|
||||||
}
|
}
|
||||||
|
|
||||||
$request_body = [
|
$request_body = array(
|
||||||
'dateRanges' => [ [ 'startDate' => '30daysAgo', 'endDate' => 'today' ] ],
|
'dateRanges' => array(
|
||||||
'dimensions' => [ [ 'name' => 'pagePath' ] ],
|
array(
|
||||||
'metrics' => [
|
'startDate' => '30daysAgo',
|
||||||
[ 'name' => 'screenPageViews' ],
|
'endDate' => 'today',
|
||||||
[ 'name' => 'averageSessionDuration' ],
|
),
|
||||||
[ 'name' => 'bounceRate' ],
|
),
|
||||||
],
|
'dimensions' => array( array( 'name' => 'pagePath' ) ),
|
||||||
'dimensionFilter' => [
|
'metrics' => array(
|
||||||
'filter' => [
|
array( 'name' => 'screenPageViews' ),
|
||||||
|
array( 'name' => 'averageSessionDuration' ),
|
||||||
|
array( 'name' => 'bounceRate' ),
|
||||||
|
),
|
||||||
|
'dimensionFilter' => array(
|
||||||
|
'filter' => array(
|
||||||
'fieldName' => 'pagePath',
|
'fieldName' => 'pagePath',
|
||||||
'inListFilter' => [ 'values' => $page_paths ],
|
'inListFilter' => array( 'values' => $page_paths ),
|
||||||
],
|
),
|
||||||
],
|
),
|
||||||
];
|
);
|
||||||
|
|
||||||
$response = wp_remote_post(
|
$response = wp_remote_post(
|
||||||
"https://analyticsdata.googleapis.com/v1beta/properties/{$property_id}:runReport",
|
"https://analyticsdata.googleapis.com/v1beta/properties/{$property_id}:runReport",
|
||||||
[
|
array(
|
||||||
'timeout' => 30,
|
'timeout' => 30,
|
||||||
'headers' => [
|
'headers' => array(
|
||||||
'Authorization' => 'Bearer ' . $access_token,
|
'Authorization' => 'Bearer ' . $access_token,
|
||||||
'Content-Type' => 'application/json',
|
'Content-Type' => 'application/json',
|
||||||
],
|
),
|
||||||
'body' => wp_json_encode( $request_body ),
|
'body' => wp_json_encode( $request_body ),
|
||||||
]
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
if ( is_wp_error( $response ) ) {
|
if ( is_wp_error( $response ) ) {
|
||||||
PRAutoBlogger_Logger::instance()->error( 'GA4 API request failed (WP HTTP error): ' . $response->get_error_message(), 'ga4' );
|
PRAutoBlogger_Logger::instance()->error( 'GA4 API request failed (WP HTTP error): ' . $response->get_error_message(), 'ga4' );
|
||||||
return [];
|
return array();
|
||||||
}
|
}
|
||||||
|
|
||||||
$ga4_status = wp_remote_retrieve_response_code( $response );
|
$ga4_status = wp_remote_retrieve_response_code( $response );
|
||||||
@@ -105,20 +112,20 @@ class PRAutoBlogger_GA4_Client {
|
|||||||
sprintf( 'GA4 API request failed (HTTP %d): %s', $ga4_status, substr( $ga4_body, 0, 500 ) ),
|
sprintf( 'GA4 API request failed (HTTP %d): %s', $ga4_status, substr( $ga4_body, 0, 500 ) ),
|
||||||
'ga4'
|
'ga4'
|
||||||
);
|
);
|
||||||
return [];
|
return array();
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = json_decode( wp_remote_retrieve_body( $response ), true );
|
$data = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||||
$results = [];
|
$results = array();
|
||||||
|
|
||||||
foreach ( ( $data['rows'] ?? [] ) as $row ) {
|
foreach ( ( $data['rows'] ?? array() ) as $row ) {
|
||||||
$path = $row['dimensionValues'][0]['value'] ?? '';
|
$path = $row['dimensionValues'][0]['value'] ?? '';
|
||||||
if ( isset( $path_to_id[ $path ] ) ) {
|
if ( isset( $path_to_id[ $path ] ) ) {
|
||||||
$results[ $path_to_id[ $path ] ] = [
|
$results[ $path_to_id[ $path ] ] = array(
|
||||||
'pageviews' => (int) ( $row['metricValues'][0]['value'] ?? 0 ),
|
'pageviews' => (int) ( $row['metricValues'][0]['value'] ?? 0 ),
|
||||||
'avg_time_on_page' => (float) ( $row['metricValues'][1]['value'] ?? 0 ),
|
'avg_time_on_page' => (float) ( $row['metricValues'][1]['value'] ?? 0 ),
|
||||||
'bounce_rate' => (float) ( $row['metricValues'][2]['value'] ?? 0 ),
|
'bounce_rate' => (float) ( $row['metricValues'][2]['value'] ?? 0 ),
|
||||||
];
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,14 +147,21 @@ class PRAutoBlogger_GA4_Client {
|
|||||||
|
|
||||||
// Build JWT for service account auth.
|
// Build JWT for service account auth.
|
||||||
$now = time();
|
$now = time();
|
||||||
$header = wp_json_encode( [ 'alg' => 'RS256', 'typ' => 'JWT' ] );
|
$header = wp_json_encode(
|
||||||
$claim = wp_json_encode( [
|
array(
|
||||||
|
'alg' => 'RS256',
|
||||||
|
'typ' => 'JWT',
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$claim = wp_json_encode(
|
||||||
|
array(
|
||||||
'iss' => $creds['client_email'],
|
'iss' => $creds['client_email'],
|
||||||
'scope' => 'https://www.googleapis.com/auth/analytics.readonly',
|
'scope' => 'https://www.googleapis.com/auth/analytics.readonly',
|
||||||
'aud' => 'https://oauth2.googleapis.com/token',
|
'aud' => 'https://oauth2.googleapis.com/token',
|
||||||
'exp' => $now + 3600,
|
'exp' => $now + 3600,
|
||||||
'iat' => $now,
|
'iat' => $now,
|
||||||
] );
|
)
|
||||||
|
);
|
||||||
|
|
||||||
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
|
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
|
||||||
$base64_header = rtrim( strtr( base64_encode( $header ), '+/', '-_' ), '=' );
|
$base64_header = rtrim( strtr( base64_encode( $header ), '+/', '-_' ), '=' );
|
||||||
@@ -171,13 +185,13 @@ class PRAutoBlogger_GA4_Client {
|
|||||||
|
|
||||||
$response = wp_remote_post(
|
$response = wp_remote_post(
|
||||||
'https://oauth2.googleapis.com/token',
|
'https://oauth2.googleapis.com/token',
|
||||||
[
|
array(
|
||||||
'timeout' => 15,
|
'timeout' => 15,
|
||||||
'body' => [
|
'body' => array(
|
||||||
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||||
'assertion' => $jwt,
|
'assertion' => $jwt,
|
||||||
],
|
),
|
||||||
]
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
if ( is_wp_error( $response ) ) {
|
if ( is_wp_error( $response ) ) {
|
||||||
|
|||||||
71
includes/core/class-generation-checkpoint-helpers.php
Normal file
71
includes/core/class-generation-checkpoint-helpers.php
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Private helpers for PRAutoBlogger_Generation_Checkpoint_Runner (v0.21.0, M4).
|
||||||
|
*
|
||||||
|
* Extracted to keep class-generation-checkpoint-runner.php under the 300-line
|
||||||
|
* cap (CONVENTIONS §1). Contains the three internal-only static methods:
|
||||||
|
* finalize() — write final transient, mark run done, release lock.
|
||||||
|
* cleanup_queue() — delete queue + run-id options on abort paths.
|
||||||
|
* fire_cron_now() — non-blocking loopback to fire cron events immediately.
|
||||||
|
*
|
||||||
|
* Only PRAutoBlogger_Generation_Checkpoint_Runner should call this class.
|
||||||
|
*
|
||||||
|
* @see core/class-generation-checkpoint-runner.php — Caller / orchestrator.
|
||||||
|
* @see ARCHITECTURE.md ss25 — Checkpoint design.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Generation_Checkpoint_Helpers {
|
||||||
|
|
||||||
|
/** Option key for the checkpoint queue (mirrors Runner constant). */
|
||||||
|
private const QUEUE_KEY = 'prautoblogger_article_queue';
|
||||||
|
|
||||||
|
/** Option key for the in-progress run UUID (mirrors Runner constant). */
|
||||||
|
private const RUN_ID_KEY = 'prautoblogger_checkpoint_run_id';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalize a completed run: write final transient, mark run done, release lock.
|
||||||
|
*
|
||||||
|
* @param array $results Generation counters (generated/published/rejected/cost).
|
||||||
|
* @param string $run_id Run UUID (read from option if blank).
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function finalize( array $results, string $run_id = '' ): void {
|
||||||
|
if ( '' === $run_id ) {
|
||||||
|
$run_id = (string) get_option( self::RUN_ID_KEY, '' );
|
||||||
|
}
|
||||||
|
if ( '' !== $run_id ) {
|
||||||
|
PRAutoBlogger_Run_State::mark_status( $run_id, 'done' );
|
||||||
|
}
|
||||||
|
PRAutoBlogger_Pipeline_Status::write_final( $results );
|
||||||
|
PRAutoBlogger_Pipeline_Status::log_summary( $results );
|
||||||
|
PRAutoBlogger_Generation_Lock::release();
|
||||||
|
delete_option( self::RUN_ID_KEY );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove the persistent queue and run-id option on abort paths. */
|
||||||
|
public static function cleanup_queue(): void {
|
||||||
|
delete_option( self::QUEUE_KEY );
|
||||||
|
delete_option( self::RUN_ID_KEY );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-blocking loopback to fire cron events immediately.
|
||||||
|
* Mirrors Pipeline_Runner::schedule_next() exactly.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function fire_cron_now(): void {
|
||||||
|
spawn_cron();
|
||||||
|
wp_remote_post(
|
||||||
|
site_url( 'wp-cron.php?doing_wp_cron=' . sprintf( '%.22F', microtime( true ) ) ),
|
||||||
|
array(
|
||||||
|
'timeout' => 0.01,
|
||||||
|
'blocking' => false,
|
||||||
|
'sslverify' => false,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
267
includes/core/class-generation-checkpoint-runner.php
Normal file
267
includes/core/class-generation-checkpoint-runner.php
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Chained-cron checkpoint runner for in-admin manual article generation (v0.21.0, M4).
|
||||||
|
*
|
||||||
|
* What: Replaces the monolithic on_manual_generation() path with a two-tick
|
||||||
|
* checkpoint machine so Hostinger's background-PHP-process kill cannot
|
||||||
|
* abort a mid-pipeline run:
|
||||||
|
* Tick 1 (ORCHESTRATE): collect sources -> analyze patterns -> score ideas ->
|
||||||
|
* persist idea queue to option -> schedule Tick 2.
|
||||||
|
* Tick 2..N (GENERATE): pop one idea from queue -> Article_Worker ->
|
||||||
|
* update transient -> schedule next tick or finalize.
|
||||||
|
*
|
||||||
|
* Why separate from Rerun_Executor: Rerun_Executor targets existing run_stages
|
||||||
|
* rows (replay/rebuild). This class creates a brand-new run from scratch.
|
||||||
|
*
|
||||||
|
* SSH retirement (v0.21.0): the former `wp eval 'do_action("prautoblogger_manual_generation")'`
|
||||||
|
* workaround fired on_manual_generation() synchronously in one PHP process --
|
||||||
|
* bypassing the nonce and running the full pipeline without checkpoints.
|
||||||
|
* That path is retired: Executor::on_manual_generation() now delegates here,
|
||||||
|
* and `wp prautoblogger generate` queues via kick_off() (see ARCHITECTURE.md ss25).
|
||||||
|
*
|
||||||
|
* Cost governor: Run_State::ceiling_setting() is applied per-run; a halted run
|
||||||
|
* aborts all remaining ticks (same guarantee as Pipeline_Runner's queued-article path).
|
||||||
|
*
|
||||||
|
* Who triggers it: PRAutoBlogger_Executor::on_manual_generation() (cron),
|
||||||
|
* PRAutoBlogger_WP_CLI_Commands (wp prautoblogger generate).
|
||||||
|
* Dependencies: Pipeline_Runner (orchestration), Run_State (run ledger),
|
||||||
|
* Pipeline_Status (transient), Generation_Lock, Cost_Tracker, Logger.
|
||||||
|
*
|
||||||
|
* @see includes/class-executor.php -- Cron/AJAX entry points.
|
||||||
|
* @see core/class-pipeline-runner.php -- Orchestration + article worker.
|
||||||
|
* @see core/class-run-state.php -- Per-run ledger (cost governor).
|
||||||
|
* @see core/class-pipeline-status.php -- Transient broadcast helpers.
|
||||||
|
* @see core/class-generation-checkpoint-helpers.php -- finalize/cleanup/fire helpers.
|
||||||
|
* @see ARCHITECTURE.md ss25 -- Checkpoint generation path design.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Generation_Checkpoint_Runner {
|
||||||
|
|
||||||
|
/** Option key for the checkpoint queue (shared with Pipeline_Runner article queue). */
|
||||||
|
private const QUEUE_KEY = 'prautoblogger_article_queue';
|
||||||
|
|
||||||
|
/** Option key for the in-progress run UUID (generation tick). */
|
||||||
|
private const RUN_ID_KEY = 'prautoblogger_checkpoint_run_id';
|
||||||
|
|
||||||
|
/** WP-Cron action for the orchestration tick. */
|
||||||
|
public const ORCHESTRATE_ACTION = 'prautoblogger_gen_orchestrate';
|
||||||
|
|
||||||
|
/** WP-Cron action for each article-generation tick. */
|
||||||
|
public const GENERATE_ACTION = 'prautoblogger_gen_tick';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule the orchestration tick and write the initial status transient.
|
||||||
|
* Called by on_ajax_generate_now() (via Executor) and by the WP-CLI command.
|
||||||
|
* Returns immediately -- ALL pipeline work happens in cron.
|
||||||
|
*
|
||||||
|
* Side effects: cron scheduling, transient write.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function kick_off(): void {
|
||||||
|
if ( wp_next_scheduled( self::ORCHESTRATE_ACTION ) ) {
|
||||||
|
return; // Idempotent: already queued.
|
||||||
|
}
|
||||||
|
wp_schedule_single_event( time(), self::ORCHESTRATE_ACTION );
|
||||||
|
PRAutoBlogger_Pipeline_Status::write_initial();
|
||||||
|
PRAutoBlogger_Generation_Checkpoint_Helpers::fire_cron_now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron Tick 1: collect -> analyze -> score. Persists ideas, creates run row,
|
||||||
|
* schedules Tick 2 for the first article, then returns.
|
||||||
|
*
|
||||||
|
* Side effects: source/analysis DB writes, run row insert, option write,
|
||||||
|
* status transient update, cron scheduling.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function on_orchestrate_tick(): void {
|
||||||
|
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||||
|
@ignore_user_abort( true );
|
||||||
|
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||||
|
@set_time_limit( 300 );
|
||||||
|
|
||||||
|
if ( ! PRAutoBlogger_Generation_Lock::acquire() ) {
|
||||||
|
PRAutoBlogger_Pipeline_Status::write_error(
|
||||||
|
__( 'Could not acquire generation lock. Another run may be in progress.', 'prautoblogger' )
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$run_id = wp_generate_uuid4();
|
||||||
|
update_option( self::RUN_ID_KEY, $run_id, false );
|
||||||
|
PRAutoBlogger_Run_State::ensure_run( $run_id );
|
||||||
|
|
||||||
|
$cost_tracker = new PRAutoBlogger_Cost_Tracker();
|
||||||
|
$cost_tracker->set_run_id( $run_id );
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ( $cost_tracker->is_budget_exceeded() ) {
|
||||||
|
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
|
||||||
|
PRAutoBlogger_Pipeline_Status::write_error(
|
||||||
|
__( 'Monthly API budget exceeded. Generation halted.', 'prautoblogger' )
|
||||||
|
);
|
||||||
|
PRAutoBlogger_Generation_Lock::release();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Collecting sources…', 'prautoblogger' ) );
|
||||||
|
$runner = ( new PRAutoBlogger_Pipeline_Runner() )->set_skip_dedup( true );
|
||||||
|
$ideas = $runner->orchestrate_only( $cost_tracker );
|
||||||
|
|
||||||
|
if ( empty( $ideas ) ) {
|
||||||
|
PRAutoBlogger_Run_State::mark_status( $run_id, 'done' );
|
||||||
|
PRAutoBlogger_Pipeline_Status::write_final(
|
||||||
|
array(
|
||||||
|
'generated' => 0,
|
||||||
|
'published' => 0,
|
||||||
|
'rejected' => 0,
|
||||||
|
'cost' => 0.0,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
PRAutoBlogger_Generation_Lock::release();
|
||||||
|
delete_option( self::RUN_ID_KEY );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$serialized = array_map(
|
||||||
|
static fn( PRAutoBlogger_Article_Idea $i ) => $i->to_array(),
|
||||||
|
$ideas
|
||||||
|
);
|
||||||
|
update_option(
|
||||||
|
self::QUEUE_KEY,
|
||||||
|
array(
|
||||||
|
'run_id' => $run_id,
|
||||||
|
'ideas' => $serialized,
|
||||||
|
'results' => array(
|
||||||
|
'generated' => 0,
|
||||||
|
'published' => 0,
|
||||||
|
'rejected' => 0,
|
||||||
|
'cost' => 0.0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
PRAutoBlogger_Pipeline_Status::broadcast(
|
||||||
|
sprintf(
|
||||||
|
/* translators: %d: number of ideas found */
|
||||||
|
__( 'Found %d idea(s) — starting generation…', 'prautoblogger' ),
|
||||||
|
count( $ideas )
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( ! wp_next_scheduled( self::GENERATE_ACTION ) ) {
|
||||||
|
wp_schedule_single_event( time(), self::GENERATE_ACTION );
|
||||||
|
}
|
||||||
|
PRAutoBlogger_Generation_Checkpoint_Helpers::fire_cron_now();
|
||||||
|
|
||||||
|
} catch ( \Throwable $e ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->error(
|
||||||
|
sprintf( 'Orchestrate tick %s: %s', get_class( $e ), $e->getMessage() ),
|
||||||
|
'pipeline'
|
||||||
|
);
|
||||||
|
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
|
||||||
|
PRAutoBlogger_Pipeline_Status::write_error( $e->getMessage() );
|
||||||
|
PRAutoBlogger_Generation_Lock::release();
|
||||||
|
delete_option( self::RUN_ID_KEY );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron Tick 2..N: generate one article, persist totals, reschedule or finalize.
|
||||||
|
*
|
||||||
|
* Side effects: LLM API calls, DB writes, post creation, transient updates,
|
||||||
|
* cron scheduling.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function on_generate_tick(): void {
|
||||||
|
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||||
|
@ignore_user_abort( true );
|
||||||
|
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||||
|
@set_time_limit( 300 );
|
||||||
|
|
||||||
|
$queue = get_option( self::QUEUE_KEY );
|
||||||
|
if ( ! is_array( $queue ) || empty( $queue['ideas'] ) ) {
|
||||||
|
PRAutoBlogger_Generation_Checkpoint_Helpers::finalize(
|
||||||
|
array(
|
||||||
|
'generated' => 0,
|
||||||
|
'published' => 0,
|
||||||
|
'rejected' => 0,
|
||||||
|
'cost' => 0.0,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$run_id = (string) ( $queue['run_id'] ?? '' );
|
||||||
|
$run_status = PRAutoBlogger_Run_State::get_status( $run_id );
|
||||||
|
if ( in_array( $run_status, array( 'halted', 'failed' ), true ) ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->warning(
|
||||||
|
sprintf( 'Generate tick: run %s is %s -- aborting.', $run_id, $run_status ),
|
||||||
|
'pipeline'
|
||||||
|
);
|
||||||
|
PRAutoBlogger_Generation_Checkpoint_Helpers::cleanup_queue();
|
||||||
|
PRAutoBlogger_Generation_Lock::release();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$idea_data = array_shift( $queue['ideas'] );
|
||||||
|
$idea = new PRAutoBlogger_Article_Idea( $idea_data );
|
||||||
|
$cost_tracker = new PRAutoBlogger_Cost_Tracker();
|
||||||
|
$cost_tracker->set_run_id( $run_id );
|
||||||
|
|
||||||
|
if ( $cost_tracker->is_budget_exceeded() ) {
|
||||||
|
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
|
||||||
|
PRAutoBlogger_Generation_Checkpoint_Helpers::cleanup_queue();
|
||||||
|
PRAutoBlogger_Generation_Lock::release();
|
||||||
|
PRAutoBlogger_Pipeline_Status::write_error(
|
||||||
|
__( 'Monthly API budget exceeded. Generation halted.', 'prautoblogger' )
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist consumed queue BEFORE generating (prevents orphan-recovery duplication).
|
||||||
|
if ( ! empty( $queue['ideas'] ) ) {
|
||||||
|
update_option( self::QUEUE_KEY, $queue, false );
|
||||||
|
} else {
|
||||||
|
delete_option( self::QUEUE_KEY );
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$worker = new PRAutoBlogger_Article_Worker( $cost_tracker );
|
||||||
|
$result = $worker->generate( $idea );
|
||||||
|
|
||||||
|
$queue['results']['generated'] += $result['generated'];
|
||||||
|
$queue['results']['published'] += $result['published'];
|
||||||
|
$queue['results']['rejected'] += $result['rejected'];
|
||||||
|
$queue['results']['cost'] += $result['cost'];
|
||||||
|
|
||||||
|
if ( ! empty( $queue['ideas'] ) ) {
|
||||||
|
update_option( self::QUEUE_KEY, $queue, false );
|
||||||
|
PRAutoBlogger_Pipeline_Status::update_queue_progress( $queue );
|
||||||
|
if ( ! wp_next_scheduled( self::GENERATE_ACTION ) ) {
|
||||||
|
wp_schedule_single_event( time(), self::GENERATE_ACTION );
|
||||||
|
}
|
||||||
|
PRAutoBlogger_Generation_Checkpoint_Helpers::fire_cron_now();
|
||||||
|
} else {
|
||||||
|
PRAutoBlogger_Post_Assembler::amortize_research_costs( $run_id );
|
||||||
|
PRAutoBlogger_Generation_Checkpoint_Helpers::finalize( $queue['results'], $run_id );
|
||||||
|
}
|
||||||
|
} catch ( \Throwable $e ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->error(
|
||||||
|
sprintf( 'Generate tick %s for run %s: %s', get_class( $e ), $run_id, $e->getMessage() ),
|
||||||
|
'pipeline'
|
||||||
|
);
|
||||||
|
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
|
||||||
|
PRAutoBlogger_Generation_Checkpoint_Helpers::cleanup_queue();
|
||||||
|
PRAutoBlogger_Generation_Lock::release();
|
||||||
|
PRAutoBlogger_Pipeline_Status::write_error( $e->getMessage() );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,27 +2,30 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
* Ranks and deduplicates article ideas from analysis results.
|
* Ranks and deduplicates article ideas from analysis results.
|
||||||
*
|
*
|
||||||
* Compares against existing published posts to avoid topic overlap,
|
* Uses PRAutoBlogger_Semantic_Dedup for similarity detection — embedding-based
|
||||||
* scores ideas by relevance, frequency, and freshness, then returns
|
* cosine similarity with automatic fallback to keyword overlap if the embedding
|
||||||
* the top N ideas matching the daily target.
|
* API is unavailable.
|
||||||
*
|
*
|
||||||
* Triggered by: PRAutoBlogger::run_generation_pipeline() (step 3).
|
* Triggered by: PRAutoBlogger_Pipeline_Runner (step 3).
|
||||||
* Dependencies: WordPress $wpdb, WP_Query.
|
* Dependencies: Semantic_Dedup, Keyword_Extractor, 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-content-analyzer.php — Produces the analysis results we consume.
|
* @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 core/class-content-generator.php — Consumes the ranked ideas we produce.
|
||||||
* @see ARCHITECTURE.md — Data flow step 3.
|
* @see ARCHITECTURE.md — Data flow step 3.
|
||||||
*/
|
*/
|
||||||
class PRAutoBlogger_Idea_Scorer {
|
class PRAutoBlogger_Idea_Scorer {
|
||||||
|
|
||||||
/** @var bool Whether to skip the has_similar_post() check. */
|
/** @var bool Whether to skip deduplication entirely. */
|
||||||
private bool $skip_dedup = false;
|
private bool $skip_dedup = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param bool $skip True to skip deduplication.
|
* @param bool $skip True to skip deduplication.
|
||||||
*
|
|
||||||
* @return $this
|
* @return $this
|
||||||
*/
|
*/
|
||||||
public function set_skip_dedup( bool $skip ): self {
|
public function set_skip_dedup( bool $skip ): self {
|
||||||
@@ -33,34 +36,66 @@ class PRAutoBlogger_Idea_Scorer {
|
|||||||
/**
|
/**
|
||||||
* Score, deduplicate, and rank article ideas.
|
* Score, deduplicate, and rank article ideas.
|
||||||
*
|
*
|
||||||
* @param PRAutoBlogger_Analysis_Result[] $analysis_results Raw analysis output.
|
* Initializes the semantic dedup engine once, then checks each candidate
|
||||||
* @param int $target_count Number of ideas to return.
|
* against (1) intra-batch duplicates and (2) recently published posts.
|
||||||
*
|
*
|
||||||
* @return PRAutoBlogger_Article_Idea[] Top-ranked ideas, sorted by score descending.
|
* Side effects: embedding API calls (via Semantic_Dedup), WP_Query.
|
||||||
|
*
|
||||||
|
* @param PRAutoBlogger_Analysis_Result[] $analysis_results Raw analysis output.
|
||||||
|
* @param int $target_count Max ideas to return.
|
||||||
|
*
|
||||||
|
* @return PRAutoBlogger_Article_Idea[] Top-ranked ideas, sorted by score desc.
|
||||||
*/
|
*/
|
||||||
public function score_and_rank( array $analysis_results, int $target_count ): array {
|
public function score_and_rank( array $analysis_results, int $target_count ): array {
|
||||||
$exclusions = json_decode( get_option( 'prautoblogger_topic_exclusions', '[]' ), true ) ?: [];
|
$exclusions = json_decode( get_option( 'prautoblogger_topic_exclusions', '[]' ), true ) ?? array();
|
||||||
$ideas = [];
|
$ideas = array();
|
||||||
|
$excluded_count = 0;
|
||||||
|
$deduped_count = 0;
|
||||||
|
|
||||||
|
$dedup = new PRAutoBlogger_Semantic_Dedup();
|
||||||
|
if ( ! $this->skip_dedup ) {
|
||||||
|
$dedup->initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyword sets kept for fallback (passed through to Semantic_Dedup).
|
||||||
|
$accepted_keyword_sets = array();
|
||||||
|
|
||||||
foreach ( $analysis_results as $result ) {
|
foreach ( $analysis_results as $result ) {
|
||||||
// Skip excluded topics.
|
|
||||||
if ( $this->is_excluded( $result->get_topic(), $exclusions ) ) {
|
if ( $this->is_excluded( $result->get_topic(), $exclusions ) ) {
|
||||||
|
++$excluded_count;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if we've already published something very similar
|
if ( ! $this->skip_dedup ) {
|
||||||
// (unless dedup is disabled for manual runs).
|
$topic = $result->get_topic();
|
||||||
if ( ! $this->skip_dedup && $this->has_similar_post( $result->get_topic() ) ) {
|
$topic_keywords = PRAutoBlogger_Keyword_Extractor::extract( $topic );
|
||||||
|
|
||||||
|
if ( $dedup->is_batch_duplicate( $topic, $topic_keywords, $accepted_keyword_sets ) ) {
|
||||||
|
++$deduped_count;
|
||||||
PRAutoBlogger_Logger::instance()->info(
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
sprintf( 'Skipping topic "%s" — similar post exists.', substr( $result->get_topic(), 0, 80 ) ),
|
sprintf( 'Dedup(batch): skipping "%s"', substr( $topic, 0, 80 ) ),
|
||||||
'scorer'
|
'scorer'
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$metadata = $result->get_metadata() ?? [];
|
if ( $dedup->is_recent_duplicate( $topic, $topic_keywords ) ) {
|
||||||
|
++$deduped_count;
|
||||||
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
|
sprintf( 'Dedup(recent): skipping "%s"', substr( $topic, 0, 80 ) ),
|
||||||
|
'scorer'
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$ideas[] = new PRAutoBlogger_Article_Idea( [
|
$accepted_keyword_sets[] = $topic_keywords;
|
||||||
|
$dedup->record_accepted( $topic );
|
||||||
|
}
|
||||||
|
|
||||||
|
$metadata = $result->get_metadata() ?? array();
|
||||||
|
|
||||||
|
$ideas[] = new PRAutoBlogger_Article_Idea(
|
||||||
|
array(
|
||||||
'topic' => $result->get_topic(),
|
'topic' => $result->get_topic(),
|
||||||
'article_type' => $this->map_type( $result->get_analysis_type() ),
|
'article_type' => $this->map_type( $result->get_analysis_type() ),
|
||||||
'suggested_title' => $metadata['suggested_title'] ?? $result->get_topic(),
|
'suggested_title' => $metadata['suggested_title'] ?? $result->get_topic(),
|
||||||
@@ -68,15 +103,30 @@ class PRAutoBlogger_Idea_Scorer {
|
|||||||
'score' => $this->compute_score( $result ),
|
'score' => $this->compute_score( $result ),
|
||||||
'analysis_id' => $result->get_id(),
|
'analysis_id' => $result->get_id(),
|
||||||
'source_ids' => $result->get_source_ids(),
|
'source_ids' => $result->get_source_ids(),
|
||||||
'key_points' => $metadata['key_points'] ?? [],
|
'key_points' => $metadata['key_points'] ?? array(),
|
||||||
'target_keywords' => $metadata['target_keywords'] ?? [],
|
'target_keywords' => $metadata['target_keywords'] ?? array(),
|
||||||
] );
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by score descending.
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
usort( $ideas, static function ( PRAutoBlogger_Article_Idea $a, PRAutoBlogger_Article_Idea $b ): int {
|
sprintf(
|
||||||
|
'Scorer: %d input → %d viable, %d deduped, %d excluded. Returning top %d.',
|
||||||
|
count( $analysis_results ),
|
||||||
|
count( $ideas ),
|
||||||
|
$deduped_count,
|
||||||
|
$excluded_count,
|
||||||
|
min( $target_count, count( $ideas ) )
|
||||||
|
),
|
||||||
|
'scorer'
|
||||||
|
);
|
||||||
|
|
||||||
|
usort(
|
||||||
|
$ideas,
|
||||||
|
static function ( PRAutoBlogger_Article_Idea $a, PRAutoBlogger_Article_Idea $b ): int {
|
||||||
return $b->get_score() <=> $a->get_score();
|
return $b->get_score() <=> $a->get_score();
|
||||||
} );
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return array_slice( $ideas, 0, $target_count );
|
return array_slice( $ideas, 0, $target_count );
|
||||||
}
|
}
|
||||||
@@ -87,14 +137,12 @@ class PRAutoBlogger_Idea_Scorer {
|
|||||||
* Factors: LLM relevance score (50%), frequency (30%), recency bonus (20%).
|
* Factors: LLM relevance score (50%), frequency (30%), recency bonus (20%).
|
||||||
*
|
*
|
||||||
* @param PRAutoBlogger_Analysis_Result $result
|
* @param PRAutoBlogger_Analysis_Result $result
|
||||||
*
|
|
||||||
* @return float Score between 0 and 1.
|
* @return float Score between 0 and 1.
|
||||||
*/
|
*/
|
||||||
private function compute_score( PRAutoBlogger_Analysis_Result $result ): float {
|
private function compute_score( PRAutoBlogger_Analysis_Result $result ): float {
|
||||||
$relevance = min( $result->get_relevance_score(), 1.0 );
|
$relevance = min( $result->get_relevance_score(), 1.0 );
|
||||||
$frequency = min( $result->get_frequency() / 10.0, 1.0 );
|
$frequency = min( $result->get_frequency() / 10.0, 1.0 );
|
||||||
|
|
||||||
// Recency: analyzed within last 24h gets full bonus.
|
|
||||||
$hours_ago = ( time() - strtotime( $result->get_analyzed_at() ) ) / 3600.0;
|
$hours_ago = ( time() - strtotime( $result->get_analyzed_at() ) ) / 3600.0;
|
||||||
$recency = max( 0.0, 1.0 - ( $hours_ago / 48.0 ) );
|
$recency = max( 0.0, 1.0 - ( $hours_ago / 48.0 ) );
|
||||||
|
|
||||||
@@ -106,7 +154,6 @@ class PRAutoBlogger_Idea_Scorer {
|
|||||||
*
|
*
|
||||||
* @param string $topic Topic text.
|
* @param string $topic Topic text.
|
||||||
* @param string[] $exclusions List of excluded terms/phrases.
|
* @param string[] $exclusions List of excluded terms/phrases.
|
||||||
*
|
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
private function is_excluded( string $topic, array $exclusions ): bool {
|
private function is_excluded( string $topic, array $exclusions ): bool {
|
||||||
@@ -119,44 +166,20 @@ class PRAutoBlogger_Idea_Scorer {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a substantially similar post already exists.
|
|
||||||
*
|
|
||||||
* Uses a simple keyword overlap check against existing published post titles.
|
|
||||||
*
|
|
||||||
* @param string $topic Topic to check.
|
|
||||||
*
|
|
||||||
* @return bool True if a similar post exists.
|
|
||||||
*/
|
|
||||||
private function has_similar_post( string $topic ): bool {
|
|
||||||
$query = new \WP_Query( [
|
|
||||||
'post_type' => 'post',
|
|
||||||
'post_status' => [ 'publish', 'draft', 'pending' ],
|
|
||||||
'meta_key' => '_prautoblogger_generated',
|
|
||||||
'meta_value' => '1',
|
|
||||||
's' => $topic,
|
|
||||||
'posts_per_page' => 1,
|
|
||||||
'fields' => 'ids',
|
|
||||||
] );
|
|
||||||
|
|
||||||
return $query->found_posts > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map analysis type to a reader-friendly article type.
|
* Map analysis type to a reader-friendly article type.
|
||||||
*
|
*
|
||||||
* @param string $analysis_type 'question', 'complaint', 'comparison'.
|
* @param string $analysis_type 'question', 'complaint', 'comparison', etc.
|
||||||
*
|
|
||||||
* @return string Article type label.
|
* @return string Article type label.
|
||||||
*/
|
*/
|
||||||
private function map_type( string $analysis_type ): string {
|
private function map_type( string $analysis_type ): string {
|
||||||
$map = [
|
$map = array(
|
||||||
'question' => 'guide',
|
'question' => 'guide',
|
||||||
'complaint' => 'solution',
|
'complaint' => 'solution',
|
||||||
'comparison' => 'comparison',
|
'comparison' => 'comparison',
|
||||||
'news' => 'news',
|
'news' => 'news',
|
||||||
'guide' => 'guide',
|
'guide' => 'guide',
|
||||||
];
|
);
|
||||||
return $map[ $analysis_type ] ?? 'article';
|
return $map[ $analysis_type ] ?? 'article';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
169
includes/core/class-image-attacher.php
Normal file
169
includes/core/class-image-attacher.php
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Persists generated images and composed variants for a post.
|
||||||
|
*
|
||||||
|
* Owns the media-library side of the image pipeline: sideloading the primary
|
||||||
|
* (featured) image with cost logging, sideloading composed OG/square variants
|
||||||
|
* with role/base meta-linking (no cost-tracker entries — composition is $0
|
||||||
|
* local CPU), and prepending the editable HTML caption to the post. Extracted
|
||||||
|
* from PRAutoBlogger_Image_Pipeline in v0.17.0 for 300-line compliance.
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger_Image_Pipeline during content generation run.
|
||||||
|
* Dependencies: PRAutoBlogger_Image_Media_Sideloader, PRAutoBlogger_Cost_Tracker,
|
||||||
|
* PRAutoBlogger_Logger.
|
||||||
|
*
|
||||||
|
* @see core/class-image-pipeline.php — Sole caller.
|
||||||
|
* @see core/class-image-composer.php — Produces the variants this persists.
|
||||||
|
* @see core/class-image-media-sideloader.php — Media library import.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Image_Attacher {
|
||||||
|
|
||||||
|
/** Post meta key per composed variant role. */
|
||||||
|
private const ROLE_POST_META = array(
|
||||||
|
'og' => '_prautoblogger_og_image_id',
|
||||||
|
'square' => '_prautoblogger_square_image_id',
|
||||||
|
);
|
||||||
|
|
||||||
|
/** @var PRAutoBlogger_Image_Media_Sideloader Imports bytes into the media library. */
|
||||||
|
private PRAutoBlogger_Image_Media_Sideloader $sideloader;
|
||||||
|
|
||||||
|
/** @var PRAutoBlogger_Cost_Tracker Logs primary image generation spend. */
|
||||||
|
private PRAutoBlogger_Cost_Tracker $cost_tracker;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construct with the pipeline's sideloader + cost tracker.
|
||||||
|
*
|
||||||
|
* @param PRAutoBlogger_Image_Media_Sideloader $sideloader Media importer.
|
||||||
|
* @param PRAutoBlogger_Cost_Tracker $cost_tracker Spend logger.
|
||||||
|
*/
|
||||||
|
public function __construct( PRAutoBlogger_Image_Media_Sideloader $sideloader, PRAutoBlogger_Cost_Tracker $cost_tracker ) {
|
||||||
|
$this->sideloader = $sideloader;
|
||||||
|
$this->cost_tracker = $cost_tracker;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sideload a primary image into the media library and log its cost.
|
||||||
|
*
|
||||||
|
* Side effects: media import, cost-tracker row, info log.
|
||||||
|
*
|
||||||
|
* @param array $image_data Provider result with bytes, model, cost_usd.
|
||||||
|
* @param int $post_id Post ID.
|
||||||
|
* @param string $slot Cost-tracking slot ('image_a' or 'image_b').
|
||||||
|
* @param string $label Human-readable label for logs.
|
||||||
|
* @param string $alt_text Alt text — the editorial caption (v0.17.0 bug
|
||||||
|
* fix: previously the model name leaked here).
|
||||||
|
*
|
||||||
|
* @return int|\WP_Error Attachment ID on success.
|
||||||
|
*/
|
||||||
|
public function sideload_and_log( array $image_data, int $post_id, string $slot, string $label, string $alt_text = '' ) {
|
||||||
|
$attachment_id = $this->sideloader->sideload_image( $image_data, $post_id, $alt_text );
|
||||||
|
|
||||||
|
if ( is_wp_error( $attachment_id ) ) {
|
||||||
|
return $attachment_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cost_tracker->log_image_generation(
|
||||||
|
$image_data['cost_usd'],
|
||||||
|
$image_data['model'] ?? 'unknown',
|
||||||
|
$post_id,
|
||||||
|
$slot
|
||||||
|
);
|
||||||
|
|
||||||
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
|
sprintf( '%s for post %d (att %d, $%.4f)', $label, $post_id, $attachment_id, $image_data['cost_usd'] ),
|
||||||
|
'image_pipeline'
|
||||||
|
);
|
||||||
|
|
||||||
|
return $attachment_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sideload composed variants and meta-link them to post + base image.
|
||||||
|
*
|
||||||
|
* Each variant attachment gets `_prautoblogger_image_role` and
|
||||||
|
* `_prautoblogger_base_attachment_id`; the post gets the role-specific
|
||||||
|
* ID meta (see ROLE_POST_META). No cost-tracker entries — composition is
|
||||||
|
* a $0 local stage. A failed variant sideload logs a WARNING and is
|
||||||
|
* skipped; it never blocks the publish.
|
||||||
|
*
|
||||||
|
* @param array $variants Composed variants (role 'og'/'square' only are persisted).
|
||||||
|
* @param int $post_id Post ID.
|
||||||
|
* @param int $base_attachment_id Featured attachment the variants derive from.
|
||||||
|
* @param string $caption Editorial caption (used as variant alt text).
|
||||||
|
* @param string $model Generation model recorded on the variant attachment.
|
||||||
|
*/
|
||||||
|
public function attach_variants( array $variants, int $post_id, int $base_attachment_id, string $caption, string $model ): void {
|
||||||
|
foreach ( $variants as $variant ) {
|
||||||
|
$role = (string) ( $variant['role'] ?? '' );
|
||||||
|
if ( ! isset( self::ROLE_POST_META[ $role ] ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attachment_id = $this->sideloader->sideload_image(
|
||||||
|
array(
|
||||||
|
'bytes' => (string) ( $variant['bytes'] ?? '' ),
|
||||||
|
'mime_type' => (string) ( $variant['mime_type'] ?? 'image/png' ),
|
||||||
|
'width' => (int) ( $variant['width'] ?? 0 ),
|
||||||
|
'height' => (int) ( $variant['height'] ?? 0 ),
|
||||||
|
'model' => $model,
|
||||||
|
'cost_usd' => 0.0,
|
||||||
|
'latency_ms' => 0,
|
||||||
|
),
|
||||||
|
$post_id,
|
||||||
|
$caption,
|
||||||
|
$role
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( is_wp_error( $attachment_id ) ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->warning(
|
||||||
|
sprintf( 'Failed to sideload %s variant for post %d: %s', $role, $post_id, $attachment_id->get_error_message() ),
|
||||||
|
'image_composer'
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
update_post_meta( $attachment_id, '_prautoblogger_image_role', $role );
|
||||||
|
update_post_meta( $attachment_id, '_prautoblogger_base_attachment_id', $base_attachment_id );
|
||||||
|
update_post_meta( $post_id, self::ROLE_POST_META[ $role ], $attachment_id );
|
||||||
|
|
||||||
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
|
sprintf( '%s variant for post %d (att %d, %dx%d)', $role, $post_id, $attachment_id, (int) $variant['width'], (int) $variant['height'] ),
|
||||||
|
'image_composer'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepend the editorial caption as styled text at the top of the post.
|
||||||
|
*
|
||||||
|
* Only inserts the caption — NOT the image. The theme already displays
|
||||||
|
* the featured image, so embedding it again would cause a duplicate.
|
||||||
|
*
|
||||||
|
* @param int $post_id Post ID.
|
||||||
|
* @param int $attachment_id Featured image attachment ID (unused; kept for parity with meta storage).
|
||||||
|
* @param string $caption Caption text shown below the image.
|
||||||
|
*/
|
||||||
|
public function prepend_caption_to_post( int $post_id, int $attachment_id, string $caption ): void {
|
||||||
|
$post = get_post( $post_id );
|
||||||
|
if ( ! $post ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caption-only block — the theme handles the featured image display.
|
||||||
|
$caption_html = sprintf(
|
||||||
|
'<p class="prautoblogger-comic-caption" style="text-align:center;font-style:italic;color:#555;font-size:1.1em;margin:0 0 2em 0;">— "%s"</p>',
|
||||||
|
esc_html( $caption )
|
||||||
|
);
|
||||||
|
|
||||||
|
wp_update_post(
|
||||||
|
array(
|
||||||
|
'ID' => $post_id,
|
||||||
|
'post_content' => $caption_html . "\n\n" . $post->post_content,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
186
includes/core/class-image-composer-canvas.php
Normal file
186
includes/core/class-image-composer-canvas.php
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Low-level Imagick canvas primitives for the deterministic image composer.
|
||||||
|
*
|
||||||
|
* Capability smoke test, base-image loading, cover-cropping, logo placement,
|
||||||
|
* and deterministic PNG encoding. No WordPress functions anywhere in this
|
||||||
|
* class — it runs unchanged inside the standalone render harness used to
|
||||||
|
* produce the PR sample images.
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger_Image_Composer_Imagick (renderer) per variant.
|
||||||
|
* Dependencies: ext-imagick (callers must gate on is_imagick_usable()).
|
||||||
|
*
|
||||||
|
* @see core/class-image-composer-imagick.php — Sole consumer.
|
||||||
|
* @see core/class-image-composer.php — Caches the capability probe result.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Image_Composer_Canvas {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe whether Imagick can do everything the renderer needs: PNG
|
||||||
|
* encode plus TTF text annotation (the smoke test draws one glyph on a
|
||||||
|
* tiny in-memory canvas). Cheap to run but callers should cache it.
|
||||||
|
*
|
||||||
|
* Side effects: none beyond a transient in-memory Imagick object.
|
||||||
|
*
|
||||||
|
* @param string $font_path Absolute path to a bundled TTF used for the smoke test.
|
||||||
|
*
|
||||||
|
* @return bool True when full compositing is available.
|
||||||
|
*/
|
||||||
|
public static function is_imagick_usable( string $font_path ): bool {
|
||||||
|
if ( ! extension_loaded( 'imagick' ) || ! class_exists( '\Imagick' ) ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$formats = \Imagick::queryFormats( 'PNG' );
|
||||||
|
if ( empty( $formats ) ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! is_readable( $font_path ) ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$probe = new \Imagick();
|
||||||
|
$probe->newImage( 8, 8, new \ImagickPixel( '#ffffff' ), 'png' );
|
||||||
|
$draw = new \ImagickDraw();
|
||||||
|
$draw->setFont( $font_path );
|
||||||
|
$draw->setFontSize( 6 );
|
||||||
|
$probe->annotateImage( $draw, 1, 6, 0.0, 'P' );
|
||||||
|
$probe->clear();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch ( \Throwable $e ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load base image bytes into an Imagick object (first frame only).
|
||||||
|
*
|
||||||
|
* @param string $bytes Raw image bytes (PNG/JPEG/WebP — any readable format).
|
||||||
|
*
|
||||||
|
* @return \Imagick Loaded image.
|
||||||
|
* @throws \RuntimeException When the bytes cannot be decoded.
|
||||||
|
*/
|
||||||
|
public static function load_base( string $bytes ): \Imagick {
|
||||||
|
$image = new \Imagick();
|
||||||
|
if ( ! $image->readImageBlob( $bytes ) ) {
|
||||||
|
throw new \RuntimeException( 'Composer could not decode base image bytes.' );
|
||||||
|
}
|
||||||
|
$image->setIteratorIndex( 0 );
|
||||||
|
$image = $image->coalesceImages()->current();
|
||||||
|
$image->setImagePage( 0, 0, 0, 0 );
|
||||||
|
|
||||||
|
return $image;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Center cover-crop an image to exact target dimensions without upscaling.
|
||||||
|
*
|
||||||
|
* Crops the largest centered region matching the target aspect ratio,
|
||||||
|
* then scales DOWN to the target. Refuses (returns false) when the source
|
||||||
|
* is too small to fill the target without upscaling.
|
||||||
|
*
|
||||||
|
* Side effects: mutates $image in place on success.
|
||||||
|
*
|
||||||
|
* @param \Imagick $image Source image (mutated).
|
||||||
|
* @param int $target_w Target width in px.
|
||||||
|
* @param int $target_h Target height in px.
|
||||||
|
*
|
||||||
|
* @return bool False when the crop would require upscaling.
|
||||||
|
*/
|
||||||
|
public static function cover_crop( \Imagick $image, int $target_w, int $target_h ): bool {
|
||||||
|
$width = $image->getImageWidth();
|
||||||
|
$height = $image->getImageHeight();
|
||||||
|
if ( $width < 1 || $height < 1 ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ratio = max( $target_w / $width, $target_h / $height );
|
||||||
|
if ( $ratio > 1.0 ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$src_w = min( $width, (int) round( $target_w / $ratio ) );
|
||||||
|
$src_h = min( $height, (int) round( $target_h / $ratio ) );
|
||||||
|
$x = (int) floor( ( $width - $src_w ) / 2 );
|
||||||
|
$y = (int) floor( ( $height - $src_h ) / 2 );
|
||||||
|
|
||||||
|
$image->cropImage( $src_w, $src_h, $x, $y );
|
||||||
|
$image->setImagePage( 0, 0, 0, 0 );
|
||||||
|
$image->resizeImage( $target_w, $target_h, \Imagick::FILTER_LANCZOS, 1 );
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composite a brand PNG onto the canvas at a target height and opacity.
|
||||||
|
*
|
||||||
|
* @param \Imagick $canvas Destination canvas (mutated).
|
||||||
|
* @param string $asset_path Absolute path to the brand PNG.
|
||||||
|
* @param int $target_h Logo height in px (width keeps aspect).
|
||||||
|
* @param int $x Left position on the canvas.
|
||||||
|
* @param int $y Top position on the canvas.
|
||||||
|
* @param float $opacity 1.0 = opaque, 0.55 = subtle corner mark.
|
||||||
|
*
|
||||||
|
* @return int Rendered logo width in px (useful for caption x-offsets).
|
||||||
|
* @throws \RuntimeException When the asset is missing or unreadable.
|
||||||
|
*/
|
||||||
|
public static function place_logo( \Imagick $canvas, string $asset_path, int $target_h, int $x, int $y, float $opacity = 1.0 ): int {
|
||||||
|
if ( ! is_readable( $asset_path ) ) {
|
||||||
|
throw new \RuntimeException( 'Composer brand asset missing: ' . basename( $asset_path ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$logo = new \Imagick( $asset_path );
|
||||||
|
$logo->setImagePage( 0, 0, 0, 0 );
|
||||||
|
$scale = $target_h / max( 1, $logo->getImageHeight() );
|
||||||
|
$target_w = max( 1, (int) round( $logo->getImageWidth() * $scale ) );
|
||||||
|
$logo->resizeImage( $target_w, $target_h, \Imagick::FILTER_LANCZOS, 1 );
|
||||||
|
|
||||||
|
if ( $opacity < 1.0 ) {
|
||||||
|
$logo->evaluateImage( \Imagick::EVALUATE_MULTIPLY, max( 0.0, $opacity ), \Imagick::CHANNEL_ALPHA );
|
||||||
|
}
|
||||||
|
|
||||||
|
$canvas->compositeImage( $logo, \Imagick::COMPOSITE_OVER, $x, $y );
|
||||||
|
$logo->clear();
|
||||||
|
|
||||||
|
return $target_w;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a canvas as a deterministic PNG variant payload.
|
||||||
|
*
|
||||||
|
* Strips all metadata and excludes PNG date/time chunks so identical
|
||||||
|
* inputs produce byte-identical output in the same environment.
|
||||||
|
*
|
||||||
|
* Side effects: destroys the Imagick object after encoding.
|
||||||
|
*
|
||||||
|
* @param \Imagick $image Finished canvas.
|
||||||
|
* @param string $role Variant role: 'featured', 'og', or 'square'.
|
||||||
|
*
|
||||||
|
* @return array{bytes: string, mime_type: string, width: int, height: int, role: string}
|
||||||
|
*/
|
||||||
|
public static function finalize_png( \Imagick $image, string $role ): array {
|
||||||
|
$image->setImageFormat( 'png' );
|
||||||
|
$image->setImageDepth( 8 );
|
||||||
|
$image->stripImage();
|
||||||
|
$image->setOption( 'png:exclude-chunks', 'date,time' );
|
||||||
|
$image->setOption( 'png:compression-level', '9' );
|
||||||
|
|
||||||
|
$variant = array(
|
||||||
|
'bytes' => $image->getImageBlob(),
|
||||||
|
'mime_type' => 'image/png',
|
||||||
|
'width' => $image->getImageWidth(),
|
||||||
|
'height' => $image->getImageHeight(),
|
||||||
|
'role' => $role,
|
||||||
|
);
|
||||||
|
$image->clear();
|
||||||
|
|
||||||
|
return $variant;
|
||||||
|
}
|
||||||
|
}
|
||||||
114
includes/core/class-image-composer-editor.php
Normal file
114
includes/core/class-image-composer-editor.php
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Resize-only composer rung backed by wp_get_image_editor() (GD path).
|
||||||
|
*
|
||||||
|
* Used when Imagick is unusable: emits unbranded, caption-free center-crops
|
||||||
|
* at each variant's exact dimensions. Variants that would require upscaling
|
||||||
|
* (e.g. a 1080×1080 square from a 1200×632 base — WP's editor never upscales
|
||||||
|
* on crop-resize) are skipped; the OG crop is always achievable from the
|
||||||
|
* standard base. Output is GD PNG, which embeds no timestamps, so this rung
|
||||||
|
* is deterministic too.
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger_Image_Composer when the capability probe says 'gd'.
|
||||||
|
* Dependencies: wp_get_image_editor(), get_temp_dir(), PRAutoBlogger_Logger.
|
||||||
|
*
|
||||||
|
* @see core/class-image-composer.php — Orchestrator + degradation ladder.
|
||||||
|
* @see core/class-image-composer-imagick.php — The full-fidelity rung this replaces.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Image_Composer_Editor {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render resize-only variants for the requested roles.
|
||||||
|
*
|
||||||
|
* Side effects: temp file write/delete per variant; debug log on skips.
|
||||||
|
*
|
||||||
|
* @param string $bytes Base image bytes.
|
||||||
|
* @param string[] $roles Variant roles to attempt ('og', 'square').
|
||||||
|
* @param array<string, array<string, mixed>> $layout Layout geometry (for target dimensions).
|
||||||
|
*
|
||||||
|
* @return array<int, array{bytes: string, mime_type: string, width: int, height: int, role: string}>
|
||||||
|
* Successfully resized variants only (possibly empty).
|
||||||
|
*/
|
||||||
|
public function compose_variants( string $bytes, array $roles, array $layout ): array {
|
||||||
|
$variants = array();
|
||||||
|
|
||||||
|
foreach ( $roles as $role ) {
|
||||||
|
$target_w = (int) ( $layout[ $role ]['width'] ?? 0 );
|
||||||
|
$target_h = (int) ( $layout[ $role ]['height'] ?? 0 );
|
||||||
|
if ( $target_w < 1 || $target_h < 1 ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resized = $this->cover_crop( $bytes, $target_w, $target_h );
|
||||||
|
if ( null === $resized ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->debug(
|
||||||
|
sprintf( 'Resize-only %s variant skipped (editor failure or would upscale).', $role ),
|
||||||
|
'image_composer'
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resized['role'] = $role;
|
||||||
|
$variants[] = $resized;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $variants;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Center-crop bytes to exact dimensions with the portable WP editor.
|
||||||
|
*
|
||||||
|
* @param string $bytes Source image bytes.
|
||||||
|
* @param int $target_w Target width in px.
|
||||||
|
* @param int $target_h Target height in px.
|
||||||
|
*
|
||||||
|
* @return array{bytes: string, mime_type: string, width: int, height: int}|null
|
||||||
|
* Null when the editor fails or cannot hit the exact size
|
||||||
|
* without upscaling.
|
||||||
|
*/
|
||||||
|
private function cover_crop( string $bytes, int $target_w, int $target_h ): ?array {
|
||||||
|
$temp = get_temp_dir() . uniqid( 'prab_compose_', true ) . '.png';
|
||||||
|
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- temp file, same pattern as the sideloader.
|
||||||
|
if ( false === file_put_contents( $temp, $bytes ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$editor = wp_get_image_editor( $temp );
|
||||||
|
if ( is_wp_error( $editor ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ( is_wp_error( $editor->resize( $target_w, $target_h, true ) ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$size = $editor->get_size();
|
||||||
|
if ( (int) $size['width'] !== $target_w || (int) $size['height'] !== $target_h ) {
|
||||||
|
return null; // Exact size not reachable without upscaling.
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( is_wp_error( $editor->save( $temp, 'image/png' ) ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- temp file we just wrote.
|
||||||
|
$out = file_get_contents( $temp );
|
||||||
|
|
||||||
|
return false === $out ? null : array(
|
||||||
|
'bytes' => $out,
|
||||||
|
'mime_type' => 'image/png',
|
||||||
|
'width' => $target_w,
|
||||||
|
'height' => $target_h,
|
||||||
|
);
|
||||||
|
} catch ( \Throwable $e ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->debug( 'WP image editor crop threw: ' . $e->getMessage(), 'image_composer' );
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort temp cleanup.
|
||||||
|
@unlink( $temp );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
250
includes/core/class-image-composer-imagick.php
Normal file
250
includes/core/class-image-composer-imagick.php
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Imagick renderer for the deterministic image composer (full-fidelity rung).
|
||||||
|
*
|
||||||
|
* Renders the three branded variants from one base image: corner-marked
|
||||||
|
* featured (1200×632), teal-band OG (1200×630), and card-layout square
|
||||||
|
* (1080×1080). No WordPress functions anywhere — layout geometry and asset
|
||||||
|
* directories arrive via the constructor, so the class runs unchanged inside
|
||||||
|
* the standalone render harness that produced the PR samples. Methods throw
|
||||||
|
* on failure; the orchestrator catches and degrades, so a renderer error can
|
||||||
|
* never fail a publish.
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger_Image_Composer when the capability probe says 'imagick'.
|
||||||
|
* Dependencies: ext-imagick, bundled brand PNGs + OFL fonts,
|
||||||
|
* PRAutoBlogger_Image_Composer_Canvas (primitives),
|
||||||
|
* PRAutoBlogger_Image_Composer_Layout (caption clamp).
|
||||||
|
*
|
||||||
|
* @see core/class-image-composer.php — Orchestrator + degradation ladder.
|
||||||
|
* @see core/class-image-composer-canvas.php — Crop/logo/PNG primitives.
|
||||||
|
* @see core/class-image-composer-layout.php — Geometry defaults + clamp.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Image_Composer_Imagick {
|
||||||
|
|
||||||
|
/** @var array<string, array<string, mixed>> Layout geometry keyed by role. */
|
||||||
|
private array $layout;
|
||||||
|
|
||||||
|
/** @var string Absolute path to the vendored brand PNG directory (trailing slash). */
|
||||||
|
private string $brand_dir;
|
||||||
|
|
||||||
|
/** @var string Absolute path to the bundled font directory (trailing slash). */
|
||||||
|
private string $fonts_dir;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construct with resolved layout + asset locations.
|
||||||
|
*
|
||||||
|
* @param array<string, array<string, mixed>> $layout Filtered layout (see Layout::defaults()).
|
||||||
|
* @param string $brand_dir Brand PNG directory, trailing slash.
|
||||||
|
* @param string $fonts_dir Font directory, trailing slash.
|
||||||
|
*/
|
||||||
|
public function __construct( array $layout, string $brand_dir, string $fonts_dir ) {
|
||||||
|
$this->layout = $layout;
|
||||||
|
$this->brand_dir = $brand_dir;
|
||||||
|
$this->fonts_dir = $fonts_dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Featured variant: base image untouched except a subtle corner mark.
|
||||||
|
*
|
||||||
|
* @param string $bytes Base image bytes.
|
||||||
|
*
|
||||||
|
* @return array{bytes: string, mime_type: string, width: int, height: int, role: string}
|
||||||
|
* @throws \RuntimeException|\ImagickException On decode/asset failure.
|
||||||
|
*/
|
||||||
|
public function compose_featured( string $bytes ): array {
|
||||||
|
$conf = $this->layout['featured'];
|
||||||
|
$canvas = PRAutoBlogger_Image_Composer_Canvas::load_base( $bytes );
|
||||||
|
|
||||||
|
$mark_h = (int) $conf['mark_height'];
|
||||||
|
$inset = (int) $conf['mark_inset'];
|
||||||
|
// The mark asset is square, so rendered width equals the height.
|
||||||
|
PRAutoBlogger_Image_Composer_Canvas::place_logo(
|
||||||
|
$canvas,
|
||||||
|
$this->brand_dir . $conf['mark_asset'],
|
||||||
|
$mark_h,
|
||||||
|
$canvas->getImageWidth() - $mark_h - $inset,
|
||||||
|
$canvas->getImageHeight() - $mark_h - $inset,
|
||||||
|
(float) $conf['mark_opacity']
|
||||||
|
);
|
||||||
|
|
||||||
|
return PRAutoBlogger_Image_Composer_Canvas::finalize_png( $canvas, 'featured' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OG variant (1200×630): center-cropped base, bottom teal band with the
|
||||||
|
* brand mark and the clamped caption in white.
|
||||||
|
*
|
||||||
|
* @param string $bytes Base image bytes.
|
||||||
|
* @param string $caption Editorial caption (clamped to 2 lines).
|
||||||
|
*
|
||||||
|
* @return array{bytes: string, mime_type: string, width: int, height: int, role: string}
|
||||||
|
* @throws \RuntimeException|\ImagickException On decode/crop/asset failure.
|
||||||
|
*/
|
||||||
|
public function compose_og( string $bytes, string $caption ): array {
|
||||||
|
$conf = $this->layout['og'];
|
||||||
|
$width = (int) $conf['width'];
|
||||||
|
$height = (int) $conf['height'];
|
||||||
|
$band_h = (int) $conf['band_height'];
|
||||||
|
$band_y = $height - $band_h;
|
||||||
|
|
||||||
|
$canvas = PRAutoBlogger_Image_Composer_Canvas::load_base( $bytes );
|
||||||
|
if ( ! PRAutoBlogger_Image_Composer_Canvas::cover_crop( $canvas, $width, $height ) ) {
|
||||||
|
$canvas->clear();
|
||||||
|
throw new \RuntimeException( 'OG variant would require upscaling the base image.' );
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->draw_rect( $canvas, 0, $band_y, $width, $height, (string) $conf['band_color'] );
|
||||||
|
|
||||||
|
$logo_h = (int) $conf['logo_height'];
|
||||||
|
$logo_w = PRAutoBlogger_Image_Composer_Canvas::place_logo(
|
||||||
|
$canvas,
|
||||||
|
$this->brand_dir . $conf['logo_asset'],
|
||||||
|
$logo_h,
|
||||||
|
(int) $conf['logo_x'],
|
||||||
|
$band_y + (int) floor( ( $band_h - $logo_h ) / 2 )
|
||||||
|
);
|
||||||
|
|
||||||
|
$lines = PRAutoBlogger_Image_Composer_Layout::clamp_caption(
|
||||||
|
$caption,
|
||||||
|
(int) $conf['caption_chars_per_line'],
|
||||||
|
(int) $conf['caption_max_lines']
|
||||||
|
);
|
||||||
|
$this->draw_lines(
|
||||||
|
$canvas,
|
||||||
|
$lines,
|
||||||
|
array(
|
||||||
|
'font' => $this->fonts_dir . $conf['caption_font'],
|
||||||
|
'size' => (float) $conf['caption_size'],
|
||||||
|
'color' => (string) $conf['caption_color'],
|
||||||
|
'line_height' => (float) $conf['caption_line_height'],
|
||||||
|
'x' => (int) $conf['logo_x'] + $logo_w + (int) $conf['caption_gap'],
|
||||||
|
'center_y' => $band_y + (int) floor( $band_h / 2 ),
|
||||||
|
'align' => \Imagick::ALIGN_LEFT,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return PRAutoBlogger_Image_Composer_Canvas::finalize_png( $canvas, 'og' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Square variant (1080×1080, card layout, no upscaling): full base
|
||||||
|
* downscaled into the top slice, cream caption panel in Poppins Bold,
|
||||||
|
* teal footer band with the horizontal reverse lockup.
|
||||||
|
*
|
||||||
|
* @param string $bytes Base image bytes.
|
||||||
|
* @param string $caption Editorial caption (clamped to 3 lines).
|
||||||
|
*
|
||||||
|
* @return array{bytes: string, mime_type: string, width: int, height: int, role: string}
|
||||||
|
* @throws \RuntimeException|\ImagickException On decode/crop/asset failure.
|
||||||
|
*/
|
||||||
|
public function compose_square( string $bytes, string $caption ): array {
|
||||||
|
$conf = $this->layout['square'];
|
||||||
|
$width = (int) $conf['width'];
|
||||||
|
$height = (int) $conf['height'];
|
||||||
|
$slice_h = (int) $conf['slice_height'];
|
||||||
|
$footer_h = (int) $conf['footer_height'];
|
||||||
|
$footer_y = $height - $footer_h;
|
||||||
|
|
||||||
|
$slice = PRAutoBlogger_Image_Composer_Canvas::load_base( $bytes );
|
||||||
|
if ( ! PRAutoBlogger_Image_Composer_Canvas::cover_crop( $slice, $width, $slice_h ) ) {
|
||||||
|
$slice->clear();
|
||||||
|
throw new \RuntimeException( 'Square variant would require upscaling the base image.' );
|
||||||
|
}
|
||||||
|
|
||||||
|
$canvas = new \Imagick();
|
||||||
|
$canvas->newImage( $width, $height, new \ImagickPixel( (string) $conf['panel_color'] ), 'png' );
|
||||||
|
$canvas->compositeImage( $slice, \Imagick::COMPOSITE_OVER, 0, 0 );
|
||||||
|
$slice->clear();
|
||||||
|
|
||||||
|
$this->draw_rect( $canvas, 0, $footer_y, $width, $height, (string) $conf['footer_color'] );
|
||||||
|
|
||||||
|
$logo_h = (int) $conf['footer_logo_height'];
|
||||||
|
$logo = new \Imagick( $this->brand_dir . $conf['footer_logo_asset'] );
|
||||||
|
$logo_w = (int) round( $logo->getImageWidth() * ( $logo_h / max( 1, $logo->getImageHeight() ) ) );
|
||||||
|
$logo->clear();
|
||||||
|
PRAutoBlogger_Image_Composer_Canvas::place_logo(
|
||||||
|
$canvas,
|
||||||
|
$this->brand_dir . $conf['footer_logo_asset'],
|
||||||
|
$logo_h,
|
||||||
|
(int) floor( ( $width - $logo_w ) / 2 ),
|
||||||
|
$footer_y + (int) floor( ( $footer_h - $logo_h ) / 2 )
|
||||||
|
);
|
||||||
|
|
||||||
|
$lines = PRAutoBlogger_Image_Composer_Layout::clamp_caption(
|
||||||
|
$caption,
|
||||||
|
(int) $conf['caption_chars_per_line'],
|
||||||
|
(int) $conf['caption_max_lines']
|
||||||
|
);
|
||||||
|
$this->draw_lines(
|
||||||
|
$canvas,
|
||||||
|
$lines,
|
||||||
|
array(
|
||||||
|
'font' => $this->fonts_dir . $conf['caption_font'],
|
||||||
|
'size' => (float) $conf['caption_size'],
|
||||||
|
'color' => (string) $conf['caption_color'],
|
||||||
|
'line_height' => (float) $conf['caption_line_height'],
|
||||||
|
'x' => (int) floor( $width / 2 ),
|
||||||
|
'center_y' => $slice_h + (int) floor( ( $footer_y - $slice_h ) / 2 ),
|
||||||
|
'align' => \Imagick::ALIGN_CENTER,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return PRAutoBlogger_Image_Composer_Canvas::finalize_png( $canvas, 'square' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill an axis-aligned rectangle on the canvas.
|
||||||
|
*
|
||||||
|
* @param \Imagick $canvas Destination canvas (mutated).
|
||||||
|
* @param int $x1 Left.
|
||||||
|
* @param int $y1 Top.
|
||||||
|
* @param int $x2 Right.
|
||||||
|
* @param int $y2 Bottom.
|
||||||
|
* @param string $color Hex fill color.
|
||||||
|
*/
|
||||||
|
private function draw_rect( \Imagick $canvas, int $x1, int $y1, int $x2, int $y2, string $color ): void {
|
||||||
|
$draw = new \ImagickDraw();
|
||||||
|
$draw->setFillColor( new \ImagickPixel( $color ) );
|
||||||
|
$draw->rectangle( (float) $x1, (float) $y1, (float) $x2, (float) $y2 );
|
||||||
|
$canvas->drawImage( $draw );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw pre-clamped caption lines vertically centered around center_y.
|
||||||
|
*
|
||||||
|
* @param \Imagick $canvas Destination canvas (mutated).
|
||||||
|
* @param string[] $lines Already-clamped lines (may be empty — no-op).
|
||||||
|
* @param array{font: string, size: float, color: string, line_height: float, x: int, center_y: int, align: int} $opts Text options.
|
||||||
|
*
|
||||||
|
* @throws \RuntimeException When the bundled font file is missing.
|
||||||
|
*/
|
||||||
|
private function draw_lines( \Imagick $canvas, array $lines, array $opts ): void {
|
||||||
|
if ( empty( $lines ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( ! is_readable( $opts['font'] ) ) {
|
||||||
|
throw new \RuntimeException( 'Composer font missing: ' . basename( $opts['font'] ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$draw = new \ImagickDraw();
|
||||||
|
$draw->setFont( $opts['font'] );
|
||||||
|
$draw->setFontSize( $opts['size'] );
|
||||||
|
$draw->setFillColor( new \ImagickPixel( $opts['color'] ) );
|
||||||
|
$draw->setTextAlignment( $opts['align'] );
|
||||||
|
$draw->setTextAntialias( true );
|
||||||
|
|
||||||
|
$metrics = $canvas->queryFontMetrics( $draw, 'Hg' );
|
||||||
|
$line_step = (int) round( $opts['size'] * $opts['line_height'] );
|
||||||
|
$block_h = ( count( $lines ) - 1 ) * $line_step + (int) round( $metrics['ascender'] - $metrics['descender'] );
|
||||||
|
$baseline = $opts['center_y'] - (int) floor( $block_h / 2 ) + (int) round( $metrics['ascender'] );
|
||||||
|
|
||||||
|
foreach ( $lines as $line ) {
|
||||||
|
$canvas->annotateImage( $draw, (float) $opts['x'], (float) $baseline, 0.0, $line );
|
||||||
|
$baseline += $line_step;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
175
includes/core/class-image-composer-layout.php
Normal file
175
includes/core/class-image-composer-layout.php
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Geometry defaults and caption clamping for the deterministic image composer.
|
||||||
|
*
|
||||||
|
* Pure functions and constants only — no WordPress, no Imagick. This keeps
|
||||||
|
* the class unit-testable and usable from a standalone render harness. The
|
||||||
|
* orchestrator exposes defaults() through the `prautoblogger_image_compose_layout`
|
||||||
|
* filter; geometry is intentionally NOT a settings-page surface (promote to
|
||||||
|
* settings only if the CEO asks to tune it — see ARCHITECTURE.md #21).
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger_Image_Composer (orchestrator) at compose time.
|
||||||
|
* Dependencies: mbstring (bundled with WordPress via compat polyfills).
|
||||||
|
*
|
||||||
|
* @see core/class-image-composer.php — Applies the layout filter.
|
||||||
|
* @see core/class-image-composer-imagick.php — Consumes the geometry values.
|
||||||
|
* @see tests/unit/Core/ImageComposerLayoutTest.php — Clamp boundary cases.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Image_Composer_Layout {
|
||||||
|
|
||||||
|
/** Brand tokens (locked — see peptide-repo-brand README). */
|
||||||
|
public const COLOR_TEAL = '#1B8A92';
|
||||||
|
public const COLOR_LIME = '#7FD600';
|
||||||
|
public const COLOR_ORANGE = '#FF8A3D';
|
||||||
|
public const COLOR_INK = '#0D0D0D';
|
||||||
|
public const COLOR_CREAM = '#FAFAF7';
|
||||||
|
|
||||||
|
/** Ellipsis appended when a caption overflows its line budget. */
|
||||||
|
public const ELLIPSIS = '…';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default geometry for every composed variant.
|
||||||
|
*
|
||||||
|
* Asset filenames resolve against assets/brand/ and assets/fonts/ in the
|
||||||
|
* plugin; sizes are px. Values were tuned against rendered samples (see
|
||||||
|
* PR #thread 2026-06-image-composer assets).
|
||||||
|
*
|
||||||
|
* Note: 'caption_margin_right' (og) and 'caption_side_padding' (square) were removed
|
||||||
|
* in v0.19.4 — the Imagick renderer enforces right-edge geometry via caption_chars_per_line
|
||||||
|
* and centered x positioning respectively, so those keys had no consumer.
|
||||||
|
*
|
||||||
|
* @return array<string, array<string, mixed>> Keyed by role: featured, og, square.
|
||||||
|
*/
|
||||||
|
public static function defaults(): array {
|
||||||
|
return array(
|
||||||
|
'featured' => array(
|
||||||
|
'mark_asset' => 'logo-mark-small-56.png',
|
||||||
|
'mark_height' => 28,
|
||||||
|
'mark_inset' => 16,
|
||||||
|
'mark_opacity' => 0.55,
|
||||||
|
),
|
||||||
|
'og' => array(
|
||||||
|
'width' => 1200,
|
||||||
|
'height' => 630,
|
||||||
|
'band_height' => 120,
|
||||||
|
'band_color' => self::COLOR_TEAL,
|
||||||
|
'logo_asset' => 'logo-mark-small-112.png',
|
||||||
|
'logo_height' => 64,
|
||||||
|
'logo_x' => 40,
|
||||||
|
'caption_font' => 'Poppins-SemiBold.ttf',
|
||||||
|
'caption_size' => 32,
|
||||||
|
'caption_color' => '#FFFFFF',
|
||||||
|
'caption_gap' => 24,
|
||||||
|
'caption_max_lines' => 2,
|
||||||
|
'caption_chars_per_line' => 52,
|
||||||
|
'caption_line_height' => 1.3,
|
||||||
|
),
|
||||||
|
'square' => array(
|
||||||
|
'width' => 1080,
|
||||||
|
'height' => 1080,
|
||||||
|
'slice_height' => 569,
|
||||||
|
'panel_color' => self::COLOR_CREAM,
|
||||||
|
'footer_height' => 96,
|
||||||
|
'footer_color' => self::COLOR_TEAL,
|
||||||
|
'footer_logo_asset' => 'logo-horizontal-reverse-128.png',
|
||||||
|
'footer_logo_height' => 48,
|
||||||
|
'caption_font' => 'Poppins-Bold.ttf',
|
||||||
|
'caption_size' => 48,
|
||||||
|
'caption_color' => self::COLOR_INK,
|
||||||
|
'caption_max_lines' => 3,
|
||||||
|
'caption_chars_per_line' => 36,
|
||||||
|
'caption_line_height' => 1.3,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clamp and word-wrap a caption to a hard per-variant budget.
|
||||||
|
*
|
||||||
|
* Whitespace is collapsed, words wrap at word boundaries, words longer
|
||||||
|
* than a full line are hard-split, and overflow beyond max_lines is cut
|
||||||
|
* with a trailing ellipsis. Multibyte-safe (mb_* functions).
|
||||||
|
*
|
||||||
|
* Side effects: none (pure).
|
||||||
|
*
|
||||||
|
* @param string $caption Raw caption text (any length, any script).
|
||||||
|
* @param int $chars_per_line Hard character budget per line (min 1).
|
||||||
|
* @param int $max_lines Maximum number of lines (min 1).
|
||||||
|
*
|
||||||
|
* @return string[] Wrapped lines; empty array when the caption is blank.
|
||||||
|
*/
|
||||||
|
public static function clamp_caption( string $caption, int $chars_per_line, int $max_lines ): array {
|
||||||
|
$chars_per_line = max( 1, $chars_per_line );
|
||||||
|
$max_lines = max( 1, $max_lines );
|
||||||
|
$normalized = trim( (string) preg_replace( '/\s+/u', ' ', $caption ) );
|
||||||
|
|
||||||
|
if ( '' === $normalized ) {
|
||||||
|
return array();
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = array();
|
||||||
|
$line = '';
|
||||||
|
|
||||||
|
foreach ( explode( ' ', $normalized ) as $word ) {
|
||||||
|
// Hard-split words longer than a full line so they cannot overflow.
|
||||||
|
while ( mb_strlen( $word ) > $chars_per_line ) {
|
||||||
|
if ( '' !== $line ) {
|
||||||
|
$lines[] = $line;
|
||||||
|
$line = '';
|
||||||
|
if ( count( $lines ) >= $max_lines ) {
|
||||||
|
return self::ellipsize( $lines, $chars_per_line );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$lines[] = mb_substr( $word, 0, $chars_per_line );
|
||||||
|
$word = mb_substr( $word, $chars_per_line );
|
||||||
|
if ( count( $lines ) >= $max_lines ) {
|
||||||
|
return self::ellipsize( $lines, $chars_per_line );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidate = ( '' === $line ) ? $word : $line . ' ' . $word;
|
||||||
|
if ( mb_strlen( $candidate ) <= $chars_per_line ) {
|
||||||
|
$line = $candidate;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines[] = $line;
|
||||||
|
$line = $word;
|
||||||
|
if ( count( $lines ) >= $max_lines ) {
|
||||||
|
return self::ellipsize( $lines, $chars_per_line );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( '' !== $line ) {
|
||||||
|
$lines[] = $line;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append an ellipsis to the final line, trimming first if the line is
|
||||||
|
* already at the character budget so the result never exceeds it.
|
||||||
|
*
|
||||||
|
* @param string[] $lines Lines already at the max-line count.
|
||||||
|
* @param int $chars_per_line Hard character budget per line.
|
||||||
|
*
|
||||||
|
* @return string[] Lines with a trailing ellipsis on the last one.
|
||||||
|
*/
|
||||||
|
private static function ellipsize( array $lines, int $chars_per_line ): array {
|
||||||
|
$last_index = count( $lines ) - 1;
|
||||||
|
$last = $lines[ $last_index ];
|
||||||
|
|
||||||
|
if ( mb_strlen( $last ) + 1 > $chars_per_line ) {
|
||||||
|
$last = rtrim( mb_substr( $last, 0, max( 0, $chars_per_line - 1 ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines[ $last_index ] = $last . self::ELLIPSIS;
|
||||||
|
|
||||||
|
return $lines;
|
||||||
|
}
|
||||||
|
}
|
||||||
298
includes/core/class-image-composer.php
Normal file
298
includes/core/class-image-composer.php
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Orchestrates deterministic image composition with a capability ladder.
|
||||||
|
*
|
||||||
|
* Sits between the provider's raw bytes and the media sideloader. Probes the
|
||||||
|
* environment once (cached in an option keyed by a PHP/extension fingerprint,
|
||||||
|
* so it auto-invalidates when the host changes — Hostinger extension state
|
||||||
|
* has flipped before), then renders per the ladder: (1) Imagick usable →
|
||||||
|
* full branded compositing; (2) GD via WP editor → resize-only variants, no
|
||||||
|
* branding/text, WARNING once; (3) no image library → pass-through, WARNING
|
||||||
|
* once. compose() never throws and always returns a featured-first non-empty
|
||||||
|
* list, so a composition failure can never block a publish. Local CPU only:
|
||||||
|
* $0, no provider call; duration is logged on the image_composer channel.
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger_Image_Pipeline after batch results + NSFW retry,
|
||||||
|
* before sideloading.
|
||||||
|
* Dependencies: Image_Composer_Imagick (rung 1), Image_Composer_Editor (rung 2),
|
||||||
|
* Image_Composer_Canvas (probe), Image_Composer_Layout, Logger.
|
||||||
|
*
|
||||||
|
* @see core/interface-image-composer.php — The contract.
|
||||||
|
* @see core/class-image-pipeline.php — Caller.
|
||||||
|
* @see core/class-image-attacher.php — Persists returned variants.
|
||||||
|
* @see ARCHITECTURE.md — Decision #21.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Image_Composer implements PRAutoBlogger_Image_Composer_Interface {
|
||||||
|
|
||||||
|
/** Option keys (prautoblogger_ prefix keeps the uninstall sweep effective). */
|
||||||
|
public const OPTION_ENABLED = 'prautoblogger_image_compose_enabled';
|
||||||
|
public const OPTION_VARIANTS = 'prautoblogger_image_compose_variants';
|
||||||
|
public const OPTION_MARK_ENABLED = 'prautoblogger_image_featured_mark_enabled';
|
||||||
|
public const OPTION_CAPABILITY = 'prautoblogger_image_compose_capability';
|
||||||
|
|
||||||
|
/** Capability rungs. */
|
||||||
|
public const CAP_IMAGICK = 'imagick';
|
||||||
|
public const CAP_GD = 'gd';
|
||||||
|
public const CAP_NONE = 'none';
|
||||||
|
|
||||||
|
/** @var PRAutoBlogger_Image_Composer_Imagick|null Lazily built; injectable for tests. */
|
||||||
|
private ?PRAutoBlogger_Image_Composer_Imagick $renderer;
|
||||||
|
|
||||||
|
/** @var bool Degradation WARNING fired this run (once per PHP process). */
|
||||||
|
private static bool $warned_this_run = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construct, optionally injecting a renderer (tests).
|
||||||
|
* @param PRAutoBlogger_Image_Composer_Imagick|null $renderer Optional renderer override.
|
||||||
|
*/
|
||||||
|
public function __construct( ?PRAutoBlogger_Image_Composer_Imagick $renderer = null ) {
|
||||||
|
$this->renderer = $renderer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compose final variants for one generated image. See the interface for
|
||||||
|
* the full contract; this implementation never throws.
|
||||||
|
* Side effects: reads options, may cache the capability probe, logs.
|
||||||
|
*
|
||||||
|
* @param array $image_data Provider result (bytes, mime_type, width, height, ...).
|
||||||
|
* @param array $context {post_id, caption, title, slot}.
|
||||||
|
* @return array<int, array{bytes: string, mime_type: string, width: int, height: int, role: string}>
|
||||||
|
*/
|
||||||
|
public function compose( array $image_data, array $context ): array {
|
||||||
|
$passthrough = array( $this->passthrough_variant( $image_data ) );
|
||||||
|
|
||||||
|
if ( '' === $passthrough[0]['bytes'] || '1' !== (string) get_option( self::OPTION_ENABLED, '1' ) ) {
|
||||||
|
return $passthrough;
|
||||||
|
}
|
||||||
|
|
||||||
|
$started = microtime( true );
|
||||||
|
$bytes = $passthrough[0]['bytes'];
|
||||||
|
$capability = $this->resolve_capability();
|
||||||
|
$caption = (string) ( $context['caption'] ?? '' );
|
||||||
|
$roles = ( 'image_a' === (string) ( $context['slot'] ?? 'image_a' ) )
|
||||||
|
? $this->configured_variants()
|
||||||
|
: array();
|
||||||
|
|
||||||
|
if ( self::CAP_IMAGICK === $capability ) {
|
||||||
|
$variants = $this->compose_via_imagick( $bytes, $caption, $roles, $passthrough[0] );
|
||||||
|
} elseif ( self::CAP_GD === $capability ) {
|
||||||
|
$this->warn_once( 'Imagick unavailable — emitting resize-only variants without branding or caption.' );
|
||||||
|
$editor = new PRAutoBlogger_Image_Composer_Editor();
|
||||||
|
$variants = array_merge( $passthrough, $editor->compose_variants( $bytes, $roles, $this->layout() ) );
|
||||||
|
} else {
|
||||||
|
$this->warn_once( 'No usable image library — base image passed through unbranded.' );
|
||||||
|
$variants = $passthrough;
|
||||||
|
}
|
||||||
|
|
||||||
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
|
sprintf(
|
||||||
|
'Composed %d variant(s) for post %d in %dms ($0, local, capability: %s).',
|
||||||
|
count( $variants ),
|
||||||
|
(int) ( $context['post_id'] ?? 0 ),
|
||||||
|
(int) round( ( microtime( true ) - $started ) * 1000 ),
|
||||||
|
$capability
|
||||||
|
),
|
||||||
|
'image_composer'
|
||||||
|
);
|
||||||
|
|
||||||
|
return $variants;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full compositing rung. Each render is individually guarded: a featured
|
||||||
|
* failure degrades to pass-through, a variant failure skips that variant.
|
||||||
|
*
|
||||||
|
* @param string $bytes Base image bytes.
|
||||||
|
* @param string $caption Editorial caption for OG/square.
|
||||||
|
* @param array $roles Variant roles to render ('og', 'square').
|
||||||
|
* @param array $passthrough Pass-through featured variant (fallback).
|
||||||
|
* @return array<int, array<string, mixed>> Featured-first variant list.
|
||||||
|
*/
|
||||||
|
private function compose_via_imagick( string $bytes, string $caption, array $roles, array $passthrough ): array {
|
||||||
|
$variants = array();
|
||||||
|
|
||||||
|
if ( '1' === (string) get_option( self::OPTION_MARK_ENABLED, '1' ) ) {
|
||||||
|
$featured = $this->try_render( 'featured', $bytes, $caption );
|
||||||
|
$variants[] = null === $featured ? $passthrough : $featured;
|
||||||
|
} else {
|
||||||
|
$variants[] = $passthrough;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ( $roles as $role ) {
|
||||||
|
$variant = $this->try_render( $role, $bytes, $caption );
|
||||||
|
if ( null !== $variant ) {
|
||||||
|
$variants[] = $variant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $variants;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render one role via the Imagick renderer. Any throwable or malformed
|
||||||
|
* result (empty bytes) maps to one per-run WARNING + null (caller degrades).
|
||||||
|
* @param string $role 'featured', 'og', or 'square'.
|
||||||
|
* @param string $bytes Base image bytes.
|
||||||
|
* @param string $caption Editorial caption.
|
||||||
|
* @return array<string, mixed>|null Variant payload or null on failure.
|
||||||
|
*/
|
||||||
|
private function try_render( string $role, string $bytes, string $caption ): ?array {
|
||||||
|
try {
|
||||||
|
$renderer = $this->renderer();
|
||||||
|
if ( 'og' === $role ) {
|
||||||
|
$variant = $renderer->compose_og( $bytes, $caption );
|
||||||
|
} elseif ( 'square' === $role ) {
|
||||||
|
$variant = $renderer->compose_square( $bytes, $caption );
|
||||||
|
} else {
|
||||||
|
$variant = $renderer->compose_featured( $bytes );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( '' === (string) ( $variant['bytes'] ?? '' ) ) {
|
||||||
|
$this->warn_once( sprintf( 'Imagick render for %s variant returned no bytes — degrading.', $role ) );
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$variant['role'] = $role;
|
||||||
|
|
||||||
|
return $variant;
|
||||||
|
} catch ( \Throwable $e ) {
|
||||||
|
$this->warn_once( sprintf( 'Imagick render failed for %s variant: %s', $role, $e->getMessage() ) );
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the capability rung: cached probe + override filter. The
|
||||||
|
* same-named filter lets tests/ops force a rung (e.g. 'none').
|
||||||
|
*
|
||||||
|
* @return string One of the CAP_* constants.
|
||||||
|
*/
|
||||||
|
private function resolve_capability(): string {
|
||||||
|
$fingerprint = md5(
|
||||||
|
PHP_VERSION . '|' . ( extension_loaded( 'imagick' ) ? 'im1' : 'im0' ) . '|' . ( extension_loaded( 'gd' ) ? 'gd1' : 'gd0' )
|
||||||
|
);
|
||||||
|
|
||||||
|
$cached = get_option( self::OPTION_CAPABILITY, array() );
|
||||||
|
if ( is_array( $cached ) && ( $cached['fingerprint'] ?? '' ) === $fingerprint && isset( $cached['capability'] ) ) {
|
||||||
|
$capability = (string) $cached['capability'];
|
||||||
|
} else {
|
||||||
|
$capability = $this->probe_capability();
|
||||||
|
update_option(
|
||||||
|
self::OPTION_CAPABILITY,
|
||||||
|
array(
|
||||||
|
'fingerprint' => $fingerprint,
|
||||||
|
'capability' => $capability,
|
||||||
|
),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$capability = (string) apply_filters( 'prautoblogger_image_compose_capability', $capability );
|
||||||
|
|
||||||
|
return in_array( $capability, array( self::CAP_IMAGICK, self::CAP_GD, self::CAP_NONE ), true )
|
||||||
|
? $capability
|
||||||
|
: self::CAP_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe the host for the best available rung (uncached).
|
||||||
|
* @return string One of the CAP_* constants.
|
||||||
|
*/
|
||||||
|
private function probe_capability(): string {
|
||||||
|
if ( PRAutoBlogger_Image_Composer_Canvas::is_imagick_usable( $this->fonts_dir() . 'Poppins-Bold.ttf' ) ) {
|
||||||
|
return self::CAP_IMAGICK;
|
||||||
|
}
|
||||||
|
|
||||||
|
$png_crop = array(
|
||||||
|
'mime_type' => 'image/png',
|
||||||
|
'methods' => array( 'resize', 'crop' ),
|
||||||
|
);
|
||||||
|
if ( function_exists( 'wp_image_editor_supports' ) && wp_image_editor_supports( $png_crop ) ) {
|
||||||
|
return self::CAP_GD;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::CAP_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily build the Imagick renderer with filtered layout + asset paths.
|
||||||
|
* @return PRAutoBlogger_Image_Composer_Imagick
|
||||||
|
*/
|
||||||
|
private function renderer(): PRAutoBlogger_Image_Composer_Imagick {
|
||||||
|
if ( null === $this->renderer ) {
|
||||||
|
$this->renderer = new PRAutoBlogger_Image_Composer_Imagick(
|
||||||
|
$this->layout(),
|
||||||
|
dirname( __DIR__, 2 ) . '/assets/brand/',
|
||||||
|
$this->fonts_dir()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return $this->renderer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout geometry: pure defaults behind the documented override filter.
|
||||||
|
* @return array<string, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function layout(): array {
|
||||||
|
return (array) apply_filters( 'prautoblogger_image_compose_layout', PRAutoBlogger_Image_Composer_Layout::defaults() );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bundled font directory (path-derived, no constants — unit-test safe).
|
||||||
|
* @return string Absolute path with trailing slash.
|
||||||
|
*/
|
||||||
|
private function fonts_dir(): string {
|
||||||
|
return dirname( __DIR__, 2 ) . '/assets/fonts/';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variant roles from settings, whitelisted at point of use.
|
||||||
|
* @return string[] Subset of ['og', 'square'], deduplicated, in config order.
|
||||||
|
*/
|
||||||
|
private function configured_variants(): array {
|
||||||
|
$raw = (string) get_option( self::OPTION_VARIANTS, 'og,square' );
|
||||||
|
$roles = array();
|
||||||
|
foreach ( explode( ',', $raw ) as $piece ) {
|
||||||
|
$piece = strtolower( trim( $piece ) );
|
||||||
|
if ( in_array( $piece, array( 'og', 'square' ), true ) && ! in_array( $piece, $roles, true ) ) {
|
||||||
|
$roles[] = $piece;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $roles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the pass-through featured variant from the provider result.
|
||||||
|
* @param array $image_data Provider result.
|
||||||
|
* @return array{bytes: string, mime_type: string, width: int, height: int, role: string}
|
||||||
|
*/
|
||||||
|
private function passthrough_variant( array $image_data ): array {
|
||||||
|
return array(
|
||||||
|
'bytes' => (string) ( $image_data['bytes'] ?? '' ),
|
||||||
|
'mime_type' => (string) ( $image_data['mime_type'] ?? 'image/png' ),
|
||||||
|
'width' => (int) ( $image_data['width'] ?? 0 ),
|
||||||
|
'height' => (int) ( $image_data['height'] ?? 0 ),
|
||||||
|
'role' => 'featured',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a degradation WARNING once per run (one row is enough for ops).
|
||||||
|
* @param string $message Warning text.
|
||||||
|
*/
|
||||||
|
private function warn_once( string $message ): void {
|
||||||
|
if ( self::$warned_this_run ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self::$warned_this_run = true;
|
||||||
|
PRAutoBlogger_Logger::instance()->warning( $message, 'image_composer' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset per-run state (unit tests only). */
|
||||||
|
public static function reset_run_state(): void {
|
||||||
|
self::$warned_this_run = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
* Downloads generated images and imports them into the WordPress media library.
|
* Downloads generated images and imports them into the WordPress media library.
|
||||||
*
|
*
|
||||||
* Handles sideloading (importing external images as WordPress attachments),
|
* Handles sideloading (importing external images as WordPress attachments),
|
||||||
@@ -34,11 +36,23 @@ class PRAutoBlogger_Image_Media_Sideloader {
|
|||||||
* latency_ms: int,
|
* latency_ms: int,
|
||||||
* } $image_data Raw image bytes + metadata from PRAutoBlogger_Image_Provider_Interface.
|
* } $image_data Raw image bytes + metadata from PRAutoBlogger_Image_Provider_Interface.
|
||||||
* @param int $post_id Target post ID to attach the image to.
|
* @param int $post_id Target post ID to attach the image to.
|
||||||
* @param string $alt_text Alt text for the image (from the generation prompt).
|
* @param string $alt_text Alt text for the image (the editorial caption).
|
||||||
|
* @param string $filename_suffix Optional role hint appended to the filename
|
||||||
|
* (e.g. 'og', 'square' for composed variants).
|
||||||
*
|
*
|
||||||
* @return int|\WP_Error Attachment ID on success, WP_Error on failure.
|
* @return int|\WP_Error Attachment ID on success, WP_Error on failure.
|
||||||
*/
|
*/
|
||||||
public function sideload_image( array $image_data, int $post_id, string $alt_text = '' ) {
|
public function sideload_image( array $image_data, int $post_id, string $alt_text = '', string $filename_suffix = '' ) {
|
||||||
|
// WordPress media functions live in wp-admin/includes/ and are NOT
|
||||||
|
// loaded automatically in cron or REST contexts. Load them if missing.
|
||||||
|
// Why: WP-Cron runs as a frontend request, so admin-only files like
|
||||||
|
// media.php, file.php, and image.php are not included by default.
|
||||||
|
if ( ! function_exists( 'media_handle_sideload' ) ) {
|
||||||
|
require_once ABSPATH . 'wp-admin/includes/media.php';
|
||||||
|
require_once ABSPATH . 'wp-admin/includes/file.php';
|
||||||
|
require_once ABSPATH . 'wp-admin/includes/image.php';
|
||||||
|
}
|
||||||
|
|
||||||
// Validate input.
|
// Validate input.
|
||||||
if ( empty( $image_data['bytes'] ) ) {
|
if ( empty( $image_data['bytes'] ) ) {
|
||||||
return new \WP_Error( 'invalid_image_data', 'Image data (bytes) is empty.' );
|
return new \WP_Error( 'invalid_image_data', 'Image data (bytes) is empty.' );
|
||||||
@@ -51,10 +65,10 @@ class PRAutoBlogger_Image_Media_Sideloader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Prepare file array for media_handle_sideload().
|
// Prepare file array for media_handle_sideload().
|
||||||
$file_array = [
|
$file_array = array(
|
||||||
'name' => $this->generate_filename( $image_data['model'], $image_data['width'], $image_data['height'] ),
|
'name' => $this->generate_filename( $image_data['model'], $image_data['width'], $image_data['height'], $filename_suffix ),
|
||||||
'tmp_name' => $temp_file,
|
'tmp_name' => $temp_file,
|
||||||
];
|
);
|
||||||
|
|
||||||
// Import the file into media library.
|
// Import the file into media library.
|
||||||
$attachment_id = media_handle_sideload( $file_array, $post_id );
|
$attachment_id = media_handle_sideload( $file_array, $post_id );
|
||||||
@@ -123,19 +137,21 @@ class PRAutoBlogger_Image_Media_Sideloader {
|
|||||||
* @param string $model Model name (e.g., 'flux-1-schnell').
|
* @param string $model Model name (e.g., 'flux-1-schnell').
|
||||||
* @param int $width Image width.
|
* @param int $width Image width.
|
||||||
* @param int $height Image height.
|
* @param int $height Image height.
|
||||||
|
* @param string $suffix Optional role hint (e.g. 'og', 'square'); back-compat default ''.
|
||||||
*
|
*
|
||||||
* @return string Sanitized filename.
|
* @return string Sanitized filename.
|
||||||
*/
|
*/
|
||||||
private function generate_filename( string $model, int $width, int $height ): string {
|
private function generate_filename( string $model, int $width, int $height, string $suffix = '' ): string {
|
||||||
$base = sprintf(
|
$base = sprintf(
|
||||||
'prautoblogger_%s_%dx%d_%s',
|
'prautoblogger_%s_%dx%d_%s%s',
|
||||||
sanitize_title( $model ),
|
sanitize_title( $model ),
|
||||||
$width,
|
$width,
|
||||||
$height,
|
$height,
|
||||||
gmdate( 'Y-m-d_H-i-s' )
|
gmdate( 'Y-m-d_H-i-s' ),
|
||||||
|
'' !== $suffix ? '_' . sanitize_title( $suffix ) : ''
|
||||||
);
|
);
|
||||||
|
|
||||||
return $base . '.png'; // Default to PNG since FLUX.1 outputs PNG.
|
return $base . '.png'; // Default to PNG — most image models output PNG.
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -146,11 +162,11 @@ class PRAutoBlogger_Image_Media_Sideloader {
|
|||||||
* @return string File extension with leading dot (e.g., '.png').
|
* @return string File extension with leading dot (e.g., '.png').
|
||||||
*/
|
*/
|
||||||
private function get_extension_for_mime( string $mime_type ): string {
|
private function get_extension_for_mime( string $mime_type ): string {
|
||||||
$map = [
|
$map = array(
|
||||||
'image/png' => '.png',
|
'image/png' => '.png',
|
||||||
'image/jpeg' => '.jpg',
|
'image/jpeg' => '.jpg',
|
||||||
'image/jpg' => '.jpg',
|
'image/jpg' => '.jpg',
|
||||||
];
|
);
|
||||||
|
|
||||||
return $map[ strtolower( $mime_type ) ] ?? '.png';
|
return $map[ strtolower( $mime_type ) ] ?? '.png';
|
||||||
}
|
}
|
||||||
|
|||||||
141
includes/core/class-image-nsfw-retry.php
Normal file
141
includes/core/class-image-nsfw-retry.php
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Single-shot retry of NSFW-blocked image slots with a sanitized prompt.
|
||||||
|
*
|
||||||
|
* When an image provider rejects a prompt via its content filter, its
|
||||||
|
* batch result carries
|
||||||
|
* `['error_type' => 'nsfw_blocked']`. This helper reruns just the blocked
|
||||||
|
* slot once with the rule-based fallback prompt (article title + style
|
||||||
|
* suffix, no LLM rewrite) and merges the new result back into the batch.
|
||||||
|
*
|
||||||
|
* Intentionally single-retry: if the fallback is also blocked, we log a
|
||||||
|
* WARNING and leave the `['error' => ...]` entry in place so the pipeline
|
||||||
|
* proceeds to publish without a featured image (current behaviour for any
|
||||||
|
* unrecoverable image failure).
|
||||||
|
*
|
||||||
|
* Gated behind the `prautoblogger_image_nsfw_retry` setting (default on).
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger_Image_Pipeline::generate_and_attach_images.
|
||||||
|
* Dependencies: PRAutoBlogger_Image_Provider_Interface (retry HTTP call),
|
||||||
|
* PRAutoBlogger_Image_Prompt_Builder (fallback prompt),
|
||||||
|
* PRAutoBlogger_Logger (warning + info lines).
|
||||||
|
*
|
||||||
|
* @see core/class-image-pipeline.php — Sole caller.
|
||||||
|
* @see core/class-image-prompt-builder.php — Supplies the fallback prompt.
|
||||||
|
* @see providers/class-image-nsfw-blocked.php — Typed exception raised by providers.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Image_NSFW_Retry {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Option key for the admin toggle.
|
||||||
|
*/
|
||||||
|
private const SETTING_KEY = 'prautoblogger_image_nsfw_retry';
|
||||||
|
|
||||||
|
/** @var PRAutoBlogger_Image_Provider_Interface */
|
||||||
|
private PRAutoBlogger_Image_Provider_Interface $provider;
|
||||||
|
|
||||||
|
/** @var PRAutoBlogger_Image_Prompt_Builder */
|
||||||
|
private PRAutoBlogger_Image_Prompt_Builder $prompt_builder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param PRAutoBlogger_Image_Provider_Interface $provider Provider to retry against.
|
||||||
|
* @param PRAutoBlogger_Image_Prompt_Builder $prompt_builder Fallback prompt source.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
PRAutoBlogger_Image_Provider_Interface $provider,
|
||||||
|
PRAutoBlogger_Image_Prompt_Builder $prompt_builder
|
||||||
|
) {
|
||||||
|
$this->provider = $provider;
|
||||||
|
$this->prompt_builder = $prompt_builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the admin setting currently allows NSFW retries.
|
||||||
|
*
|
||||||
|
* Defaults to on. Kept as a static so callers can short-circuit before
|
||||||
|
* constructing a retry object when nothing needs retrying anyway.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function is_enabled(): bool {
|
||||||
|
return '1' === (string) get_option( self::SETTING_KEY, '1' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace every NSFW-blocked slot in $batch_results with a fresh result
|
||||||
|
* from a sanitized fallback prompt.
|
||||||
|
*
|
||||||
|
* Mutates $batch_results and $captions in place. Also updates the
|
||||||
|
* per-slot entry in $captions so the caller writes the fallback
|
||||||
|
* caption under the post if the retry succeeds.
|
||||||
|
*
|
||||||
|
* Side effects: up to one HTTP call per blocked slot; Logger lines
|
||||||
|
* for every retry attempt (info on success, warning on second block).
|
||||||
|
*
|
||||||
|
* @param int $post_id Target post ID (for logs).
|
||||||
|
* @param array $batch_requests Original per-slot request map.
|
||||||
|
* @param array<string, array|null> $batch_results Provider result map (mutated).
|
||||||
|
* @param array<string, string> $captions Caption map (mutated).
|
||||||
|
* @param array<string, mixed> $article_data Article data (for title).
|
||||||
|
* @param array<string, mixed>|null $source_data Source data (for title on image_b).
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function retry_blocked_slots(
|
||||||
|
int $post_id,
|
||||||
|
array $batch_requests,
|
||||||
|
array &$batch_results,
|
||||||
|
array &$captions,
|
||||||
|
array $article_data,
|
||||||
|
?array $source_data
|
||||||
|
): void {
|
||||||
|
foreach ( $batch_results as $key => $entry ) {
|
||||||
|
if ( ! is_array( $entry ) || 'nsfw_blocked' !== ( $entry['error_type'] ?? '' ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$title = 'image_b' === $key
|
||||||
|
? (string) ( $source_data['title'] ?? '' )
|
||||||
|
: (string) ( $article_data['post_title'] ?? $article_data['suggested_title'] ?? '' );
|
||||||
|
|
||||||
|
$fallback = $this->prompt_builder->build_fallback_prompt( $title );
|
||||||
|
|
||||||
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
|
sprintf( 'NSFW retry for post %d slot "%s" with sanitized fallback prompt.', $post_id, $key ),
|
||||||
|
'image_pipeline'
|
||||||
|
);
|
||||||
|
|
||||||
|
$retry_request = array(
|
||||||
|
'prompt' => $fallback['prompt'],
|
||||||
|
'width' => (int) ( $batch_requests[ $key ]['width'] ?? 0 ),
|
||||||
|
'height' => (int) ( $batch_requests[ $key ]['height'] ?? 0 ),
|
||||||
|
'options' => $batch_requests[ $key ]['options'] ?? array(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$retry_results = $this->provider->generate_image_batch( array( $key => $retry_request ) );
|
||||||
|
$retry_entry = $retry_results[ $key ] ?? array( 'error' => 'Retry provider returned no result.' );
|
||||||
|
|
||||||
|
if ( isset( $retry_entry['error'] ) ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->warning(
|
||||||
|
sprintf(
|
||||||
|
'NSFW retry for post %d slot "%s" also failed: %s',
|
||||||
|
$post_id,
|
||||||
|
$key,
|
||||||
|
(string) $retry_entry['error']
|
||||||
|
),
|
||||||
|
'image_pipeline'
|
||||||
|
);
|
||||||
|
// Leave the original ['error' => ...] entry so the pipeline
|
||||||
|
// publishes without this image, matching current behaviour
|
||||||
|
// for any unrecoverable image failure.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$batch_results[ $key ] = $retry_entry;
|
||||||
|
$captions[ $key ] = $fallback['caption'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,279 +2,295 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
* Orchestrates image generation for published articles.
|
* Orchestrates image generation for published articles.
|
||||||
*
|
*
|
||||||
* Generates two images per article (A/B experiment):
|
* Generates two images per article (A/B): Image A (article-driven) as featured
|
||||||
* - Image A: Article-driven prompt → featured image
|
* image, Image B (source-driven) in post meta. Each is independently fallible.
|
||||||
* - Image B: Source-driven prompt → post meta (_prautoblogger_image_b_id)
|
* Since v0.17.0 every result passes through the deterministic image composer
|
||||||
*
|
* (branded variants for A, corner mark for B) before sideloading; composition
|
||||||
* Each image is independently fallible — if A fails, still try B. If both fail,
|
* degrades gracefully and never blocks a publish.
|
||||||
* the article is published without images (graceful degradation).
|
|
||||||
*
|
*
|
||||||
* Triggered by: PRAutoBlogger_Pipeline_Runner after publisher creates the post.
|
* Triggered by: PRAutoBlogger_Pipeline_Runner after publisher creates the post.
|
||||||
* Dependencies: PRAutoBlogger_Image_Provider_Interface (concrete provider),
|
* Dependencies: Image provider, Prompt builder, Composer, Attacher, Cost tracker, Logger.
|
||||||
* PRAutoBlogger_Image_Prompt_Builder, PRAutoBlogger_Image_Media_Sideloader,
|
|
||||||
* PRAutoBlogger_Cost_Tracker, PRAutoBlogger_Logger.
|
|
||||||
*
|
*
|
||||||
* @see core/class-pipeline-runner.php — Calls this after publisher.
|
* @see core/class-pipeline-runner.php — Calls this after publisher.
|
||||||
* @see core/class-publisher.php — Sets featured image + post meta.
|
* @see core/class-image-prompt-builder.php — Prompt generation (scene + caption).
|
||||||
* @see core/class-image-prompt-builder.php — Prompt generation.
|
* @see core/class-image-composer.php — Deterministic variant composition.
|
||||||
* @see core/class-image-media-sideloader.php — Media library integration.
|
* @see core/class-image-attacher.php — Sideloading + variant meta-linking.
|
||||||
* @see ARCHITECTURE.md — Image pipeline data flow.
|
|
||||||
*/
|
*/
|
||||||
class PRAutoBlogger_Image_Pipeline {
|
class PRAutoBlogger_Image_Pipeline {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default image dimensions (landscape).
|
* Default image dimensions (landscape). 1200×632 is the closest
|
||||||
|
* 8-aligned pair to the standard OG image size (1200×630). Some
|
||||||
|
* providers require dimensions divisible by 8.
|
||||||
*/
|
*/
|
||||||
private const DEFAULT_WIDTH = 1200;
|
private const DEFAULT_WIDTH = 1200;
|
||||||
private const DEFAULT_HEIGHT = 630;
|
private const DEFAULT_HEIGHT = 632;
|
||||||
|
|
||||||
/**
|
/** @var PRAutoBlogger_Image_Provider_Interface Image gen provider. */
|
||||||
* The image provider (FLUX.1 via Cloudflare or another).
|
|
||||||
*
|
|
||||||
* @var PRAutoBlogger_Image_Provider_Interface
|
|
||||||
*/
|
|
||||||
private PRAutoBlogger_Image_Provider_Interface $provider;
|
private PRAutoBlogger_Image_Provider_Interface $provider;
|
||||||
|
/** @var PRAutoBlogger_Image_Prompt_Builder Builds scene + caption from article data. */
|
||||||
/**
|
|
||||||
* Prompt builder.
|
|
||||||
*
|
|
||||||
* @var PRAutoBlogger_Image_Prompt_Builder
|
|
||||||
*/
|
|
||||||
private PRAutoBlogger_Image_Prompt_Builder $prompt_builder;
|
private PRAutoBlogger_Image_Prompt_Builder $prompt_builder;
|
||||||
|
/** @var PRAutoBlogger_Image_Media_Sideloader Downloads images into WP media library. */
|
||||||
/**
|
|
||||||
* Media library sideloader.
|
|
||||||
*
|
|
||||||
* @var PRAutoBlogger_Image_Media_Sideloader
|
|
||||||
*/
|
|
||||||
private PRAutoBlogger_Image_Media_Sideloader $sideloader;
|
private PRAutoBlogger_Image_Media_Sideloader $sideloader;
|
||||||
|
/** @var PRAutoBlogger_Cost_Tracker Logs image generation spend. */
|
||||||
/**
|
|
||||||
* Cost tracker for logging spend.
|
|
||||||
*
|
|
||||||
* @var PRAutoBlogger_Cost_Tracker
|
|
||||||
*/
|
|
||||||
private PRAutoBlogger_Cost_Tracker $cost_tracker;
|
private PRAutoBlogger_Cost_Tracker $cost_tracker;
|
||||||
|
/** @var PRAutoBlogger_Image_Composer_Interface Composes branded variants pre-sideload. */
|
||||||
|
private PRAutoBlogger_Image_Composer_Interface $composer;
|
||||||
|
/** @var PRAutoBlogger_Image_Attacher Sideloads + meta-links results and variants. */
|
||||||
|
private PRAutoBlogger_Image_Attacher $attacher;
|
||||||
|
/** @var PRAutoBlogger_Opik_Trace_Context|null Optional tracing context. */
|
||||||
|
private ?PRAutoBlogger_Opik_Trace_Context $trace_context;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Construct with dependencies.
|
* Construct with dependencies. When no provider is injected, the
|
||||||
|
* `prautoblogger_image_provider` setting picks the concrete class
|
||||||
|
* ('runware' or 'openrouter').
|
||||||
*
|
*
|
||||||
* @param PRAutoBlogger_Image_Provider_Interface|null $provider Optional provider (defaults to Cloudflare).
|
* @param PRAutoBlogger_Image_Provider_Interface|null $provider Optional provider override.
|
||||||
* @param PRAutoBlogger_Cost_Tracker|null $cost_tracker Optional cost tracker.
|
* @param PRAutoBlogger_Cost_Tracker|null $cost_tracker Optional cost tracker.
|
||||||
|
* @param PRAutoBlogger_Opik_Trace_Context|null $trace_context Optional tracing context.
|
||||||
|
* @param PRAutoBlogger_Image_Composer_Interface|null $composer Optional composer override.
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
?PRAutoBlogger_Image_Provider_Interface $provider = null,
|
?PRAutoBlogger_Image_Provider_Interface $provider = null,
|
||||||
?PRAutoBlogger_Cost_Tracker $cost_tracker = null
|
?PRAutoBlogger_Cost_Tracker $cost_tracker = null,
|
||||||
|
?PRAutoBlogger_Opik_Trace_Context $trace_context = null,
|
||||||
|
?PRAutoBlogger_Image_Composer_Interface $composer = null
|
||||||
) {
|
) {
|
||||||
$this->provider = $provider ?? new PRAutoBlogger_Cloudflare_Image_Provider();
|
$this->provider = $provider ?? self::create_default_provider();
|
||||||
$this->prompt_builder = new PRAutoBlogger_Image_Prompt_Builder();
|
$this->prompt_builder = new PRAutoBlogger_Image_Prompt_Builder( $trace_context );
|
||||||
$this->sideloader = new PRAutoBlogger_Image_Media_Sideloader();
|
$this->sideloader = new PRAutoBlogger_Image_Media_Sideloader();
|
||||||
$this->cost_tracker = $cost_tracker ?? new PRAutoBlogger_Cost_Tracker();
|
$this->cost_tracker = $cost_tracker ?? new PRAutoBlogger_Cost_Tracker();
|
||||||
|
$this->composer = $composer ?? new PRAutoBlogger_Image_Composer();
|
||||||
|
$this->attacher = new PRAutoBlogger_Image_Attacher( $this->sideloader, $this->cost_tracker );
|
||||||
|
$this->trace_context = $trace_context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instantiate the image provider based on the admin setting. Defaults
|
||||||
|
* to Runware on unrecognised values (CF was removed in v0.10.0).
|
||||||
|
*
|
||||||
|
* @return PRAutoBlogger_Image_Provider_Interface
|
||||||
|
*/
|
||||||
|
private static function create_default_provider(): PRAutoBlogger_Image_Provider_Interface {
|
||||||
|
$provider_id = (string) get_option( 'prautoblogger_image_provider', PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER );
|
||||||
|
if ( 'openrouter' === $provider_id ) {
|
||||||
|
return new PRAutoBlogger_OpenRouter_Image_Provider();
|
||||||
|
}
|
||||||
|
return new PRAutoBlogger_Runware_Image_Provider();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate and attach images to a published post.
|
* Generate and attach images to a published post.
|
||||||
*
|
*
|
||||||
* Generates Image A (article-driven) and Image B (source-driven). If enabled
|
* Builds all prompts upfront, fires a single batch call (parallel via
|
||||||
* in settings. Each image is independently fallible.
|
* curl_multi on OpenRouter), then processes results. Wall-clock time
|
||||||
|
* equals the slowest image, not the sum — solving the Image B timeout.
|
||||||
*
|
*
|
||||||
* @param int $post_id Post ID to attach images to.
|
* @param int $post_id Post ID to attach images to.
|
||||||
* @param array{
|
* @param array $article_data Article title + content.
|
||||||
* post_title?: string,
|
* @param array|null $source_data Optional source data for Image B.
|
||||||
* post_content?: string,
|
|
||||||
* suggested_title?: string,
|
|
||||||
* } $article_data Article title + content.
|
|
||||||
* @param array{
|
|
||||||
* title?: string,
|
|
||||||
* selftext?: string,
|
|
||||||
* comments?: string[],
|
|
||||||
* }|null $source_data Optional Reddit source data for Image B.
|
|
||||||
*
|
*
|
||||||
* @return array{
|
* @return array{image_a_id?: int, image_b_id?: int, cost_usd: float, errors: string[]}
|
||||||
* image_a_id?: int,
|
|
||||||
* image_b_id?: int,
|
|
||||||
* cost_usd: float,
|
|
||||||
* errors: string[],
|
|
||||||
* } Attachment IDs (if successful) and cost/errors.
|
|
||||||
*/
|
*/
|
||||||
public function generate_and_attach_images(
|
public function generate_and_attach_images(
|
||||||
int $post_id,
|
int $post_id,
|
||||||
array $article_data,
|
array $article_data,
|
||||||
?array $source_data = null
|
?array $source_data = null
|
||||||
): array {
|
): array {
|
||||||
$result = [
|
$result = array(
|
||||||
'cost_usd' => 0.0,
|
'cost_usd' => 0.0,
|
||||||
'errors' => [],
|
'errors' => array(),
|
||||||
];
|
);
|
||||||
|
|
||||||
// Early exit if image generation is disabled.
|
|
||||||
if ( ! get_option( 'prautoblogger_image_enabled' ) ) {
|
if ( ! get_option( 'prautoblogger_image_enabled' ) ) {
|
||||||
PRAutoBlogger_Logger::instance()->info( 'Image generation disabled in settings.', 'image_pipeline' );
|
PRAutoBlogger_Logger::instance()->info( 'Image generation disabled in settings.', 'image_pipeline' );
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate Image A (article-driven prompt).
|
// Check whether Image B is enabled in settings.
|
||||||
$image_a_result = $this->generate_image_a( $post_id, $article_data );
|
$image_b_enabled = get_option( 'prautoblogger_image_b_enabled', '1' );
|
||||||
if ( ! is_wp_error( $image_a_result ) && isset( $image_a_result['attachment_id'] ) ) {
|
$has_source_data = null !== $source_data && ! empty( $source_data );
|
||||||
$result['image_a_id'] = $image_a_result['attachment_id'];
|
$generate_b = $has_source_data && '1' === $image_b_enabled;
|
||||||
} else {
|
|
||||||
$result['errors'][] = is_wp_error( $image_a_result )
|
// Budget pre-check: estimate cost for all images before any HTTP calls.
|
||||||
? $image_a_result->get_error_message()
|
$image_count = $generate_b ? 2 : 1;
|
||||||
: 'Image A generation produced no attachment ID.';
|
$estimated_cost = $this->provider->estimate_cost( self::DEFAULT_WIDTH, self::DEFAULT_HEIGHT ) * $image_count;
|
||||||
}
|
if ( $this->cost_tracker->would_exceed_budget( $estimated_cost ) ) {
|
||||||
if ( ! is_wp_error( $image_a_result ) ) {
|
$result['errors'][] = 'Image generation would exceed monthly budget.';
|
||||||
$result['cost_usd'] += $image_a_result['cost_usd'] ?? 0.0;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate Image B (source-driven prompt) if source data is available.
|
// v0.18.0 — per-run governor: reserve the curl_multi batch's SUMMED
|
||||||
if ( null !== $source_data && ! empty( $source_data ) ) {
|
// worst-case estimate before dispatch (no-op outside a governed
|
||||||
$image_b_result = $this->generate_image_b( $post_id, $source_data );
|
// run). A breach halts the run and skips the whole batch.
|
||||||
if ( ! is_wp_error( $image_b_result ) && isset( $image_b_result['attachment_id'] ) ) {
|
try {
|
||||||
$result['image_b_id'] = $image_b_result['attachment_id'];
|
$batch_reservation = PRAutoBlogger_Cost_Governor::open_amount_reservation( $estimated_cost, 'image_batch' );
|
||||||
} else {
|
} catch ( PRAutoBlogger_Cost_Ceiling_Exception $e ) {
|
||||||
$result['errors'][] = is_wp_error( $image_b_result )
|
$result['errors'][] = $e->getMessage();
|
||||||
? $image_b_result->get_error_message()
|
return $result;
|
||||||
: 'Image B generation produced no attachment ID.';
|
|
||||||
}
|
}
|
||||||
if ( ! is_wp_error( $image_b_result ) ) {
|
|
||||||
$result['cost_usd'] += $image_b_result['cost_usd'] ?? 0.0;
|
// Build all prompts upfront (LLM calls happen here, sequentially).
|
||||||
|
$article_prompt = $this->prompt_builder->build_article_prompt( $article_data );
|
||||||
|
$batch_requests = array(
|
||||||
|
'image_a' => array(
|
||||||
|
'prompt' => $article_prompt['prompt'],
|
||||||
|
'width' => self::DEFAULT_WIDTH,
|
||||||
|
'height' => self::DEFAULT_HEIGHT,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
$captions = array( 'image_a' => $article_prompt['caption'] );
|
||||||
|
|
||||||
|
$source_prompt = null;
|
||||||
|
if ( $generate_b ) {
|
||||||
|
$source_prompt = $this->prompt_builder->build_source_prompt( $source_data );
|
||||||
|
$batch_requests['image_b'] = array(
|
||||||
|
'prompt' => $source_prompt['prompt'],
|
||||||
|
'width' => self::DEFAULT_WIDTH,
|
||||||
|
'height' => self::DEFAULT_HEIGHT,
|
||||||
|
);
|
||||||
|
$captions['image_b'] = $source_prompt['caption'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fire all image generation requests in parallel.
|
||||||
|
try {
|
||||||
|
$batch_results = $this->provider->generate_image_batch( $batch_requests );
|
||||||
|
} catch ( \Throwable $e ) {
|
||||||
|
PRAutoBlogger_Cost_Governor::release( $batch_reservation );
|
||||||
|
PRAutoBlogger_Logger::instance()->error( 'Batch generation failed: ' . $e->getMessage(), 'image_pipeline' );
|
||||||
|
$result['errors'][] = $e->getMessage();
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settle the batch reservation to the actual per-image costs.
|
||||||
|
$actual_batch_cost = 0.0;
|
||||||
|
foreach ( $batch_results as $batch_entry ) {
|
||||||
|
$actual_batch_cost += (float) ( $batch_entry['cost_usd'] ?? 0 );
|
||||||
|
}
|
||||||
|
PRAutoBlogger_Cost_Governor::settle( $batch_reservation, $actual_batch_cost );
|
||||||
|
|
||||||
|
// Single-shot retry for any slot the provider flagged as NSFW-blocked.
|
||||||
|
if ( PRAutoBlogger_Image_NSFW_Retry::is_enabled() ) {
|
||||||
|
( new PRAutoBlogger_Image_NSFW_Retry( $this->provider, $this->prompt_builder ) )
|
||||||
|
->retry_blocked_slots( $post_id, $batch_requests, $batch_results, $captions, $article_data, $source_data );
|
||||||
|
}
|
||||||
|
|
||||||
|
$title = (string) ( $article_data['post_title'] ?? $article_data['suggested_title'] ?? '' );
|
||||||
|
|
||||||
|
// Process Image A result.
|
||||||
|
$this->process_slot( 'image_a', $post_id, $batch_results, $captions, $title, $result );
|
||||||
|
|
||||||
|
// Process Image B result (if requested).
|
||||||
|
if ( isset( $batch_results['image_b'] ) ) {
|
||||||
|
$this->process_slot( 'image_b', $post_id, $batch_results, $captions, $title, $result );
|
||||||
|
} elseif ( null === $source_data || empty( $source_data ) ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
|
sprintf( 'Image B skipped for post %d: no source data.', $post_id ),
|
||||||
|
'image_pipeline'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate Image A from article content.
|
* Process one slot's batch result: compose, sideload, link to the post.
|
||||||
*
|
*
|
||||||
|
* Image A becomes the featured image (role meta, caption prepend, and
|
||||||
|
* composed OG/square variants meta-linked); Image B is stored in post
|
||||||
|
* meta with a corner mark only. Alt text is the editorial caption
|
||||||
|
* (v0.17.0 fix — the model name used to leak here).
|
||||||
|
*
|
||||||
|
* @param string $slot 'image_a' or 'image_b'.
|
||||||
* @param int $post_id Post ID.
|
* @param int $post_id Post ID.
|
||||||
* @param array $article_data Article data.
|
* @param array $batch_results Keyed results from generate_image_batch().
|
||||||
*
|
* @param array $captions Keyed captions from prompt builder.
|
||||||
* @return array{
|
* @param string $title Post title (composer context).
|
||||||
* attachment_id?: int,
|
* @param array $result Pipeline result array (modified by reference).
|
||||||
* cost_usd: float,
|
|
||||||
* }|\WP_Error
|
|
||||||
*/
|
*/
|
||||||
private function generate_image_a( int $post_id, array $article_data ) {
|
private function process_slot( string $slot, int $post_id, array $batch_results, array $captions, string $title, array &$result ): void {
|
||||||
try {
|
$label = 'image_a' === $slot ? 'Image A' : 'Image B';
|
||||||
// Build the article-driven prompt.
|
$image_data = $batch_results[ $slot ] ?? null;
|
||||||
$prompt = $this->prompt_builder->build_article_prompt( $article_data );
|
|
||||||
|
|
||||||
// Estimate cost before generating.
|
if ( ! $image_data || isset( $image_data['error'] ) ) {
|
||||||
$estimated_cost = $this->provider->estimate_cost( self::DEFAULT_WIDTH, self::DEFAULT_HEIGHT );
|
$err_msg = $image_data['error'] ?? sprintf( '%s missing from batch results.', $label );
|
||||||
if ( $this->cost_tracker->would_exceed_budget( $estimated_cost ) ) {
|
$result['errors'][] = $err_msg;
|
||||||
return new \WP_Error(
|
// Log at WARNING so the path is visible even if the outer catch in the
|
||||||
'budget_exceeded',
|
// caller doesn't fire. See thread image-mime-bug Issue 2.
|
||||||
'Image generation would exceed monthly budget.'
|
PRAutoBlogger_Logger::instance()->warning(
|
||||||
|
sprintf( '%s generation failed for post %d: %s', $label, $post_id, $err_msg ),
|
||||||
|
'image_pipeline'
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate the image.
|
$caption = (string) ( $captions[ $slot ] ?? '' );
|
||||||
$image_data = $this->provider->generate_image( $prompt, self::DEFAULT_WIDTH, self::DEFAULT_HEIGHT );
|
$variants = $this->compose_variants( $image_data, $post_id, $caption, $title, $slot );
|
||||||
|
$featured = array_merge( $image_data, array_shift( $variants ) );
|
||||||
// Sideload the image into media library.
|
|
||||||
$attachment_id = $this->sideloader->sideload_image(
|
|
||||||
$image_data,
|
|
||||||
$post_id,
|
|
||||||
substr( $prompt, 0, 100 ) // Use first 100 chars of prompt as alt text.
|
|
||||||
);
|
|
||||||
|
|
||||||
|
$attachment_id = $this->attacher->sideload_and_log( $featured, $post_id, $slot, $label, $caption );
|
||||||
if ( is_wp_error( $attachment_id ) ) {
|
if ( is_wp_error( $attachment_id ) ) {
|
||||||
return $attachment_id;
|
$result['errors'][] = $attachment_id->get_error_message();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log the cost.
|
$result[ $slot . '_id' ] = $attachment_id;
|
||||||
$this->cost_tracker->log_image_generation(
|
$result['cost_usd'] += $image_data['cost_usd'];
|
||||||
$image_data['cost_usd'],
|
|
||||||
'flux-1-schnell',
|
|
||||||
$post_id,
|
|
||||||
'image_a'
|
|
||||||
);
|
|
||||||
|
|
||||||
PRAutoBlogger_Logger::instance()->info(
|
if ( '' !== $caption ) {
|
||||||
sprintf( 'Image A generated for post %d (attachment %d, cost $%.4f)', $post_id, $attachment_id, $image_data['cost_usd'] ),
|
update_post_meta( $attachment_id, '_prautoblogger_image_caption', $caption );
|
||||||
'image_pipeline'
|
}
|
||||||
);
|
|
||||||
|
|
||||||
return [
|
if ( 'image_a' === $slot ) {
|
||||||
'attachment_id' => $attachment_id,
|
set_post_thumbnail( $post_id, $attachment_id );
|
||||||
'cost_usd' => $image_data['cost_usd'],
|
update_post_meta( $attachment_id, '_prautoblogger_image_role', 'featured' );
|
||||||
];
|
if ( '' !== $caption ) {
|
||||||
} catch ( \Exception $e ) {
|
$this->attacher->prepend_caption_to_post( $post_id, $attachment_id, $caption );
|
||||||
PRAutoBlogger_Logger::instance()->error(
|
}
|
||||||
'Image A generation failed: ' . $e->getMessage(),
|
$this->attacher->attach_variants( $variants, $post_id, $attachment_id, $caption, (string) ( $image_data['model'] ?? 'unknown' ) );
|
||||||
'image_pipeline'
|
} else {
|
||||||
);
|
update_post_meta( $post_id, '_prautoblogger_image_b_id', $attachment_id );
|
||||||
|
|
||||||
return new \WP_Error( 'image_generation_failed', $e->getMessage() );
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate Image B from source data.
|
* Run the composer with a hard safety net: any failure (composer bugs
|
||||||
|
* included — its own internals already degrade) falls back to passing
|
||||||
|
* the base image through, so composition can never fail a publish.
|
||||||
*
|
*
|
||||||
* @param int $post_id Post ID.
|
* @param array $image_data Provider result for one slot.
|
||||||
* @param array $source_data Source Reddit data.
|
* @param int $post_id Post ID (composer context).
|
||||||
*
|
* @param string $caption Editorial caption (composer context).
|
||||||
* @return array{
|
* @param string $title Post title (composer context).
|
||||||
* attachment_id?: int,
|
* @param string $slot 'image_a' or 'image_b'.
|
||||||
* cost_usd: float,
|
* @return array<int, array<string, mixed>> Featured-first variant list (never empty).
|
||||||
* }|\WP_Error
|
|
||||||
*/
|
*/
|
||||||
private function generate_image_b( int $post_id, array $source_data ) {
|
private function compose_variants( array $image_data, int $post_id, string $caption, string $title, string $slot ): array {
|
||||||
try {
|
try {
|
||||||
// Build the source-driven prompt.
|
$variants = $this->composer->compose(
|
||||||
$prompt = $this->prompt_builder->build_source_prompt( $source_data );
|
|
||||||
|
|
||||||
// Estimate cost before generating.
|
|
||||||
$estimated_cost = $this->provider->estimate_cost( self::DEFAULT_WIDTH, self::DEFAULT_HEIGHT );
|
|
||||||
if ( $this->cost_tracker->would_exceed_budget( $estimated_cost ) ) {
|
|
||||||
return new \WP_Error(
|
|
||||||
'budget_exceeded',
|
|
||||||
'Image generation would exceed monthly budget.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate the image.
|
|
||||||
$image_data = $this->provider->generate_image( $prompt, self::DEFAULT_WIDTH, self::DEFAULT_HEIGHT );
|
|
||||||
|
|
||||||
// Sideload the image into media library.
|
|
||||||
$attachment_id = $this->sideloader->sideload_image(
|
|
||||||
$image_data,
|
$image_data,
|
||||||
$post_id,
|
array(
|
||||||
substr( $prompt, 0, 100 ) // Use first 100 chars of prompt as alt text.
|
'post_id' => $post_id,
|
||||||
|
'caption' => $caption,
|
||||||
|
'title' => $title,
|
||||||
|
'slot' => $slot,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if ( ! empty( $variants ) ) {
|
||||||
|
return $variants;
|
||||||
|
}
|
||||||
|
} catch ( \Throwable $e ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->warning(
|
||||||
|
'Image composition failed, publishing original image: ' . $e->getMessage(),
|
||||||
|
'image_composer'
|
||||||
);
|
);
|
||||||
|
|
||||||
if ( is_wp_error( $attachment_id ) ) {
|
|
||||||
return $attachment_id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log the cost.
|
return array( array_merge( $image_data, array( 'role' => 'featured' ) ) );
|
||||||
$this->cost_tracker->log_image_generation(
|
|
||||||
$image_data['cost_usd'],
|
|
||||||
'flux-1-schnell',
|
|
||||||
$post_id,
|
|
||||||
'image_b'
|
|
||||||
);
|
|
||||||
|
|
||||||
PRAutoBlogger_Logger::instance()->info(
|
|
||||||
sprintf( 'Image B generated for post %d (attachment %d, cost $%.4f)', $post_id, $attachment_id, $image_data['cost_usd'] ),
|
|
||||||
'image_pipeline'
|
|
||||||
);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'attachment_id' => $attachment_id,
|
|
||||||
'cost_usd' => $image_data['cost_usd'],
|
|
||||||
];
|
|
||||||
} catch ( \Exception $e ) {
|
|
||||||
PRAutoBlogger_Logger::instance()->error(
|
|
||||||
'Image B generation failed: ' . $e->getMessage(),
|
|
||||||
'image_pipeline'
|
|
||||||
);
|
|
||||||
|
|
||||||
return new \WP_Error( 'image_generation_failed', $e->getMessage() );
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,135 +2,288 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
* Builds image prompts from article content or source data.
|
* Builds image prompts from article content or source data.
|
||||||
*
|
*
|
||||||
* Generates concise visual concepts suitable for FLUX.1 image generation,
|
* Uses a cheap LLM (via OpenRouter) to distill article content into a concise
|
||||||
* extracting key themes from article titles/content and Reddit source data.
|
* 1-2 sentence editorial topic/mechanism summary, then substitutes that summary
|
||||||
|
* into the admin-editable editorial style template (the {{ topic_summary }}
|
||||||
|
* slot) to produce a text-free image-gen prompt. Falls back to rule-based
|
||||||
|
* synthesis if the LLM call fails, so image generation never blocks on a
|
||||||
|
* prompt-rewriting outage.
|
||||||
*
|
*
|
||||||
* Triggered by: PRAutoBlogger_Image_Pipeline during content generation run.
|
* Triggered by: PRAutoBlogger_Image_Pipeline during content generation run.
|
||||||
* Dependencies: None — pure data transformation.
|
* Dependencies: PRAutoBlogger_OpenRouter_Provider (LLM call),
|
||||||
|
* PRAutoBlogger_Image_Template_Filler (template substitution),
|
||||||
|
* PRAutoBlogger_Cost_Tracker (logs prompt-rewrite cost),
|
||||||
|
* PRAutoBlogger_Logger (diagnostics).
|
||||||
*
|
*
|
||||||
* @see core/class-image-pipeline.php — Consumes build_article_prompt() and build_source_prompt().
|
* @see core/class-image-pipeline.php — Consumes build_article_prompt() and build_source_prompt().
|
||||||
|
* @see providers/class-open-router-provider.php — LLM provider used for rewriting.
|
||||||
* @see ARCHITECTURE.md — Image generation data flow.
|
* @see ARCHITECTURE.md — Image generation data flow.
|
||||||
*/
|
*/
|
||||||
class PRAutoBlogger_Image_Prompt_Builder {
|
class PRAutoBlogger_Image_Prompt_Builder {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default system prompt that teaches the LLM how to write image-gen
|
||||||
|
* prompts. Exposed as a `public const` so the admin-settings layer can
|
||||||
|
* use it as the default value for `prautoblogger_image_prompt_instructions`.
|
||||||
|
* The option, when non-empty, wins at call time (see rewrite_via_llm).
|
||||||
|
*
|
||||||
|
* Editorial pivot (v0.16.0): the model emits a concise topic/mechanism
|
||||||
|
* summary as the SCENE — concrete, single centered focal subject, no text,
|
||||||
|
* no people-as-gag, no logos — which the prompt builder substitutes into
|
||||||
|
* the editorial style template's {{ topic_summary }} slot. The CAPTION line
|
||||||
|
* is preserved unchanged for the existing HTML-caption-below-image path.
|
||||||
|
*/
|
||||||
|
public const REWRITER_SYSTEM_PROMPT = <<<'PROMPT'
|
||||||
|
You write subject descriptions for a text-free editorial science illustration about peptides, supplements, or biohacking.
|
||||||
|
|
||||||
|
Given an article title and summary, output TWO parts separated by a blank line:
|
||||||
|
|
||||||
|
SCENE: A concise 1-2 sentence description of the article's core topic or mechanism, written as the SUBJECT of a single editorial illustration. Name ONE concrete, centered focal subject and the few supporting visual elements around it (e.g. a labelled-free vial and dropper, a stylised receptor on a cell membrane, a molecular chain, a dosing syringe, an organ cross-section). Favor a clear central focal point with margin around it so the image crops well. This is image-gen direction, not prose.
|
||||||
|
|
||||||
|
CAPTION: A short, informative caption line (under 15 words) for display as HTML text below the image. Plain and editorial, not a joke.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- The SCENE must depict the article's actual subject/mechanism, not a generic science scene.
|
||||||
|
- NO text, words, captions, speech bubbles, labels, or logos described in the SCENE — the illustration is text-free; text is rendered separately.
|
||||||
|
- NO people used as a visual gag and NO cartoon/comic framing. A human anatomical element (a cell, an organ, a hand holding a vial) is fine if it is the legitimate subject.
|
||||||
|
- Do NOT personify molecules, peptides, hormones, or proteins (no "smiling molecule"); depict them as clean stylised diagrams or structures.
|
||||||
|
- Keep the staging simple with one clear focal subject, readable at small sizes.
|
||||||
|
- Output ONLY the scene and caption. No preamble, no explanation.
|
||||||
|
|
||||||
|
Example output format:
|
||||||
|
A single glass peptide vial on a clean surface with a fine dropper above it, a faint stylised molecular chain arcing behind as a backdrop, centered with generous negative space.
|
||||||
|
|
||||||
|
A concentrated look at how the compound is prepared before use.
|
||||||
|
PROMPT;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Max tokens for the rewriter response. Enough room for the 1-2 sentence
|
||||||
|
* topic/mechanism summary plus the short editorial caption line.
|
||||||
|
*/
|
||||||
|
private const REWRITER_MAX_TOKENS = 180;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenRouter provider for LLM calls. Null until first use.
|
||||||
|
*
|
||||||
|
* @var PRAutoBlogger_OpenRouter_Provider|null
|
||||||
|
*/
|
||||||
|
private ?PRAutoBlogger_OpenRouter_Provider $llm = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opik trace context for instrumentation.
|
||||||
|
*
|
||||||
|
* @var PRAutoBlogger_Opik_Trace_Context|null
|
||||||
|
*/
|
||||||
|
private ?PRAutoBlogger_Opik_Trace_Context $trace_context = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor.
|
||||||
|
*
|
||||||
|
* @param PRAutoBlogger_Opik_Trace_Context|null $trace_context Optional trace context for instrumentation.
|
||||||
|
*/
|
||||||
|
public function __construct( ?PRAutoBlogger_Opik_Trace_Context $trace_context = null ) {
|
||||||
|
$this->trace_context = $trace_context;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a visual prompt from finished article content.
|
* Build a visual prompt from finished article content.
|
||||||
*
|
*
|
||||||
* Extracts the title and first paragraph to synthesize a product-marketing
|
* Tries LLM rewriting first; falls back to rule-based synthesis on
|
||||||
* angle visual concept suitable for a featured image. Appends the style suffix
|
* failure. Splits scene (the topic summary, for image gen) from caption
|
||||||
* from plugin options.
|
* (HTML below the image) and substitutes the scene into the editorial
|
||||||
|
* style template's {{ topic_summary }} slot.
|
||||||
*
|
*
|
||||||
* @param array{
|
* @param array{post_title?: string, post_content?: string, suggested_title?: string} $article_data
|
||||||
* post_title?: string,
|
* @return array{prompt: string, caption: string}
|
||||||
* post_content?: string,
|
|
||||||
* suggested_title?: string,
|
|
||||||
* } $article_data Article data with title and HTML content.
|
|
||||||
*
|
|
||||||
* @return string Concise prompt (under 200 words) with style suffix appended.
|
|
||||||
*/
|
*/
|
||||||
public function build_article_prompt( array $article_data ): string {
|
public function build_article_prompt( array $article_data ): array {
|
||||||
$title = $article_data['post_title'] ?? $article_data['suggested_title'] ?? 'Product';
|
$title = $article_data['post_title'] ?? $article_data['suggested_title'] ?? 'Product';
|
||||||
$content = $article_data['post_content'] ?? '';
|
$content = $article_data['post_content'] ?? '';
|
||||||
|
$first_para = PRAutoBlogger_Image_Scene_Parser::extract_first_paragraph( $content );
|
||||||
|
|
||||||
// Extract first paragraph from HTML content.
|
$parsed = $this->rewrite_via_llm( $title, $first_para );
|
||||||
$first_para = $this->extract_first_paragraph( $content );
|
|
||||||
|
|
||||||
// Build visual concepts from title + first paragraph.
|
return array(
|
||||||
$concepts = $this->synthesize_visual_concepts( $title, $first_para );
|
'prompt' => PRAutoBlogger_Image_Template_Filler::fill( $parsed['scene'] ),
|
||||||
|
'caption' => $parsed['caption'],
|
||||||
// Append style suffix.
|
);
|
||||||
$style_suffix = get_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
|
|
||||||
|
|
||||||
return trim( $concepts . ' ' . $style_suffix );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a visual prompt from source Reddit thread data.
|
* Build a visual prompt from source Reddit thread data. Tries LLM
|
||||||
|
* rewriting first; falls back to rule-based synthesis on failure.
|
||||||
*
|
*
|
||||||
* Extracts the Reddit thread title and top comments to synthesize an
|
* @param array{title?: string, selftext?: string, comments?: string[]} $source_data
|
||||||
* editorial-cartoonist angle visual concept. Appends the style suffix
|
* @return array{prompt: string, caption: string}
|
||||||
* from plugin options.
|
|
||||||
*
|
|
||||||
* @param array{
|
|
||||||
* title?: string,
|
|
||||||
* selftext?: string,
|
|
||||||
* comments?: string[],
|
|
||||||
* } $source_data Reddit source data with title and comments.
|
|
||||||
*
|
|
||||||
* @return string Concise prompt (under 200 words) with style suffix appended.
|
|
||||||
*/
|
*/
|
||||||
public function build_source_prompt( array $source_data ): string {
|
public function build_source_prompt( array $source_data ): array {
|
||||||
$title = $source_data['title'] ?? 'Reddit Discussion';
|
$title = $source_data['title'] ?? 'Reddit Discussion';
|
||||||
$comments = $source_data['comments'] ?? [];
|
$comments = $source_data['comments'] ?? array();
|
||||||
|
$context = is_array( $comments ) && ! empty( $comments ) ? $comments[0] : '';
|
||||||
|
|
||||||
// Build visual concepts from title + top comments.
|
$parsed = $this->rewrite_via_llm( $title, $context );
|
||||||
$concepts = $this->synthesize_visual_concepts(
|
|
||||||
$title,
|
return array(
|
||||||
is_array( $comments ) && ! empty( $comments ) ? $comments[0] : ''
|
'prompt' => PRAutoBlogger_Image_Template_Filler::fill( $parsed['scene'] ),
|
||||||
|
'caption' => $parsed['caption'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use a cheap LLM to distill title + context into a visual scene + caption.
|
||||||
|
*
|
||||||
|
* The LLM response contains a SCENE line and a CAPTION line separated by a
|
||||||
|
* blank line. This method parses them apart so the scene drives image gen
|
||||||
|
* (no text baked in) and the caption is inserted as HTML below the image.
|
||||||
|
*
|
||||||
|
* Falls back to rule-based synthesis if the LLM call fails for any
|
||||||
|
* reason (network, auth, timeout, unexpected response shape).
|
||||||
|
*
|
||||||
|
* Side effects: one OpenRouter API call; one cost-tracker log entry.
|
||||||
|
*
|
||||||
|
* @param string $title Article or thread title.
|
||||||
|
* @param string $context First paragraph or top comment.
|
||||||
|
* @return array{scene: string, caption: string} Scene for image gen, caption for HTML.
|
||||||
|
*/
|
||||||
|
private function rewrite_via_llm( string $title, string $context ): array {
|
||||||
|
$title = trim( sanitize_text_field( $title ) );
|
||||||
|
$context = trim( sanitize_text_field( $context ) );
|
||||||
|
|
||||||
|
// Truncate context to keep prompt tokens low.
|
||||||
|
if ( strlen( $context ) > 300 ) {
|
||||||
|
$context = substr( $context, 0, 300 ) . '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
$user_message = "Article title: {$title}";
|
||||||
|
if ( '' !== $context ) {
|
||||||
|
$user_message .= "\n\nSummary: {$context}";
|
||||||
|
}
|
||||||
|
|
||||||
|
$span_id = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ( null !== $this->trace_context ) {
|
||||||
|
$span_id = $this->trace_context->start_span(
|
||||||
|
array(
|
||||||
|
'name' => 'image_prompt_rewrite',
|
||||||
|
'type' => 'llm',
|
||||||
|
'model' => get_option( 'prautoblogger_analysis_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL ),
|
||||||
|
'provider' => 'openrouter',
|
||||||
|
'input' => array(
|
||||||
|
'title' => $title,
|
||||||
|
'context' => substr( $context, 0, 200 ),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$llm = $this->get_llm_provider();
|
||||||
|
$model = get_option( 'prautoblogger_analysis_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL );
|
||||||
|
$system = $this->resolve_system_prompt();
|
||||||
|
|
||||||
|
$result = $llm->send_chat_completion(
|
||||||
|
array(
|
||||||
|
array(
|
||||||
|
'role' => 'system',
|
||||||
|
'content' => $system,
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'role' => 'user',
|
||||||
|
'content' => $user_message,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
$model,
|
||||||
|
array(
|
||||||
|
'temperature' => 0.7,
|
||||||
|
'max_tokens' => self::REWRITER_MAX_TOKENS,
|
||||||
|
'stage' => 'image_prompt_rewrite',
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Append style suffix.
|
$raw = trim( $result['content'] ?? '' );
|
||||||
$style_suffix = get_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
|
|
||||||
|
|
||||||
return trim( $concepts . ' ' . $style_suffix );
|
// Log the rewrite cost so it shows in the analytics dashboard.
|
||||||
|
( new PRAutoBlogger_Cost_Tracker() )->log_api_call(
|
||||||
|
null,
|
||||||
|
'image_prompt_rewrite',
|
||||||
|
'openrouter',
|
||||||
|
$model,
|
||||||
|
$result['prompt_tokens'] ?? 0,
|
||||||
|
$result['completion_tokens'] ?? 0
|
||||||
|
);
|
||||||
|
|
||||||
|
$cost = $llm->estimate_cost(
|
||||||
|
$model,
|
||||||
|
$result['prompt_tokens'] ?? 0,
|
||||||
|
$result['completion_tokens'] ?? 0
|
||||||
|
);
|
||||||
|
|
||||||
|
PRAutoBlogger_Logger::instance()->debug(
|
||||||
|
sprintf( 'Image prompt rewritten (%d→%d chars, $%.6f): %s', strlen( $user_message ), strlen( $raw ), $cost, substr( $raw, 0, 120 ) ),
|
||||||
|
'image_prompt_builder'
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( null !== $span_id && null !== $this->trace_context ) {
|
||||||
|
$this->trace_context->end_span(
|
||||||
|
$span_id,
|
||||||
|
array(
|
||||||
|
'output' => array(
|
||||||
|
'raw_response' => substr( $raw, 0, 200 ),
|
||||||
|
),
|
||||||
|
'usage' => array(
|
||||||
|
'prompt_tokens' => $result['prompt_tokens'] ?? 0,
|
||||||
|
'completion_tokens' => $result['completion_tokens'] ?? 0,
|
||||||
|
'total_tokens' => ( $result['prompt_tokens'] ?? 0 ) + ( $result['completion_tokens'] ?? 0 ),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( '' !== $raw ) {
|
||||||
|
return PRAutoBlogger_Image_Scene_Parser::parse_scene_and_caption( $raw );
|
||||||
|
}
|
||||||
|
} catch ( \Throwable $e ) {
|
||||||
|
// LLM failure is not fatal — fall back to rule-based synthesis.
|
||||||
|
PRAutoBlogger_Logger::instance()->warning(
|
||||||
|
sprintf( 'Image prompt LLM rewrite %s, using fallback: %s', get_class( $e ), $e->getMessage() ),
|
||||||
|
'image_prompt_builder'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return PRAutoBlogger_Image_Scene_Parser::synthesize_visual_concepts_fallback( $title, $context );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract the first paragraph from HTML content.
|
* Public entry into the rule-based fallback, used by NSFW retry to
|
||||||
|
* rebuild a provider-safe prompt from just the article title. Matches
|
||||||
|
* the return shape of build_article_prompt() / build_source_prompt().
|
||||||
*
|
*
|
||||||
* Removes all tags and captures the text up to the first double-newline
|
* @param string $title Article or source title.
|
||||||
* or 200 characters, whichever comes first.
|
* @return array{prompt: string, caption: string}
|
||||||
*
|
|
||||||
* @param string $html HTML content.
|
|
||||||
*
|
|
||||||
* @return string Plain text of the first paragraph.
|
|
||||||
*/
|
*/
|
||||||
private function extract_first_paragraph( string $html ): string {
|
public function build_fallback_prompt( string $title ): array {
|
||||||
// Remove HTML tags.
|
$parsed = PRAutoBlogger_Image_Scene_Parser::synthesize_visual_concepts_fallback( $title, '' );
|
||||||
$text = wp_strip_all_tags( $html );
|
return array(
|
||||||
|
'prompt' => PRAutoBlogger_Image_Template_Filler::fill( $parsed['scene'] ),
|
||||||
// Split on double-newline and take the first paragraph.
|
'caption' => $parsed['caption'],
|
||||||
$paras = preg_split( '/\n\n+/', $text );
|
);
|
||||||
$para = isset( $paras[0] ) ? trim( $paras[0] ) : '';
|
|
||||||
|
|
||||||
// Limit to first 200 chars to keep prompt concise.
|
|
||||||
if ( strlen( $para ) > 200 ) {
|
|
||||||
$para = substr( $para, 0, 200 ) . '...';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $para;
|
/** Lazy-load the OpenRouter provider. */
|
||||||
|
private function get_llm_provider(): PRAutoBlogger_OpenRouter_Provider {
|
||||||
|
if ( null === $this->llm ) {
|
||||||
|
$this->llm = new PRAutoBlogger_OpenRouter_Provider();
|
||||||
|
}
|
||||||
|
return $this->llm;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Admin option wins; blank falls back to REWRITER_SYSTEM_PROMPT. */
|
||||||
* Synthesize visual concepts from a title and supporting text.
|
private function resolve_system_prompt(): string {
|
||||||
*
|
$override = (string) get_option( 'prautoblogger_image_prompt_instructions', '' );
|
||||||
* Combines key words and themes into a short visual narrative that FLUX.1
|
return '' !== trim( $override ) ? $override : self::REWRITER_SYSTEM_PROMPT;
|
||||||
* can render. This is intentionally rule-based rather than LLM-driven to
|
|
||||||
* avoid another API call per image generation.
|
|
||||||
*
|
|
||||||
* @param string $title Main heading / topic.
|
|
||||||
* @param string $supporting_text Additional context (first para or comment).
|
|
||||||
*
|
|
||||||
* @return string Synthesized visual prompt concept.
|
|
||||||
*/
|
|
||||||
private function synthesize_visual_concepts( string $title, string $supporting_text ): string {
|
|
||||||
$title = trim( sanitize_text_field( $title ) );
|
|
||||||
$text = trim( sanitize_text_field( $supporting_text ) );
|
|
||||||
|
|
||||||
// Build a simple declarative concept from title and text snippets.
|
|
||||||
$prompt = "A visual representation of: {$title}";
|
|
||||||
|
|
||||||
if ( ! empty( $text ) ) {
|
|
||||||
// Take first 150 chars to avoid bloating the prompt.
|
|
||||||
$snippet = strlen( $text ) > 150
|
|
||||||
? substr( $text, 0, 150 ) . '...'
|
|
||||||
: $text;
|
|
||||||
|
|
||||||
$prompt .= ". Context: {$snippet}";
|
|
||||||
}
|
|
||||||
|
|
||||||
return trim( $prompt );
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
94
includes/core/class-image-scene-parser.php
Normal file
94
includes/core/class-image-scene-parser.php
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses LLM output into scene/caption pairs and synthesises fallback prompts
|
||||||
|
* from article titles when the LLM call fails.
|
||||||
|
*
|
||||||
|
* Who calls it: PRAutoBlogger_Image_Prompt_Builder (static calls).
|
||||||
|
* Dependencies: None.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Image_Scene_Parser {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the LLM response into separate scene and caption parts.
|
||||||
|
*
|
||||||
|
* Expected format from the LLM:
|
||||||
|
* Scene description text here.
|
||||||
|
*
|
||||||
|
* "Caption punchline here."
|
||||||
|
*
|
||||||
|
* Falls back gracefully: if no blank-line separator is found, the entire
|
||||||
|
* response becomes the scene with an empty caption.
|
||||||
|
*
|
||||||
|
* @param string $raw Raw LLM response text.
|
||||||
|
* @return array{scene: string, caption: string}
|
||||||
|
*/
|
||||||
|
public static function parse_scene_and_caption( string $raw ): array {
|
||||||
|
// Split on blank line (the format the system prompt requests).
|
||||||
|
$parts = preg_split( '/\n\s*\n/', $raw, 2 );
|
||||||
|
|
||||||
|
if ( count( $parts ) >= 2 ) {
|
||||||
|
$scene = trim( $parts[0] );
|
||||||
|
$caption = trim( $parts[1] );
|
||||||
|
// Strip surrounding quotes from caption — the LLM wraps it in quotes.
|
||||||
|
$caption = trim( $caption, '"\'' );
|
||||||
|
return array(
|
||||||
|
'scene' => $scene,
|
||||||
|
'caption' => $caption,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No blank-line separator found — treat entire response as scene.
|
||||||
|
return array(
|
||||||
|
'scene' => trim( $raw ),
|
||||||
|
'caption' => '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the first paragraph from HTML content.
|
||||||
|
*
|
||||||
|
* Removes all tags and captures the text up to the first double-newline
|
||||||
|
* or 200 characters, whichever comes first.
|
||||||
|
*
|
||||||
|
* @param string $html HTML content.
|
||||||
|
* @return string Plain text of the first paragraph.
|
||||||
|
*/
|
||||||
|
public static function extract_first_paragraph( string $html ): string {
|
||||||
|
$text = wp_strip_all_tags( $html );
|
||||||
|
$paras = preg_split( '/\n\n+/', $text );
|
||||||
|
$para = isset( $paras[0] ) ? trim( $paras[0] ) : '';
|
||||||
|
|
||||||
|
if ( strlen( $para ) > 200 ) {
|
||||||
|
$para = substr( $para, 0, 200 ) . '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $para;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rule-based fallback when LLM rewriting is unavailable.
|
||||||
|
*
|
||||||
|
* Kept deliberately simple — this only runs if OpenRouter is down. Produces
|
||||||
|
* a text-free editorial-illustration subject (not a comic) so the prompt
|
||||||
|
* builder always has a non-empty topic summary to substitute into the
|
||||||
|
* style template, never shipping a blank prompt to the provider (brief A6).
|
||||||
|
*
|
||||||
|
* @param string $title Main heading / topic.
|
||||||
|
* @param string $supporting_text Additional context.
|
||||||
|
* @return array{scene: string, caption: string} Scene for image gen, caption for HTML.
|
||||||
|
*/
|
||||||
|
public static function synthesize_visual_concepts_fallback( string $title, string $supporting_text ): array {
|
||||||
|
// Fallback produces a simple editorial-illustration subject when the LLM is unavailable.
|
||||||
|
$scene = sprintf(
|
||||||
|
'A clean, centered editorial science illustration representing the topic of %s, with a single clear focal subject and generous negative space.',
|
||||||
|
$title
|
||||||
|
);
|
||||||
|
|
||||||
|
return array(
|
||||||
|
'scene' => trim( $scene ),
|
||||||
|
'caption' => '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user