Compare commits
78 Commits
feature/v0
...
claude/pha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
579c66ecab | ||
|
|
91dea0501a | ||
|
|
72bfd232a4 | ||
|
|
682c98e981 | ||
|
|
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 |
37
.github/workflows/ci.yml
vendored
37
.github/workflows/ci.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
php: ['7.4', '8.1', '8.3']
|
||||
php: ['8.1', '8.2', '8.3']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup PHP ${{ matrix.php }}
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
coverage: none
|
||||
- name: Syntax check all PHP files
|
||||
# Exclude .github/mu-plugins — those run on the production server (PHP 8+)
|
||||
# and may use PHP 8+ syntax (union types etc.) that 7.4 rejects.
|
||||
# and may use PHP 8+ syntax (union types etc.) that older versions reject.
|
||||
run: |
|
||||
set -e
|
||||
find . -name '*.php' \
|
||||
@@ -44,9 +44,14 @@ jobs:
|
||||
phpcs:
|
||||
name: PHPCS (WordPress standards)
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # non-blocking for now — see CONVENTIONS.md
|
||||
continue-on-error: false # strict after PHPCS cleanup in v0.8.3
|
||||
permissions:
|
||||
contents: write # allows phpcbf auto-fix commits back to the PR branch
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.head_ref || github.ref_name }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
@@ -55,22 +60,34 @@ jobs:
|
||||
tools: composer:v2, phpcs
|
||||
- name: Install WordPress Coding Standards
|
||||
run: |
|
||||
# Allow the PHPCS composer-installer plugin (auto-registers standards).
|
||||
composer global config --no-plugins allow-plugins.dealerdirect/phpcodesniffer-composer-installer true
|
||||
# WPCS 3.x requires PHPCSExtra (for the Universal.* and NormalizedArrays.* sniffs)
|
||||
# AND phpcsutils. Installing the installer itself lets it auto-register the standards path.
|
||||
composer global require --no-interaction \
|
||||
"dealerdirect/phpcodesniffer-composer-installer:^1.0" \
|
||||
"wp-coding-standards/wpcs:^3.0" \
|
||||
"phpcsstandards/phpcsutils:^1.0" \
|
||||
"phpcsstandards/phpcsextra:^1.0"
|
||||
- name: Run PHPCS
|
||||
- name: Auto-fix with phpcbf (commit back to branch if changed)
|
||||
# phpcbf exits 1 when it makes fixes, 0 when nothing to fix — both are success here.
|
||||
# Commits made via GITHUB_TOKEN do not trigger a new CI run (GitHub prevents loops).
|
||||
run: |
|
||||
phpcs --standard=WordPress-Core \
|
||||
phpcbf --standard=phpcs.xml \
|
||||
--extensions=php \
|
||||
--ignore=vendor/*,tests/*,node_modules/* \
|
||||
includes/ prautoblogger.php uninstall.php || true
|
||||
if ! git diff --quiet; then
|
||||
git config user.name "peptiderepo"
|
||||
git config user.email "peptiderepo@users.noreply.github.com"
|
||||
git add -A
|
||||
git commit -m "style: auto-fix PHPCS violations via phpcbf"
|
||||
git push
|
||||
fi
|
||||
- name: Run PHPCS (only non-auto-fixable violations can fail here)
|
||||
run: |
|
||||
phpcs --standard=phpcs.xml \
|
||||
--extensions=php \
|
||||
--ignore=vendor/*,tests/*,node_modules/* \
|
||||
--warning-severity=0 \
|
||||
--report=summary \
|
||||
--report=full \
|
||||
includes/ prautoblogger.php uninstall.php
|
||||
|
||||
phpunit:
|
||||
@@ -79,7 +96,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
php: ['7.4', '8.1', '8.3']
|
||||
php: ['8.1', '8.2', '8.3']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup PHP ${{ matrix.php }}
|
||||
|
||||
22
.github/workflows/cto-review.yml
vendored
22
.github/workflows/cto-review.yml
vendored
@@ -123,17 +123,18 @@ jobs:
|
||||
git fetch origin ${{ github.event.pull_request.base.ref }} --depth=50
|
||||
BASE_SHA="${{ github.event.pull_request.base.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"
|
||||
# (issue #110). File transport also sidesteps the ~1MB step-output cap.
|
||||
git diff --no-color "$BASE_SHA" "$HEAD_SHA" | head -c 400000 > /tmp/cto-review-diff.txt
|
||||
FILES=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")
|
||||
SIZE=$(echo -n "$DIFF" | wc -c)
|
||||
SIZE=$(wc -c < /tmp/cto-review-diff.txt)
|
||||
{
|
||||
echo "size=$SIZE"
|
||||
echo "files<<EOF_FILES"
|
||||
echo "$FILES"
|
||||
echo "EOF_FILES"
|
||||
echo "diff<<EOF_DIFF"
|
||||
echo "$DIFF"
|
||||
echo "EOF_DIFF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Load repo conventions
|
||||
@@ -161,7 +162,7 @@ jobs:
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO_NAME: ${{ github.event.repository.name }}
|
||||
DIFF: ${{ steps.diff.outputs.diff }}
|
||||
DIFF_PATH: /tmp/cto-review-diff.txt
|
||||
DIFF_SIZE: ${{ steps.diff.outputs.size }}
|
||||
CHANGED_FILES: ${{ steps.diff.outputs.files }}
|
||||
CONVENTIONS: ${{ steps.conventions.outputs.content }}
|
||||
@@ -208,7 +209,8 @@ jobs:
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -228,7 +230,9 @@ jobs:
|
||||
user_blocks.append(f"**Changed files:**\n```\n{os.environ['CHANGED_FILES']}\n```\n")
|
||||
if os.environ.get("CONVENTIONS", "").strip():
|
||||
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)
|
||||
|
||||
tools = [{
|
||||
@@ -447,7 +451,7 @@ jobs:
|
||||
if rev.get("concerns"):
|
||||
lines.append("**Concerns:**")
|
||||
for c in rev["concerns"]:
|
||||
sev = c["severity"].upper()
|
||||
sev = c.get("severity", "UNKNOWN").upper()
|
||||
f = f"`{c['file']}`" if c.get("file") else ""
|
||||
lines.append(f"- **{sev}** {f} — {c['issue']}")
|
||||
lines.append("")
|
||||
|
||||
74
.github/workflows/deploy.yml
vendored
74
.github/workflows/deploy.yml
vendored
@@ -4,21 +4,20 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: deploy-prautoblogger-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: PHP Lint Gate
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
- uses: actions/checkout@v4
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.1'
|
||||
coverage: none
|
||||
|
||||
- name: PHP Lint Check
|
||||
run: |
|
||||
set -e
|
||||
@@ -30,60 +29,9 @@ jobs:
|
||||
-print0 | xargs -0 -n1 -P4 php -l > /dev/null
|
||||
|
||||
deploy:
|
||||
name: Deploy (shared pipeline)
|
||||
needs: validate
|
||||
name: Deploy to Production
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
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 && \
|
||||
wp litespeed-purge all 2>/dev/null || \
|
||||
(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
|
||||
uses: peptiderepo/peptide-e2e/.github/workflows/deploy-app.yml@main
|
||||
with:
|
||||
target_path: wp-content/plugins/prautoblogger
|
||||
secrets: inherit
|
||||
|
||||
22
.github/workflows/main-push-audit.yml
vendored
22
.github/workflows/main-push-audit.yml
vendored
@@ -29,12 +29,22 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const sha = context.sha;
|
||||
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: sha,
|
||||
});
|
||||
const merged = prs.find(p => p.merged_at);
|
||||
// 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({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: sha,
|
||||
});
|
||||
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) {
|
||||
core.info(`OK: commit ${sha.substring(0,7)} came from PR #${merged.number}`);
|
||||
return;
|
||||
|
||||
433
ARCHITECTURE.md
Executable file → Normal file
433
ARCHITECTURE.md
Executable file → Normal file
@@ -75,6 +75,11 @@ PRAutoBlogger is a WordPress plugin that monitors social media (starting with Re
|
||||
│ │ 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.
|
||||
└────────────┬────────────┘
|
||||
│
|
||||
@@ -103,16 +108,33 @@ prautoblogger/
|
||||
├── CHANGELOG.md # Semantic versioning changelog
|
||||
│
|
||||
├── 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/
|
||||
│ │ ├── 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)
|
||||
│ │ └── posts-widget.css # Frontend posts widget styles (uses theme CSS vars)
|
||||
│ └── js/
|
||||
│ ├── admin.js # Admin page interactivity (vanilla JS / Alpine.js)
|
||||
│ ├── board.js # Board poller: AJAX poll + DOM updates + backoff (v0.19.0)
|
||||
│ ├── dossier.js # Per-stage raw-trace toggle: aria-expanded / aria-controls (v0.19.2)
|
||||
│ └── posts-widget.js # React component for frontend post cards (wp.element)
|
||||
│
|
||||
├── includes/
|
||||
│ ├── class-prautoblogger.php # Main orchestrator — registers all hooks, delegates execution
|
||||
│ ├── class-executor.php # Cron handlers, generation AJAX (start + status), model registry
|
||||
│ ├── class-prautoblogger.php # Main orchestrator — registers all hooks, delegates execution (258 lines; 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 # v0.18.0 substrate tables (prompts, run_sources, run_decisions, runs, run_stages)
|
||||
│ ├── 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
|
||||
@@ -120,6 +142,12 @@ prautoblogger/
|
||||
│ ├── class-autoloader.php # PSR-4-style autoloader for plugin classes
|
||||
│ │
|
||||
│ ├── admin/
|
||||
│ │ ├── class-board-page.php # Kanban board -- primary landing screen (v0.19.0)
|
||||
│ │ ├── class-board-data-provider.php # Board orchestrator: 4-column snapshot + dossier deep-links (v0.19.0, v0.19.2)
|
||||
│ │ ├── class-board-gen-log-query.php # Board gen_log queries: Generating + Failed column raw DB reads (v0.19.0)
|
||||
│ │ ├── class-dossier-page.php # Article dossier admin page: hidden-submenu, priority 12, enqueue, nonce (v0.19.2)
|
||||
│ │ ├── 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-admin-page.php # Main settings page (tabbed SaaS-style UI)
|
||||
│ │ ├── class-settings-fields.php # Declarative settings: sections + core fields (API/Models/Content/Sources)
|
||||
│ │ ├── class-settings-fields-extended.php # Operational fields: schedule, publishing, display, analytics, images
|
||||
@@ -147,8 +175,29 @@ prautoblogger/
|
||||
│ │ ├── 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-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-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 (idempotent resume, output snapshots)
|
||||
│ │ ├── 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-article-worker.php # Single-article generation (content + edit + publish)
|
||||
│ │ ├── class-pipeline-runner.php # Orchestrates pipeline; chains per-article cron jobs
|
||||
@@ -158,6 +207,7 @@ prautoblogger/
|
||||
│ ├── providers/
|
||||
│ │ ├── interface-llm-provider.php # Contract for any LLM provider
|
||||
│ │ ├── 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
|
||||
│ │ ├── interface-source-provider.php # Contract for any social media source
|
||||
@@ -170,11 +220,13 @@ prautoblogger/
|
||||
│ │ ├── class-open-router-image-support.php # API key, response parsing, retry/backoff helpers
|
||||
│ │ ├── 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 # Header building + Hostinger cURL auth workaround
|
||||
│ │ ├── 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)
|
||||
│ │
|
||||
│ ├── services/
|
||||
@@ -198,11 +250,15 @@ prautoblogger/
|
||||
│
|
||||
├── templates/
|
||||
│ └── admin/
|
||||
│ ├── board-page.php # Kanban board template (4-column editorial layout) (v0.19.0)
|
||||
│ ├── dossier-page.php # Article dossier: two-column layout, sidebar, stage sections (v0.19.2)
|
||||
│ ├── dossier-stage-section.php # Per-stage block: rendered output + raw-trace toggle + cost receipt (v0.19.2)
|
||||
│ ├── metabox-dossier-link.php # Slim post metabox: "View generation dossier →" link (v0.19.2)
|
||||
│ ├── settings-page.php # Settings page template (tabbed sidebar layout)
|
||||
│ ├── metrics-page.php # Metrics/cost dashboard template
|
||||
│ ├── review-queue.php # Review queue table template
|
||||
│ ├── log-viewer.php # Activity log viewer template
|
||||
│ └── metabox-generation-info.php# Post metabox template
|
||||
│ └── metabox-generation-info.php# Post metabox template (superseded by metabox-dossier-link.php in v0.19.2)
|
||||
│
|
||||
├── languages/
|
||||
│ └── prautoblogger.pot # i18n translation template
|
||||
@@ -299,11 +355,19 @@ Hostinger's 120-second LiteSpeed connection timeout. The frontend polls for stat
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
**Fallback detection:** If Hostinger kills the PHP process before it updates the
|
||||
transient (despite `ignore_user_abort`), the status endpoint detects the orphan after
|
||||
180 seconds by checking for recently-created posts with `_prautoblogger_run_id` meta.
|
||||
It also releases any stale generation lock. After 600 seconds (STATUS_TTL), it gives
|
||||
up unconditionally and reports a timeout.
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
@@ -357,7 +421,7 @@ All tables use `$wpdb->prefix` + `prautoblogger_` prefix.
|
||||
| id | BIGINT UNSIGNED | Auto-increment PK |
|
||||
| post_id | BIGINT UNSIGNED | WordPress post ID (nullable, set after publish)|
|
||||
| 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' |
|
||||
| model | VARCHAR(100) | Model identifier used |
|
||||
| prompt_tokens | INT | Input tokens consumed |
|
||||
@@ -366,6 +430,8 @@ All tables use `$wpdb->prefix` + `prautoblogger_` prefix.
|
||||
| request_json | LONGTEXT | Full request payload (for debugging) |
|
||||
| response_status | VARCHAR(20) | 'success', 'error', 'timeout' |
|
||||
| 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 |
|
||||
|
||||
- INDEX: `post_id`
|
||||
@@ -373,6 +439,18 @@ All tables use `$wpdb->prefix` + `prautoblogger_` prefix.
|
||||
- INDEX: `created_at`
|
||||
- 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
|
||||
|
||||
| Column | Type | Description |
|
||||
@@ -405,6 +483,97 @@ All tables use `$wpdb->prefix` + `prautoblogger_` prefix.
|
||||
- INDEX: `measured_at`
|
||||
- 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 |
|
||||
| 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) |
|
||||
| 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`
|
||||
|
||||
### WordPress Options (`wp_options`)
|
||||
|
||||
All prefixed with `prautoblogger_`:
|
||||
@@ -419,11 +588,14 @@ All prefixed with `prautoblogger_`:
|
||||
| `prautoblogger_analysis_model` | OpenRouter model for analysis (default: cheap) |
|
||||
| `prautoblogger_writing_model` | OpenRouter model for writing (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_writing_pipeline` | 'single_pass' or 'multi_step' (default: multi_step) |
|
||||
| `prautoblogger_niche_description` | Text description of the site's niche |
|
||||
| `prautoblogger_target_subreddits` | JSON array of subreddits to monitor |
|
||||
| `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_min_word_count` | Minimum article word count (default: 800) |
|
||||
| `prautoblogger_max_word_count` | Maximum article word count (default: 2000) |
|
||||
@@ -437,15 +609,22 @@ All prefixed with `prautoblogger_`:
|
||||
| `prautoblogger_schedule_time` | Daily generation time (HH:MM, default: '03:00') |
|
||||
| `prautoblogger_image_model` | Image model slug from `Image_Model_Registry::get_models()`; provider is derived from this on save |
|
||||
| `prautoblogger_image_provider` | Derived from the chosen model on save (v0.8.0+); not editable in admin UI |
|
||||
| `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 |
|
||||
| `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_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_suffix` | Text appended to every image prompt (default: CEO-locked 90s infomercial prompt) |
|
||||
| `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_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
|
||||
|
||||
@@ -464,6 +643,15 @@ Stored on every PRAutoBlogger-generated post:
|
||||
| `_prautoblogger_editor_notes` | Chief editor's review notes |
|
||||
| `_prautoblogger_generated_at` | ISO 8601 timestamp of generation |
|
||||
| `_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.
|
||||
|
||||
---
|
||||
|
||||
@@ -476,7 +664,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 .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` |
|
||||
| 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. | 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` |
|
||||
| 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)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -531,6 +763,52 @@ Reddit rejected our OAuth API application (April 2026). We initially switched to
|
||||
|
||||
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).
|
||||
@@ -544,6 +822,135 @@ We already use Cloudflare for DNS/CDN on peptiderepo.com, so layering AI Gateway
|
||||
|
||||
---
|
||||
|
||||
### #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)
|
||||
|
||||
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`): registered as a hidden submenu under
|
||||
`prautoblogger-settings` at `admin_menu` priority **12**, one step after the board (priority
|
||||
11), which is itself after the parent menu (priority 10). The page is immediately removed
|
||||
from `$submenu` so it does not appear in the WP admin sidebar, but remains reachable by
|
||||
direct URL. URL shape: `admin.php?page=prautoblogger-dossier&post_id=<int>`. Secured by
|
||||
`manage_options` capability + nonce (`prautoblogger_dossier`). Static `url_for_post(int
|
||||
$post_id): string` provides the canonical deep-link. Regression test:
|
||||
`tests/unit/Admin/DossierMenuRegistrationTest.php`.
|
||||
|
||||
**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`).
|
||||
|
||||
## 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.
|
||||
|
||||
424
CHANGELOG.md
Executable file → Normal file
424
CHANGELOG.md
Executable file → Normal file
@@ -5,6 +5,421 @@ All notable changes to PRAutoBlogger will be documented in this file.
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project uses [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [0.19.2] - 2026-06-12
|
||||
|
||||
### Added
|
||||
- **Article Dossier page (M2).** Per-article read-only inspection page at
|
||||
`admin.php?page=prautoblogger-dossier&post_id=<id>`. Accessible via board card
|
||||
deep-links and the slim post metabox link. Shows: header (title, verdict pill, tier,
|
||||
status), sidebar (run metadata, per-stage cost receipt with model + prompt-version),
|
||||
vertical stage sections (rendered output default, per-stage raw-trace toggle exposing
|
||||
`generation_log` request JSON + tokens + cost + role), editorial decisions rationale,
|
||||
image A/B pair sections.
|
||||
- **Board card deep-links.** All Kanban cards now deep-link directly to the article
|
||||
dossier page (`dossier_url` on card data; `click_action = dossier` in board JS).
|
||||
- **Slim post metabox.** `PRAutoBlogger_Post_Metabox` now renders a single "View
|
||||
generation dossier →" link (was a verbose generation-metadata dump).
|
||||
- **Dossier CSS/JS.** `assets/css/dossier.css` (warm editorial palette, verdict pills,
|
||||
cost receipt, raw-trace toggle) + `assets/js/dossier.js` (vanilla JS toggle,
|
||||
`aria-expanded` / `aria-controls` accessible).
|
||||
- **Menu registration test coverage.** `DossierMenuRegistrationTest.php` — asserts
|
||||
dossier hook fires at priority > board (> parent), uses hidden-submenu pattern,
|
||||
correct slug, and `url_for_post()` URL shape. Mirrors `BoardMenuRegistrationTest`.
|
||||
- **Raw-trace credential check (binding 4).** `request_json` column contains only
|
||||
OpenRouter request bodies (model, messages, temperature — no Authorization headers
|
||||
per architecture). All model output escaped via `esc_html()` / `wp_kses_post()`;
|
||||
treated as untrusted HTML throughout the render path.
|
||||
- **Legacy graceful state (binding 5).** Posts with no run record (pre-v0.18.0) render
|
||||
a clean "No generation record" notice — no notices, no fatals. Amortized research
|
||||
rows (pv=null, role='') render model as "—" with no raw-trace toggle.
|
||||
|
||||
### Changed
|
||||
- **`class-prautoblogger.php` → `class-db-migrations.php` split (binding 1).**
|
||||
All DB-migration methods extracted byte-identically into new static class
|
||||
`PRAutoBlogger_DB_Migrations` (`includes/class-db-migrations.php`, 129 lines).
|
||||
`class-prautoblogger.php` shrinks from 407 → 258 lines; delegates via thin proxy.
|
||||
Loaded via explicit `require_once` in `prautoblogger.php` (autoloader filename
|
||||
collision avoidance — see ARCHITECTURE.md §File Naming Notes).
|
||||
|
||||
## [0.19.1] - 2026-06-12
|
||||
|
||||
### Fixed
|
||||
- **Board admin submenu 404 (P0 hotfix).** `PRAutoBlogger_Board_Page::on_register_menu`
|
||||
was hooked at `admin_menu` priority 10, identical to
|
||||
`PRAutoBlogger_Admin_Page::on_register_menu`. Because the board hook was
|
||||
registered first in `register_admin_hooks()`, `add_submenu_page()` fired while
|
||||
`$admin_page_hooks['prautoblogger-settings']` was still unset. WordPress fell back
|
||||
to the `admin_page_prautoblogger-board` hookname; at request time it recomputed
|
||||
`prautoblogger_page_prautoblogger-board`, found no registered callback, and called
|
||||
`wp_die()` with "Invalid plugin page". Board CSS/JS also never loaded (same
|
||||
enqueue-gate mismatch). Fix: parent menu hook stays at priority 10; board submenu
|
||||
hook moves to priority 11, guaranteeing `add_menu_page()` fires first. Board is
|
||||
kept as the first submenu item via explicit `$submenu` reorder in
|
||||
`PRAutoBlogger_Board_Page::on_register_menu()`.
|
||||
- **Regression test added:** `tests/unit/Admin/BoardMenuRegistrationTest.php` —
|
||||
three assertions covering priority relationship, execution order, and parent-slug
|
||||
correctness. Includes a "documents the bug" test that fires the unfixed hook order
|
||||
to prove the broken state.
|
||||
|
||||
## [0.19.0] - 2026-06-12
|
||||
|
||||
### Added
|
||||
- **Kanban board dashboard (M1 — Phase 2 admin / Direction C).** New
|
||||
`Board` submenu page is now the primary landing screen for the plugin.
|
||||
Four columns — Generating | In Review | Published | Failed — with card
|
||||
click-throughs: Generating/Failed → Activity Log; In Review → Review
|
||||
Queue; Published → Post edit screen. (M2 will rewire all to the Article
|
||||
Dossier.)
|
||||
- **Live board polling.** AJAX action `prautoblogger_board_status` (nonce
|
||||
`prautoblogger_board`) returns the full board snapshot every N seconds.
|
||||
Poll interval backs off to 2× when no article is actively generating
|
||||
and resets to base as soon as a generating card appears. All nonce and
|
||||
capability checks enforced.
|
||||
- **Two new settings** in Schedule & Budget: `prautoblogger_board_poll_interval`
|
||||
(default 5s, min 3s) and `prautoblogger_board_published_window_days`
|
||||
(default 7 days). Both localized into board.js — no hardcoded values.
|
||||
- New files: `includes/admin/class-board-page.php`,
|
||||
`includes/admin/class-board-data-provider.php`,
|
||||
`templates/admin/board-page.php`, `assets/css/board.css`,
|
||||
`assets/js/board.js`.
|
||||
- PHPUnit behavioral tests: `tests/unit/Admin/BoardDataProviderTest.php`
|
||||
(8 tests covering all column/state mappings) and
|
||||
`tests/unit/Core/CostReporterSqlBugFixTest.php` (3 tests for the SQL
|
||||
inline-comment fix).
|
||||
|
||||
### Fixed
|
||||
- **Cost-reporter SQL inline-comment bug (P2).** `PRAutoBlogger_Cost_Reporter::get_daily_spend()`
|
||||
and `get_spend_by_stage()` embedded PHP `// phpcs:ignore` annotations
|
||||
inside SQL string literals, passing the comment text to MySQL. Moved
|
||||
phpcs suppression annotations to standalone PHP line comments above the
|
||||
`$wpdb->prepare()` call — matching the pattern already used in
|
||||
`get_monthly_spend()`. No query behavior change; fixes malformed
|
||||
prepared-statement strings visible in debug logging.
|
||||
|
||||
## [0.17.0] - 2026-06-11
|
||||
|
||||
### Added
|
||||
- **Deterministic PHP image composer (Commit 2 of the in-plugin editorial image
|
||||
pipeline).** Every generated image now passes through a local, $0,
|
||||
deterministic composer between the provider's bytes and the media sideloader.
|
||||
Image A yields a corner-marked featured image (1200×632) plus a branded
|
||||
**OG/social variant** (1200×630, teal band, brand mark, baked editorial
|
||||
caption in Poppins SemiBold) and a **square card variant** (1080×1080, image
|
||||
slice + cream caption panel in Poppins Bold + teal footer with the horizontal
|
||||
lockup). Image B gets the corner mark only. Variants are sideloaded with a
|
||||
role filename suffix and meta-linked: attachment meta
|
||||
`_prautoblogger_image_role` + `_prautoblogger_base_attachment_id`, post meta
|
||||
`_prautoblogger_og_image_id` + `_prautoblogger_square_image_id` (the rebuilt
|
||||
SEO stage will emit `og:image` from the frozen og key).
|
||||
- **Capability ladder with cached probe.** Imagick (full compositing) →
|
||||
`wp_get_image_editor()` GD path (resize-only, unbranded, single WARNING) →
|
||||
pass-through (single WARNING). The probe result is cached in
|
||||
`prautoblogger_image_compose_capability`, fingerprinted by PHP version +
|
||||
imagick/gd presence so it auto-invalidates when the host changes. Override
|
||||
filter: `prautoblogger_image_compose_capability`. Composition failure can
|
||||
never block a publish (per-render try/catch + pipeline safety net).
|
||||
- New Images settings: `prautoblogger_image_compose_enabled` (default on),
|
||||
`prautoblogger_image_compose_variants` (default `og,square`),
|
||||
`prautoblogger_image_featured_mark_enabled` (default on). Geometry ships as
|
||||
layout-class constants behind the `prautoblogger_image_compose_layout`
|
||||
filter. All keys keep the `prautoblogger_`/`_prautoblogger_` prefixes, so
|
||||
the existing uninstall prefix-sweep purges them (no uninstall.php changes).
|
||||
- Vendored brand rasters (`assets/brand/`, from peptide-repo-brand SVG masters)
|
||||
and bundled OFL fonts (`assets/fonts/`: Poppins Bold + Poppins SemiBold +
|
||||
OFL.txt) — no system-font or runtime-SVG dependency, byte-stable output per
|
||||
environment (metadata stripped, PNG date/time chunks excluded).
|
||||
|
||||
### Fixed
|
||||
- Featured/Image B **alt text is now the editorial caption** instead of the
|
||||
generation model name (`sideload_and_log()` passed the model as `$alt_text`,
|
||||
contradicting the sideloader docblock). The model keeps persisting in
|
||||
`_prautoblogger_image_model`.
|
||||
|
||||
### Changed
|
||||
- `PRAutoBlogger_Image_Media_Sideloader::sideload_image()` /
|
||||
`generate_filename()` accept an optional filename role suffix
|
||||
(back-compat default `''`).
|
||||
- `PRAutoBlogger_Image_Pipeline` delegates sideload/cost-log, caption
|
||||
prepending, and variant persistence to the new
|
||||
`PRAutoBlogger_Image_Attacher` (300-line compliance); also declares the
|
||||
previously-dynamic `$trace_context` property (PHP 8.2 deprecation).
|
||||
|
||||
## [0.16.0] - 2026-05-29
|
||||
|
||||
### Changed
|
||||
- **Editorial image prompt (Commit 1 of the in-plugin editorial image pipeline).**
|
||||
The image style pivots from a single-panel newspaper comic to a text-free
|
||||
editorial scientific illustration. The rewriter LLM now emits a concise
|
||||
1-2 sentence topic/mechanism summary as the SCENE (one centered focal
|
||||
subject, no text/people-as-gag/logos), keeping the short CAPTION line for the
|
||||
unchanged HTML-caption-below-image behavior. The scene/caption parsing
|
||||
contract is unchanged. Provider/model are unchanged — production keeps
|
||||
generating via Runware FLUX.1 schnell but now produces text-free editorial
|
||||
base images. See `docs/proposals/2026-05-29-image-pipeline-in-plugin-brief.md`.
|
||||
- The prompt builder now substitutes the rewriter scene into an editable style
|
||||
template's `{{ topic_summary }}` slot via the new
|
||||
`PRAutoBlogger_Image_Template_Filler`, replacing the old
|
||||
`scene + style suffix` concatenation, across all three entry points
|
||||
(`build_article_prompt`, `build_source_prompt`, `build_fallback_prompt`).
|
||||
Before substitution the scene is stripped of control characters and
|
||||
length-clamped (brief A5). If the template lacks exactly one token the
|
||||
summary is appended and a warning logged; if the summary is empty the
|
||||
style-only prompt is emitted — a blank or token-only prompt is never sent to
|
||||
the provider (brief A5/A6).
|
||||
|
||||
### Added
|
||||
- New setting **Style Template** (`prautoblogger_image_style_template`,
|
||||
textarea) replacing the Style Suffix field. Default =
|
||||
`PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE` (the editorial template). On save
|
||||
it is validated to contain exactly one `{{ topic_summary }}` token; an invalid
|
||||
template keeps the previous value and surfaces a settings error.
|
||||
|
||||
### Deprecated
|
||||
- `prautoblogger_image_style_suffix` is deprecated and no longer read by the
|
||||
prompt builder. A one-time migration (`prautoblogger_migrated_style_template_v0160`)
|
||||
mirrors its value to `prautoblogger_image_style_suffix_deprecated` for one
|
||||
version cycle and seeds the new template default. The old option is NOT
|
||||
deleted this cycle.
|
||||
|
||||
## [0.15.1] - 2026-05-14
|
||||
|
||||
### Fixed
|
||||
- Cost preview in AI Models settings tab no longer prints a WordPress database
|
||||
error notice; query now uses canonical column names (`prompt_tokens`,
|
||||
`completion_tokens`, `created_at`). Bug present since v0.11.0 (2026-04-23).
|
||||
|
||||
## [0.14.0] - 2026-04-26
|
||||
## [0.15.0] - 2026-04-26
|
||||
|
||||
### Added
|
||||
- **Live Runware Model Catalog Sync**: New `class-runware-model-catalog.php`
|
||||
fetches the live Runware model list via their `/v1` endpoint (task-based API),
|
||||
filters to `taskType=imageInference` (text-to-image only, excluding inpainting),
|
||||
normalizes to the Image_Model_Registry shape, and caches in `prautoblogger_runware_model_cache`
|
||||
with a 24-hour TTL. Fixes the pattern from v0.13.8 and v0.14.0 where the
|
||||
hardcoded Runware model list was a maintenance burden and gaps (missing models,
|
||||
wrong prices) went unnoticed until customer images generation broke.
|
||||
- Daily WP-Cron sync (`prautoblogger_sync_runware_models`) scheduled on activation
|
||||
and unscheduled on deactivation.
|
||||
- On-demand "Sync now" AJAX button in PRAdmin (Images settings section) with
|
||||
nonce protection and `manage_options` cap check. Refreshes model count and
|
||||
last-synced timestamp in the UI without full page reload.
|
||||
- Smart caching + fallback strategy: if cache is < 24h old, use it; if stale,
|
||||
trigger a sync; if sync fails, use the last-known-good cache; if no cache,
|
||||
fall back to hardcoded list (never returns empty array).
|
||||
- Pricing merge: Runware API may not expose per-image cost. The catalog pulls
|
||||
costs from `class-runware-image-pricing.php::COST_PER_IMAGE` on every sync,
|
||||
ensuring pricing data is always authoritative and up-to-date.
|
||||
- Unit tests for `PRAutoBlogger_Runware_Model_Catalog`: sync success/failure,
|
||||
fallback on API unreachable, stale cache behavior, cost merge logic.
|
||||
- `class-image-model-registry.php` refactored: `get_models()` now delegates to
|
||||
the catalog for Runware models + static OpenRouter list. Extracted fallback
|
||||
lists into private `get_runware_fallback_models()` and `get_openrouter_models()`
|
||||
for clarity.
|
||||
|
||||
### Fixed
|
||||
- Silent model list gaps no longer possible: Runware no longer depends on manual
|
||||
model list maintenance in the pricing class. The live catalog is the source of
|
||||
truth; pricing is merged in on every sync.
|
||||
|
||||
### Changed
|
||||
- `class-image-model-registry.php`: `get_models()` is now a thin wrapper around
|
||||
the catalog + OpenRouter static list. Breaking change: callers that expect a
|
||||
specific static list should note the new dynamic Runware side.
|
||||
|
||||
|
||||
### Fixed
|
||||
- Runware image generation now correctly uses the admin-selected model instead
|
||||
of silently falling back to FLUX.1 schnell (runware:100@1) for all non-schnell
|
||||
and non-dev selections. Root cause: `class-runware-image-pricing.php` only
|
||||
had 2 models in its `COST_PER_IMAGE` whitelist (schnell + dev), but the
|
||||
image model registry was expanded to 15 Runware models in v0.13.8 without a
|
||||
matching update to the pricing class. Any model ID not in the whitelist caused
|
||||
`resolve_model()` to fall back to schnell, so every Stable Diffusion, FLUX.2,
|
||||
HiDream, TwinFlow, Z-Image, Qwen-Image, Krea, and GLM-Image selection was
|
||||
silently downgraded.
|
||||
- Added all 15 Runware model IDs to `COST_PER_IMAGE` and `DEFAULT_STEPS` with
|
||||
correct per-image costs and step counts sourced from runware.ai/pricing (April 2026).
|
||||
- Added `@see` cross-reference to `class-image-model-registry.php` in pricing
|
||||
class docblock to prevent future drift.
|
||||
|
||||
## [0.13.9] - 2026-04-26
|
||||
|
||||
### Fixed
|
||||
- Model picker sort by price now works correctly for image models — previously
|
||||
\`applySort\` always read \`input_price_per_m\` (undefined on image models),
|
||||
so every image had an effective price of 0 and price sort was a no-op.
|
||||
Fix: detect image models via \`cost_per_image\` and use that field instead.
|
||||
|
||||
## [0.13.8] - 2026-04-26
|
||||
|
||||
### Added
|
||||
- Image model picker expanded from 7 to 21 models
|
||||
- New Runware text-to-image models: Stable Diffusion 3, HiDream-I1 Full/Dev/Fast,
|
||||
FLUX.1 Krea [dev], Qwen-Image, FLUX.2 [dev], FLUX.2 [klein] 9B, FLUX.2 [klein] 4B,
|
||||
GLM-Image, Z-Image, Z-Image-Turbo, TwinFlow Z-Image-Turbo
|
||||
- New OpenRouter model: openai/gpt-5.4-image-2
|
||||
- Registry now includes last-verified date and update instructions in source comment
|
||||
|
||||
|
||||
All notable changes to PRAutoBlogger will be documented in this file.
|
||||
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project uses [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [0.13.7] - 2026-04-24
|
||||
|
||||
### Fixed
|
||||
- Posts list column layout: title was set to 35% which, combined with 3 fixed-width PRAB columns, left ~50px for every remaining column (Author, Categories, Tags, Date all word-wrapping). Replaced with a balanced budget: title 22%, author 6%, categories 7%, tags 9%, date 8%, PRAB columns 85/85/50px, Peptide Topics 75px.
|
||||
|
||||
## [0.13.6] - 2026-04-24
|
||||
|
||||
### Fixed
|
||||
- Peptide Topics column still rendering vertically: WordPress posts table uses `table-layout: fixed` which ignores `min-width` entirely. Changed to explicit `width: 80px` which is respected under fixed-layout tables.
|
||||
|
||||
## [0.13.5] - 2026-04-24
|
||||
|
||||
### Fixed
|
||||
- Cost column in Posts list was always showing $0.00: a PHP `// phpcs:ignore` comment was embedded inside the SQL string literal, causing a MariaDB syntax error on every query. Moved comment above the `prepare()` call.
|
||||
- Peptide Topics column header in Posts list was being squeezed to near-zero width by the fixed-width PRAB columns. Added `min-width: 80px` to `.column-taxonomy-peptide_topic`.
|
||||
|
||||
## [0.13.4] - 2026-04-24
|
||||
|
||||
### Fixed
|
||||
- Model picker price column now consistently right-aligned. Root cause: price and description were siblings inside .ab-mp-row-meta, with description first and variable-length — this shifted price left/right per row. Fix: moved price to last position with flex-shrink: 0 so its right edge is always pinned to the row edge; capped description at max-width 200px with ellipsis; swapped column headers to match new DOM order.
|
||||
|
||||
## [0.13.3] - 2026-04-24
|
||||
|
||||
### Fixed
|
||||
- Price column header now right-aligned with `min-width: 90px` to match data cells — header and prices now track the same column edge.
|
||||
|
||||
## [0.13.2] - 2026-04-24
|
||||
|
||||
### Changed
|
||||
- Model picker: added sortable column headers (Model, Price, Context); clicking a header sorts the list ascending/descending; active column highlighted with sort arrow; sort preference persists while the picker is open.
|
||||
|
||||
## [0.12.3] — 2026-04-24
|
||||
## [0.13.1] - 2026-04-24
|
||||
|
||||
### Fixed
|
||||
- Model picker price column now right-aligned with a minimum width so values like `$0.0006/image` and `$0.0800/image` stack cleanly instead of floating at uneven offsets.
|
||||
|
||||
## [0.13.0] - 2026-04-24
|
||||
|
||||
### Added
|
||||
- **Wave 2 Eval Harness**: Complete evaluation framework for PRAutoBlogger output quality
|
||||
- Frozen eval dataset (`eval/dataset.json`) with 20 curated peptide topics
|
||||
- LLM-as-judge scorer (`class-opik-eval-scorer.php`) using Gemini Flash 1.5
|
||||
- Eval runner orchestrator (`class-opik-eval-runner.php`) with Opik experiment support
|
||||
- WP-CLI command: `wp prautoblogger opik:eval [--limit=<n>] [--dry-run]`
|
||||
- Image pipeline instrumentation: Opik span wrapping for `image_prompt_rewrite` step
|
||||
- **Image Prompt Builder Refactor**: Extracted scene/caption parsing into `PRAutoBlogger_Image_Scene_Parser`
|
||||
- Three static methods: `parse_scene_and_caption()`, `extract_first_paragraph()`, `synthesize_visual_concepts_fallback()`
|
||||
- Cleaner separation of concerns; builder now ~255 lines (was 331)
|
||||
- **Content Generator Eval Mode**: Added `$eval_mode` parameter to suppress publishing and image generation side effects
|
||||
- **Trace Context Integration**: Optional trace instrumentation in `PRAutoBlogger_Image_Prompt_Builder` constructor
|
||||
|
||||
### Changed
|
||||
- `PRAutoBlogger_Image_Prompt_Builder` constructor now accepts optional `PRAutoBlogger_Opik_Trace_Context` parameter
|
||||
- `PRAutoBlogger_Image_Pipeline` constructor passes trace context through to prompt builder
|
||||
- Image prompt rewrite LLM calls now emit Opik spans when trace context is available
|
||||
|
||||
### Fixed
|
||||
- Image prompt builder now properly delegates to static parser methods
|
||||
|
||||
|
||||
### Fixed
|
||||
- **Opik autoloader gap** — `class-autoloader.php` listed `services/` as a scan directory but not `services/opik/`, where all four Opik classes live (`Opik_Client`, `Opik_Trace_Context`, `Opik_Span_Queue`, `Opik_Dispatcher`). Composer's classmap (used in PHPUnit) covers `includes/` recursively so tests passed, but the WordPress runtime autoloader would fatal on first Opik class reference in production. Added `services/opik/` to the scan list.
|
||||
|
||||
|
||||
## [0.12.2] — 2026-04-24
|
||||
|
||||
### Fixed
|
||||
- **Image style suffix empty-string bypass** — `get_option()` for `prautoblogger_image_style_suffix` only returns the default constant when the key is absent from `wp_options`; an empty string stored in the DB returns `''`, bypassing fallback entirely. Root cause of post #675 rendering photorealistically instead of as a newspaper comic. Fixed all three call sites in `class-image-prompt-builder.php` (lines 85, 107, 272) by extracting a private helper `get_style_suffix()` that treats both absent AND empty string as "use default".
|
||||
|
||||
- **Image cost logged under wrong run_id** — `class-image-pipeline.php` created a fresh cost tracker when invoked without an argument, so image costs were logged under a different `run_id` than the article's other stages. Cost breakdowns aggregating by `run_id` silently excluded the image line item. Fixed by threading the article worker's cost tracker through `PRAutoBlogger_Publisher` and `PRAutoBlogger_Post_Assembler::attach_generated_images()` to the image pipeline constructor. Image costs now share the same `run_id` as analysis, draft, review, and llm_research stages.
|
||||
|
||||
|
||||
## [0.12.1] — 2026-04-23
|
||||
|
||||
### Fixed
|
||||
- **Opik UUID v7 bug** — `PRAutoBlogger_Opik_Trace_Context` was generating trace and
|
||||
span IDs with `wp_generate_uuid4()` (UUID v4). Opik's REST API requires UUID v7 and
|
||||
rejects v4 with HTTP 400 "Trace id must be a version 7 UUID". Added a private static
|
||||
`generate_uuid7()` method using `microtime()` + `random_bytes()` and replaced both
|
||||
call sites. All trace and span submissions now succeed.
|
||||
|
||||
## [0.12.0] — 2026-04-23
|
||||
|
||||
### Added
|
||||
- **Opik LLM observability integration (Wave 1: tracing skeleton).** Cloud-hosted observability via Comet Opik for detailed per-call tracing and prompt-version regression testing. Captured via async fire-and-forget dispatch to avoid blocking article generation.
|
||||
|
||||
- **Opik REST client.** Minimal HTTP client (no third-party SDK) with exponential backoff retry, batch endpoints, and credential isolation (API key from wp-config.php constants, never in DB).
|
||||
|
||||
- **Per-request trace context singleton.** Holds trace ID + span stack for one article generation. UUID threading across draft generation, editorial review, and image prompt generation.
|
||||
|
||||
- **Async span/trace queue.** WP options-backed queue with TTL expiry (12 hours) and max depth (1000 items). Drained by cron dispatcher with per-batch retry logic.
|
||||
|
||||
- **Cron dispatcher.** `prautoblogger_opik_dispatch` action scheduled via `wp_schedule_single_event()` drains queue in batches of up to 100 spans.
|
||||
|
||||
- **Opik admin settings section.** Toggle to enable/disable tracing (default off), project name field, opt-in full-prompt capture, read-only status display (API key configured, last dispatch, queue depth).
|
||||
|
||||
- **Span instrumentation at four LLM call sites:** single-pass article generation, outline/draft/polish stages (multi-step), and editorial review. Each span captures model name, provider, usage tokens.
|
||||
|
||||
- **Comprehensive unit test suite.** 4 test files covering client (auth, retry, batch limits), trace context (UUID generation, span stacking), queue (enqueue/dequeue, expiry, reenqueue), and dispatcher (batch POST, separation of traces vs. spans).
|
||||
|
||||
### Changed
|
||||
- **class-article-worker.php:** Initializes Opik trace context at generation start, enqueues finalized trace + spans at end if feature enabled.
|
||||
|
||||
- **class-content-generator.php:** Wraps single-pass and multi-step stages (outline, draft, polish) with Opik spans capturing token usage.
|
||||
|
||||
- **class-chief-editor.php:** Wraps editorial review LLM call with Opik span.
|
||||
|
||||
### Notes
|
||||
- **Feature-flag default OFF.** Zero network traffic to Opik unless `prautoblogger_opik_enabled` option is true AND API credentials are defined in wp-config.php.
|
||||
- **Image prompt generation instrumentation deferred.** File already at 314 LOC (over 300-line rule); can be instrumented in Wave 2 without modifying existing code.
|
||||
- **Wave 2 holds eval harness.** CLI command for frozen-dataset regression testing with LLM-as-judge scoring (separate thread after v0.12.0 ships and is live-tested).
|
||||
|
||||
## [0.11.0] — 2026-04-23
|
||||
|
||||
### Added
|
||||
- **OpenRouter model picker for text models.** Admin-facing dropdown field type (`model_select`) with searchable list, capability filtering, and estimated cost preview. Replaces free-text model slug inputs on the three text model settings (analysis, writing, editor). Includes daily cron refresh of the OpenRouter registry cache and manual "Refresh Model List" button on the AI Models tab.
|
||||
|
||||
- **Cost preview in model picker.** Shows estimated cost per generation based on historical token usage for the 30 days prior. Maps each setting to its constituent pipeline stages (writing model = outline + draft + polish).
|
||||
|
||||
- **AJAX refresh endpoint** (`prautoblogger_refresh_models`) for manual model registry refresh with nonce + capability gating (`manage_options`).
|
||||
|
||||
- **New public method on Cost Tracker:** `get_avg_tokens_for_stages(array $stages, int $days = 30): array` returns average input/output token counts for a stage list, used by cost preview.
|
||||
|
||||
### Changed
|
||||
- **AI Models tab now renders a "Refresh Model List" button** next to the panel title. Triggers `prautoblogger_refresh_models` AJAX endpoint.
|
||||
|
||||
## [0.10.1] — 2026-04-23
|
||||
|
||||
### Fixed
|
||||
- **PHPCS: closed the last 26 WordPress-Core residuals** (15 short ternaries rewritten, 11 targeted ignores on false-positive ternary-with-prepare in ideas-browser + logger).
|
||||
|
||||
- **Tests: OpenRouterImageProviderTest now round-trips through real Encryption class.**
|
||||
setUp was using a dead `eval()` mock that never installed because the real `PRAutoBlogger_Encryption`
|
||||
class was autoloaded first. Tests now call `PRAutoBlogger_Encryption::encrypt()` directly,
|
||||
leveraging the BaseTestCase-stubbed `wp_salt()` for deterministic round-trip decryption.
|
||||
Fixes both `test_generate_image_success` and `test_generate_image_4xx_throws`.
|
||||
- **Tests: add missing `post_type_exists()` stub to BaseTestCase.**
|
||||
When PR Core is not active, PeptideLinker guards calls with `post_type_exists()`.
|
||||
This WordPress function was not mocked in the base test setup, causing 8 PublisherTest errors.
|
||||
|
||||
### Changed
|
||||
|
||||
- **PHPCS: backlog substantially reduced (1,292 → 28 violations).**
|
||||
Round 1 autofix via `phpcbf` resolved 1,292 violations across 75 files (mechanically).
|
||||
Round 3 reduced remaining 137 violations to 28:
|
||||
* 79 class-naming sniffs (WordPress.Files.FileName) — excluded in phpcs.xml; architectural decision to use short class names (class-logger.php) instead of fully-qualified names for readability.
|
||||
* 56 `$wpdb->prepare()` violations (WordPress.DB.PreparedSQL) — excluded in phpcs.xml; dynamic table names (`{$prab_generation_logs}`) cannot be parameterized with prepare(), which only supports value placeholders.
|
||||
* 28 remaining violations (other sniffs) — mostly in class-logger.php, class-ideas-browser.php; deferred for future cleanup.
|
||||
Excluded sniffs are documented in phpcs.xml with architectural justifications.
|
||||
- **CI: PHPCS gate now strict.** Changed `continue-on-error: true → false` in `.github/workflows/ci.yml`.
|
||||
PHPCS failures will now block CI, preventing style regression.
|
||||
|
||||
## [0.10.0] — 2026-04-21
|
||||
|
||||
### Removed
|
||||
@@ -595,6 +1010,7 @@ swap with no user-visible change in article quality.
|
||||
run_id-based log linking, RuntimeException on WP_Error, action/filter
|
||||
hook firing. Replaces previous method-existence-only checks.
|
||||
|
||||
|
||||
## [0.2.1] — 2026-04-12
|
||||
|
||||
### Changed
|
||||
@@ -631,14 +1047,6 @@ swap with no user-visible change in article quality.
|
||||
- Posts-per-subreddit limit (5–100) in Sources tab.
|
||||
- LiteSpeed cache purge step in CI/CD deploy pipeline.
|
||||
|
||||
### Removed
|
||||
- `class-reddit-api-client.php` — replaced by `class-reddit-json-client.php`.
|
||||
- Reddit OAuth credential fields and encrypted storage entry.
|
||||
|
||||
### Fixed
|
||||
- Deploy pipeline now purges LiteSpeed/OPcache after file extraction,
|
||||
preventing stale PHP bytecode from being served.
|
||||
|
||||
## [0.1.0] — 2026-04-10
|
||||
|
||||
### Added
|
||||
|
||||
237
CONVENTIONS.md
Executable file → Normal file
237
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
|
||||
|
||||
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
|
||||
|
||||
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`
|
||||
2. Register the stage in the `get_pipeline_stages()` method for the appropriate mode (single_pass or multi_step)
|
||||
3. Each stage receives the accumulated context and returns its output
|
||||
4. Each stage logs its cost via `Cost_Tracker::log_api_call()`
|
||||
5. If a stage fails after retries, the entire pipeline fails (no partial publishes)
|
||||
1. Add the stage method to `class-content-generator.php` and call
|
||||
`$this->execute_stage( $stage, $span_name, $user_prompt, $request, $model, $options )`
|
||||
2. Wire it into `generate_multi_step()` (or the relevant mode path)
|
||||
3. Add the stage to `PRAutoBlogger_Stage_Display_Map` (label, default agent role, prompt key)
|
||||
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.
|
||||
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
|
||||
|
||||
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.
|
||||
@@ -278,4 +401,104 @@ curl -s -X POST -H "Authorization: token $GH_TOKEN" \
|
||||
|
||||
### 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.
|
||||
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -142,6 +142,7 @@
|
||||
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 {
|
||||
@@ -150,6 +151,7 @@
|
||||
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 ─────────────────────────────────────────────────────────── */
|
||||
@@ -204,3 +206,59 @@
|
||||
.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.
0
assets/js/admin.js
Executable file → Normal file
0
assets/js/admin.js
Executable file → Normal file
241
assets/js/board.js
Normal file
241
assets/js/board.js
Normal file
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* 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>';
|
||||
|
||||
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 || [] );
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ) );
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
/** Initialise the board poller on DOM ready. */
|
||||
$( function () {
|
||||
$board = $( '#prab-board' );
|
||||
if ( ! $board.length ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Inject the error banner after the heading.
|
||||
$errorBanner = $( '<div class="prab-board-error" role="alert">' + escHtml( cfg.strings.pollError || '' ) + '</div>' );
|
||||
$board.before( $errorBanner );
|
||||
|
||||
// Check if any generating card is present in the initial server render.
|
||||
var hasActive = ( $board.find( '[data-column="generating"] .prab-board-card' ).length > 0 );
|
||||
backoffFactor = hasActive ? 1 : 1; // Always start at base on first load.
|
||||
|
||||
schedulePoll( nextDelay( hasActive ) );
|
||||
} );
|
||||
|
||||
}( jQuery ) );
|
||||
41
assets/js/dossier.js
Normal file
41
assets/js/dossier.js
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* PRAutoBlogger Article Dossier -- raw-trace toggle (M2).
|
||||
*
|
||||
* Toggles the hidden raw-trace <div> per stage when the "Show raw trace"
|
||||
* button is clicked. Vanilla JS, no build step, no external deps.
|
||||
*
|
||||
* Accessible: uses aria-expanded + aria-controls; respects the hidden attribute.
|
||||
*
|
||||
* @see admin/class-dossier-page.php -- Enqueues this file.
|
||||
* @see templates/admin/dossier-page.php -- Renders the toggle buttons.
|
||||
*/
|
||||
( function () {
|
||||
'use strict';
|
||||
|
||||
document.addEventListener( 'DOMContentLoaded', function () {
|
||||
var toggles = document.querySelectorAll( '.prab-trace-toggle' );
|
||||
|
||||
toggles.forEach( function ( btn ) {
|
||||
btn.addEventListener( 'click', function () {
|
||||
var targetId = btn.getAttribute( 'aria-controls' );
|
||||
if ( ! targetId ) {
|
||||
return;
|
||||
}
|
||||
var panel = document.getElementById( targetId );
|
||||
if ( ! panel ) {
|
||||
return;
|
||||
}
|
||||
var isOpen = btn.getAttribute( 'aria-expanded' ) === 'true';
|
||||
if ( isOpen ) {
|
||||
panel.hidden = true;
|
||||
btn.setAttribute( 'aria-expanded', 'false' );
|
||||
btn.textContent = btn.textContent.replace( /hide/i, 'Show' );
|
||||
} else {
|
||||
panel.hidden = false;
|
||||
btn.setAttribute( 'aria-expanded', 'true' );
|
||||
btn.textContent = btn.textContent.replace( /show/i, 'Hide' );
|
||||
}
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
}() );
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* Model picker popup for PRAutoBlogger admin settings.
|
||||
*
|
||||
* Fetches the OpenRouter model registry via AJAX, then renders a searchable
|
||||
* popup overlay when the user clicks a model-select trigger button. Shows
|
||||
* name, provider, input/output cost, and context window per model.
|
||||
* 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.
|
||||
@@ -11,54 +12,57 @@
|
||||
(function ($) {
|
||||
'use strict';
|
||||
|
||||
var config = window.prautobloggerAdmin || {};
|
||||
var cachedModels = null;
|
||||
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) {
|
||||
function fetchModels( capability ) {
|
||||
// Image models are embedded in the page config — no AJAX needed.
|
||||
if (capability === 'image_generation') {
|
||||
if ( capability === 'image_generation' ) {
|
||||
var imgModels = config.imageModels || [];
|
||||
return $.Deferred().resolve(imgModels);
|
||||
return $.Deferred().resolve( imgModels );
|
||||
}
|
||||
|
||||
if (cachedModels) {
|
||||
return $.Deferred().resolve(filterModels(cachedModels, capability));
|
||||
if ( cachedModels ) {
|
||||
return $.Deferred().resolve( filterModels( cachedModels, capability ) );
|
||||
}
|
||||
|
||||
if (fetchInProgress) {
|
||||
return fetchInProgress.then(function () {
|
||||
return filterModels(cachedModels, capability);
|
||||
});
|
||||
if ( fetchInProgress ) {
|
||||
return fetchInProgress.then( function () {
|
||||
return filterModels( cachedModels, capability );
|
||||
} );
|
||||
}
|
||||
|
||||
fetchInProgress = $.ajax({
|
||||
url: config.ajaxUrl,
|
||||
fetchInProgress = $.ajax( {
|
||||
url: config.ajaxUrl,
|
||||
method: 'POST',
|
||||
data: {
|
||||
action: 'prautoblogger_get_models',
|
||||
nonce: config.modelsNonce
|
||||
nonce: config.modelsNonce
|
||||
},
|
||||
timeout: 15000
|
||||
}).then(function (response) {
|
||||
} ).then( function ( response ) {
|
||||
fetchInProgress = null;
|
||||
if (response.success && Array.isArray(response.data)) {
|
||||
if ( response.success && Array.isArray( response.data ) ) {
|
||||
cachedModels = response.data;
|
||||
return filterModels(cachedModels, capability);
|
||||
return filterModels( cachedModels, capability );
|
||||
}
|
||||
cachedModels = [];
|
||||
return [];
|
||||
}, function () {
|
||||
fetchInProgress = null;
|
||||
cachedModels = [];
|
||||
cachedModels = [];
|
||||
return [];
|
||||
});
|
||||
} );
|
||||
|
||||
return fetchInProgress;
|
||||
}
|
||||
@@ -70,11 +74,49 @@
|
||||
* @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;
|
||||
});
|
||||
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;
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,11 +125,11 @@
|
||||
* @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);
|
||||
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 );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,10 +138,10 @@
|
||||
* @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';
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -109,45 +151,47 @@
|
||||
* @param {Object} model Normalized model record.
|
||||
* @return {string} HTML string.
|
||||
*/
|
||||
function renderModelRow(model) {
|
||||
var deprecated = model.deprecated ? ' ab-mp-deprecated' : '';
|
||||
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';
|
||||
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>' : '';
|
||||
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);
|
||||
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>';
|
||||
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' + 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>' : '') +
|
||||
'<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">' +
|
||||
'<span class="ab-mp-price" title="' + priceTitle + '">' + priceHtml + '</span>' +
|
||||
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, '>');
|
||||
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, '"');
|
||||
function escAttr( str ) {
|
||||
return escHtml( str ).replace( /"/g, '"' );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,16 +199,16 @@
|
||||
*
|
||||
* @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') || '';
|
||||
function openPicker( $trigger ) {
|
||||
var fieldId = $trigger.data( 'field-id' );
|
||||
var capability = $trigger.data( 'capability' ) || '';
|
||||
|
||||
// Build overlay + popup structure.
|
||||
var isImageCap = capability === 'image_generation';
|
||||
var priceLabel = isImageCap ? 'Cost per generated image' : 'Prices per 1M tokens (in / out)';
|
||||
|
||||
var $overlay = $('<div class="ab-mp-overlay"></div>');
|
||||
var $popup = $(
|
||||
// ── 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>' +
|
||||
@@ -173,6 +217,13 @@
|
||||
'<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>' +
|
||||
@@ -181,105 +232,132 @@
|
||||
'</div>'
|
||||
);
|
||||
|
||||
$overlay.append($popup);
|
||||
$('body').append($overlay);
|
||||
$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 allModels = [];
|
||||
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);
|
||||
setTimeout( function () { $search.focus(); }, 50 );
|
||||
|
||||
// Fetch and render model list.
|
||||
fetchModels(capability).then(function (models) {
|
||||
allModels = models;
|
||||
renderList(models, '');
|
||||
});
|
||||
|
||||
/** Render filtered 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);
|
||||
});
|
||||
}
|
||||
|
||||
// Sort: non-deprecated first, then by provider + name.
|
||||
filtered.sort(function (a, b) {
|
||||
if (a.deprecated !== b.deprecated) return a.deprecated ? 1 : -1;
|
||||
var pa = (a.provider || '').toLowerCase();
|
||||
var pb = (b.provider || '').toLowerCase();
|
||||
if (pa !== pb) return pa < pb ? -1 : 1;
|
||||
return (a.name || '').toLowerCase().localeCompare((b.name || '').toLowerCase());
|
||||
});
|
||||
|
||||
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]);
|
||||
// ── 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( ' ⇅' );
|
||||
}
|
||||
$list.html(html);
|
||||
}
|
||||
$count.text(filtered.length + ' model' + (filtered.length !== 1 ? 's' : ''));
|
||||
} );
|
||||
}
|
||||
|
||||
// Search input handler (debounced).
|
||||
// ── 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 () {
|
||||
var q = $(this).val();
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(function () {
|
||||
renderList(allModels, q);
|
||||
}, 150);
|
||||
});
|
||||
$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 = allModels.find(function (m) { return m.id === id; });
|
||||
if (!model) return;
|
||||
// ── 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;
|
||||
|
||||
// Update hidden input + display.
|
||||
$('#' + fieldId).val(model.id);
|
||||
$trigger.find('.ab-mp-display-name').text(model.name || model.id);
|
||||
$( '#' + 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);
|
||||
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 handlers.
|
||||
// ── 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') {
|
||||
$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');
|
||||
$( document ).off( 'keydown.abModelPicker' );
|
||||
}
|
||||
});
|
||||
} );
|
||||
}
|
||||
|
||||
// Bind click on all model picker triggers.
|
||||
$(document).on('click', '.ab-mp-trigger', function (e) {
|
||||
$( document ).on( 'click', '.ab-mp-trigger', function ( e ) {
|
||||
e.preventDefault();
|
||||
openPicker($(this));
|
||||
});
|
||||
openPicker( $( this ) );
|
||||
} );
|
||||
|
||||
})(jQuery);
|
||||
} )( jQuery );
|
||||
|
||||
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);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Displays admin notices for onboarding, errors, and budget warnings.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger::register_admin_hooks() on `admin_notices`.
|
||||
@@ -62,11 +64,13 @@ class PRAutoBlogger_Admin_Notices {
|
||||
} elseif ( $utilization >= 80.0 ) {
|
||||
printf(
|
||||
'<div class="notice notice-warning"><p>%s</p></div>',
|
||||
esc_html( sprintf(
|
||||
esc_html(
|
||||
sprintf(
|
||||
/* translators: %s: budget utilization percentage */
|
||||
__( 'PRAutoBlogger: Monthly API budget is at %.0f%%. Consider increasing the budget or reducing daily article count.', 'prautoblogger' ),
|
||||
$utilization
|
||||
) )
|
||||
__( 'PRAutoBlogger: Monthly API budget is at %.0f%%. Consider increasing the budget or reducing daily article count.', 'prautoblogger' ),
|
||||
$utilization
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
152
includes/admin/class-admin-page.php
Executable file → Normal file
152
includes/admin/class-admin-page.php
Executable file → Normal file
@@ -2,6 +2,8 @@
|
||||
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.
|
||||
*
|
||||
* Uses the WordPress Settings API for option registration, sanitization, and rendering.
|
||||
@@ -26,7 +28,7 @@ class PRAutoBlogger_Admin_Page {
|
||||
__( 'PRAutoBlogger', 'prautoblogger' ),
|
||||
'manage_options',
|
||||
self::PAGE_SLUG,
|
||||
[ $this, 'render_page' ],
|
||||
array( $this, 'render_page' ),
|
||||
'dashicons-edit-page',
|
||||
30
|
||||
);
|
||||
@@ -43,54 +45,65 @@ class PRAutoBlogger_Admin_Page {
|
||||
|
||||
foreach ( $fields as $field ) {
|
||||
$section = $field['section'] ?? 'prautoblogger_api';
|
||||
register_setting( self::OPTION_GROUP, $field['id'], [
|
||||
'type' => $field['wp_type'] ?? 'string',
|
||||
'sanitize_callback' => [ $this, 'sanitize_field' ],
|
||||
'default' => $field['default'] ?? '',
|
||||
] );
|
||||
add_settings_field( $field['id'], $field['label'], [ $this, 'render_field' ], self::PAGE_SLUG, $section, $field );
|
||||
register_setting(
|
||||
self::OPTION_GROUP,
|
||||
$field['id'],
|
||||
array(
|
||||
'type' => $field['wp_type'] ?? 'string',
|
||||
'sanitize_callback' => array( $this, 'sanitize_field' ),
|
||||
'default' => $field['default'] ?? '',
|
||||
)
|
||||
);
|
||||
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. */
|
||||
public function on_enqueue_assets( string $hook_suffix ): void {
|
||||
$pages = [
|
||||
$pages = array(
|
||||
'toplevel_page_' . self::PAGE_SLUG,
|
||||
'prautoblogger_page_prautoblogger-board',
|
||||
'prautoblogger_page_prautoblogger-metrics',
|
||||
'prautoblogger_page_prautoblogger-review-queue',
|
||||
'prautoblogger_page_prautoblogger-ideas',
|
||||
'prautoblogger_page_prautoblogger-logs',
|
||||
];
|
||||
);
|
||||
if ( ! in_array( $hook_suffix, $pages, true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
wp_enqueue_style( 'prautoblogger-admin', PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/admin.css', [], PRAUTOBLOGGER_VERSION );
|
||||
wp_enqueue_style( 'prautoblogger-model-picker', PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/model-picker.css', [ 'prautoblogger-admin' ], PRAUTOBLOGGER_VERSION );
|
||||
wp_enqueue_script( 'prautoblogger-admin', PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/admin.js', [ 'jquery' ], PRAUTOBLOGGER_VERSION, true );
|
||||
wp_enqueue_script( 'prautoblogger-model-picker', PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/model-picker.js', [ 'jquery', 'prautoblogger-admin' ], PRAUTOBLOGGER_VERSION, true );
|
||||
wp_enqueue_style( 'prautoblogger-admin', PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/admin.css', array(), PRAUTOBLOGGER_VERSION );
|
||||
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', [
|
||||
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||
'adminUrl' => admin_url(),
|
||||
'siteUrl' => home_url( '/' ),
|
||||
'generateNonce' => wp_create_nonce( 'prautoblogger_generate_now' ),
|
||||
'imageNonce' => wp_create_nonce( 'prautoblogger_generate_image' ),
|
||||
'testNonce' => wp_create_nonce( 'prautoblogger_test_connection' ),
|
||||
'modelsNonce' => wp_create_nonce( 'prautoblogger_get_models' ),
|
||||
'reviewNonce' => wp_create_nonce( 'prautoblogger_review_queue' ),
|
||||
'ideaGenNonce' => wp_create_nonce( 'prautoblogger_idea_gen' ),
|
||||
'generatingText' => __( 'Generating...', 'prautoblogger' ),
|
||||
'generateText' => __( 'Generate Now', 'prautoblogger' ),
|
||||
'testingText' => __( 'Testing...', '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(),
|
||||
] );
|
||||
wp_localize_script(
|
||||
'prautoblogger-admin',
|
||||
'prautobloggerAdmin',
|
||||
array(
|
||||
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||
'adminUrl' => admin_url(),
|
||||
'siteUrl' => home_url( '/' ),
|
||||
'generateNonce' => wp_create_nonce( 'prautoblogger_generate_now' ),
|
||||
'imageNonce' => wp_create_nonce( 'prautoblogger_generate_image' ),
|
||||
'testNonce' => wp_create_nonce( 'prautoblogger_test_connection' ),
|
||||
'modelsNonce' => wp_create_nonce( 'prautoblogger_get_models' ),
|
||||
'reviewNonce' => wp_create_nonce( 'prautoblogger_review_queue' ),
|
||||
'ideaGenNonce' => wp_create_nonce( 'prautoblogger_idea_gen' ),
|
||||
'generatingText' => __( 'Generating...', 'prautoblogger' ),
|
||||
'generateText' => __( 'Generate Now', 'prautoblogger' ),
|
||||
'testingText' => __( 'Testing...', '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', [ 'jquery', 'prautoblogger-admin' ], PRAUTOBLOGGER_VERSION, true );
|
||||
wp_enqueue_script( 'prautoblogger-ideas-browser', PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/ideas-browser.js', array( 'jquery', 'prautoblogger-admin' ), PRAUTOBLOGGER_VERSION, true );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,15 +140,22 @@ class PRAutoBlogger_Admin_Page {
|
||||
case 'url':
|
||||
case 'time':
|
||||
$attrs = '';
|
||||
if ( isset( $args['min'] ) ) { $attrs .= ' min="' . esc_attr( (string) $args['min'] ) . '"'; }
|
||||
if ( isset( $args['max'] ) ) { $attrs .= ' max="' . esc_attr( (string) $args['max'] ) . '"'; }
|
||||
if ( isset( $args['step'] ) ) { $attrs .= ' step="' . esc_attr( (string) $args['step'] ) . '"'; }
|
||||
if ( isset( $args['min'] ) ) {
|
||||
$attrs .= ' min="' . esc_attr( (string) $args['min'] ) . '"'; }
|
||||
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 );
|
||||
break;
|
||||
|
||||
case 'password':
|
||||
printf( '<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' ) : '' );
|
||||
printf(
|
||||
'<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;
|
||||
|
||||
case 'textarea':
|
||||
@@ -144,30 +164,40 @@ class PRAutoBlogger_Admin_Page {
|
||||
|
||||
case 'select':
|
||||
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 ) );
|
||||
}
|
||||
echo '</select>';
|
||||
break;
|
||||
|
||||
case 'toggle':
|
||||
$checked = in_array( $value, [ '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>',
|
||||
$id, $id, checked( $checked, true, false ) );
|
||||
$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>',
|
||||
$id,
|
||||
$id,
|
||||
checked( $checked, true, false )
|
||||
);
|
||||
break;
|
||||
|
||||
case 'checkboxes':
|
||||
$current = json_decode( (string) $value, true ) ?: [];
|
||||
$current = json_decode( (string) $value, true ) ?? array();
|
||||
// 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'] ?? [] ) as $v => $label ) {
|
||||
foreach ( ( $args['options'] ?? array() ) as $v => $label ) {
|
||||
$is_checked = in_array( $v, $current, true );
|
||||
$disabled = strpos( $label, 'coming soon' ) !== false ? ' disabled' : '';
|
||||
printf( '<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 ) );
|
||||
printf(
|
||||
'<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;
|
||||
|
||||
@@ -180,11 +210,30 @@ class PRAutoBlogger_Admin_Page {
|
||||
break;
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -231,7 +280,7 @@ class PRAutoBlogger_Admin_Page {
|
||||
$option_name = substr( $filter, strlen( 'sanitize_option_' ) );
|
||||
}
|
||||
|
||||
$encrypted = [ 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json', 'prautoblogger_runware_api_key' ];
|
||||
$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 ) {
|
||||
@@ -251,7 +300,7 @@ class PRAutoBlogger_Admin_Page {
|
||||
return PRAutoBlogger_Encryption::encrypt( sanitize_text_field( $value ) );
|
||||
}
|
||||
|
||||
$json_fields = [ 'prautoblogger_target_subreddits', 'prautoblogger_topic_exclusions', 'prautoblogger_enabled_sources' ];
|
||||
$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 ) ) {
|
||||
@@ -275,13 +324,18 @@ class PRAutoBlogger_Admin_Page {
|
||||
return wp_json_encode( array_values( array_map( 'sanitize_text_field', $items ) ) );
|
||||
}
|
||||
|
||||
$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' ];
|
||||
$numeric = array( 'prautoblogger_daily_article_target', 'prautoblogger_monthly_budget_usd', 'prautoblogger_min_word_count', 'prautoblogger_max_word_count', 'prautoblogger_default_author', 'prautoblogger_default_category', 'prautoblogger_pullpush_cache_ttl', 'prautoblogger_reddit_posts_per_subreddit', 'prautoblogger_board_poll_interval', 'prautoblogger_board_published_window_days' );
|
||||
if ( in_array( $option_name, $numeric, true ) ) {
|
||||
return is_numeric( $value ) ? $value : 0;
|
||||
}
|
||||
if ( 'prautoblogger_image_model' === $option_name ) {
|
||||
return $this->sanitize_image_model( (string) $value );
|
||||
}
|
||||
if ( 'prautoblogger_image_style_template' === $option_name ) {
|
||||
// Multi-line template: validation + textarea-safe sanitisation
|
||||
// (incl. the single-token check, brief A5) lives in the filler.
|
||||
return PRAutoBlogger_Image_Template_Filler::sanitize_for_save( (string) $value );
|
||||
}
|
||||
return sanitize_text_field( (string) $value );
|
||||
}
|
||||
|
||||
|
||||
188
includes/admin/class-board-data-provider.php
Normal file
188
includes/admin/class-board-data-provider.php
Normal file
@@ -0,0 +1,188 @@
|
||||
<?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' => 20,
|
||||
'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 $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' => 20,
|
||||
'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 $cards;
|
||||
}
|
||||
}
|
||||
184
includes/admin/class-board-gen-log-query.php
Normal file
184
includes/admin/class-board-gen-log-query.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?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 10",
|
||||
$since
|
||||
),
|
||||
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 20",
|
||||
$since
|
||||
),
|
||||
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;
|
||||
}
|
||||
}
|
||||
182
includes/admin/class-board-page.php
Normal file
182
includes/admin/class-board-page.php
Normal file
@@ -0,0 +1,182 @@
|
||||
<?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 2× when no active generating run is detected.
|
||||
*
|
||||
* Card click-throughs (M1):
|
||||
* - Generating → Activity Log filtered to run context
|
||||
* - In Review → Review Queue
|
||||
* - Published → Post edit screen
|
||||
* - Failed → Activity Log
|
||||
*
|
||||
* 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 — §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 §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
|
||||
);
|
||||
|
||||
$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 ),
|
||||
'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' ),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* WordPress Dashboard widget showing PRAutoBlogger generation status at a glance.
|
||||
*
|
||||
* Displays: last generation time, articles generated today, monthly cost,
|
||||
@@ -27,7 +29,7 @@ class PRAutoBlogger_Dashboard_Widget {
|
||||
wp_add_dashboard_widget(
|
||||
'prautoblogger_status',
|
||||
__( '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' );
|
||||
|
||||
printf( '<p><strong>%s</strong> $%.2f / $%.2f (%.0f%%)</p>',
|
||||
printf(
|
||||
'<p><strong>%s</strong> $%.2f / $%.2f (%.0f%%)</p>',
|
||||
esc_html__( 'Monthly Spend:', 'prautoblogger' ),
|
||||
$monthly_spend,
|
||||
$budget,
|
||||
@@ -52,7 +55,8 @@ class PRAutoBlogger_Dashboard_Widget {
|
||||
);
|
||||
|
||||
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( wp_date( 'M j, Y g:i A', $next_run ) )
|
||||
);
|
||||
|
||||
191
includes/admin/class-dossier-data-assembler.php
Normal file
191
includes/admin/class-dossier-data-assembler.php
Normal file
@@ -0,0 +1,191 @@
|
||||
<?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.
|
||||
*
|
||||
* 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(),
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
$base['has_run'] = true;
|
||||
$base['run'] = $run;
|
||||
$base['stages'] = $this->get_stage_rows( $run_id );
|
||||
$base['decisions'] = $this->get_decision_rows( $run_id );
|
||||
$base['gen_log_index'] = $this->log_query->get_by_run( $run_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 all run_stages rows for a run, indexed by stage name.
|
||||
*
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @return array<string, array<string, mixed>> Stage rows keyed by stage name.
|
||||
*/
|
||||
private function get_stage_rows( string $run_id ): array {
|
||||
if ( ! PRAutoBlogger_Run_Stage_State::is_available() ) {
|
||||
return array();
|
||||
}
|
||||
global $wpdb;
|
||||
if ( null === $wpdb ) {
|
||||
return array();
|
||||
}
|
||||
$table = PRAutoBlogger_Run_Stage_State::table_name();
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
117
includes/admin/class-dossier-page.php
Normal file
117
includes/admin/class-dossier-page.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?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 registers
|
||||
* as a hidden submenu under 'prautoblogger-settings' at admin_menu priority 12
|
||||
* (after board at 11) so WordPress resolves the hookname correctly. Board cards and
|
||||
* post list columns deep-link here via ?page=prautoblogger-dossier&post_id=X.
|
||||
*
|
||||
* 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 -- §Dossier (M2).
|
||||
*/
|
||||
class PRAutoBlogger_Dossier_Page {
|
||||
|
||||
/** Admin page slug. */
|
||||
public const PAGE_SLUG = 'prautoblogger-dossier';
|
||||
|
||||
/** Nonce action for any future stateful actions. */
|
||||
public const NONCE_ACTION = 'prautoblogger_dossier';
|
||||
|
||||
/**
|
||||
* Register the dossier as a hidden submenu page.
|
||||
*
|
||||
* Hidden = not visible in the menu, but reachable by URL.
|
||||
* Priority 12 ensures add_menu_page() (priority 10) has populated
|
||||
* $admin_page_hooks before this add_submenu_page() call. See ARCHITECTURE.md §Board.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function on_register_menu(): void {
|
||||
add_submenu_page(
|
||||
'prautoblogger-settings',
|
||||
__( 'Article Dossier', 'prautoblogger' ),
|
||||
__( 'Dossier', 'prautoblogger' ),
|
||||
'manage_options',
|
||||
self::PAGE_SLUG,
|
||||
array( $this, 'render_page' )
|
||||
);
|
||||
|
||||
// Remove from visible submenu so it doesn't clutter the nav.
|
||||
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- intentional hidden-submenu pattern.
|
||||
global $submenu;
|
||||
if ( isset( $submenu['prautoblogger-settings'] ) ) {
|
||||
foreach ( $submenu['prautoblogger-settings'] as $idx => $item ) {
|
||||
if ( isset( $item[2] ) && self::PAGE_SLUG === $item[2] ) {
|
||||
unset( $submenu['prautoblogger-settings'][ $idx ] );
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param string $hook_suffix Current admin page hook suffix.
|
||||
* @return void
|
||||
*/
|
||||
public function on_enqueue_assets( string $hook_suffix ): void {
|
||||
$dossier_hook = 'prautoblogger_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
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the URL to the dossier for a given post.
|
||||
*
|
||||
* @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 );
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
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`.
|
||||
@@ -28,7 +30,7 @@ class PRAutoBlogger_Ideas_Browser {
|
||||
__( 'Ideas', 'prautoblogger' ),
|
||||
'manage_options',
|
||||
'prautoblogger-ideas',
|
||||
[ $this, 'render_page' ]
|
||||
array( $this, 'render_page' )
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,43 +64,51 @@ class PRAutoBlogger_Ideas_Browser {
|
||||
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( [ 'message' => 'Insufficient permissions.' ], 403 );
|
||||
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( [ 'message' => 'Invalid idea ID.' ] );
|
||||
wp_send_json_error( array( 'message' => 'Invalid idea ID.' ) );
|
||||
return;
|
||||
}
|
||||
|
||||
// Build Article_Idea from the analysis row.
|
||||
$idea_data = self::load_idea_data( $idea_id );
|
||||
if ( null === $idea_data ) {
|
||||
wp_send_json_error( [ 'message' => 'Idea not found.' ] );
|
||||
wp_send_json_error( array( 'message' => 'Idea not found.' ) );
|
||||
return;
|
||||
}
|
||||
|
||||
// Store idea for the cron handler and set initial "running" status.
|
||||
set_transient( self::STATUS_PREFIX . 'data_' . $idea_id, $idea_data, self::STATUS_TTL );
|
||||
set_transient( self::STATUS_PREFIX . $idea_id, [
|
||||
'status' => 'running',
|
||||
'stage' => __( 'Starting generation…', 'prautoblogger' ),
|
||||
'started' => time(),
|
||||
], self::STATUS_TTL );
|
||||
set_transient(
|
||||
self::STATUS_PREFIX . $idea_id,
|
||||
array(
|
||||
'status' => 'running',
|
||||
'stage' => __( 'Starting generation…', 'prautoblogger' ),
|
||||
'started' => time(),
|
||||
),
|
||||
self::STATUS_TTL
|
||||
);
|
||||
|
||||
// Schedule background cron — passes idea_id as argument.
|
||||
$hook = 'prautoblogger_generate_from_idea';
|
||||
if ( ! wp_next_scheduled( $hook, [ $idea_id ] ) ) {
|
||||
wp_schedule_single_event( time(), $hook, [ $idea_id ] );
|
||||
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 ) ) ),
|
||||
[ 'timeout' => 0.01, 'blocking' => false, 'sslverify' => false ]
|
||||
array(
|
||||
'timeout' => 0.01,
|
||||
'blocking' => false,
|
||||
'sslverify' => false,
|
||||
)
|
||||
);
|
||||
|
||||
wp_send_json_success( [ 'message' => 'Generation started.' ] );
|
||||
wp_send_json_success( array( 'message' => 'Generation started.' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,14 +119,14 @@ class PRAutoBlogger_Ideas_Browser {
|
||||
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( [ 'message' => 'Insufficient permissions.' ], 403 );
|
||||
wp_send_json_error( array( 'message' => 'Insufficient permissions.' ), 403 );
|
||||
return;
|
||||
}
|
||||
|
||||
$idea_id = absint( $_POST['idea_id'] ?? 0 );
|
||||
$status = get_transient( self::STATUS_PREFIX . $idea_id );
|
||||
if ( ! is_array( $status ) ) {
|
||||
wp_send_json_success( [ 'status' => 'idle' ] );
|
||||
wp_send_json_success( array( 'status' => 'idle' ) );
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -142,12 +152,26 @@ class PRAutoBlogger_Ideas_Browser {
|
||||
$key = self::STATUS_PREFIX . $idea_id;
|
||||
$idea_data = get_transient( self::STATUS_PREFIX . 'data_' . $idea_id );
|
||||
if ( ! is_array( $idea_data ) ) {
|
||||
set_transient( $key, [ 'status' => 'error', 'message' => 'Idea data expired.' ], self::STATUS_TTL );
|
||||
set_transient(
|
||||
$key,
|
||||
array(
|
||||
'status' => 'error',
|
||||
'message' => 'Idea data expired.',
|
||||
),
|
||||
self::STATUS_TTL
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! PRAutoBlogger_Generation_Lock::acquire() ) {
|
||||
set_transient( $key, [ 'status' => 'error', 'message' => 'Another generation is running.' ], self::STATUS_TTL );
|
||||
set_transient(
|
||||
$key,
|
||||
array(
|
||||
'status' => 'error',
|
||||
'message' => 'Another generation is running.',
|
||||
),
|
||||
self::STATUS_TTL
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -166,19 +190,30 @@ class PRAutoBlogger_Ideas_Browser {
|
||||
// Find the generated post ID for the "View" link.
|
||||
$post_id = self::find_post_by_run_id( $cost_tracker->get_run_id() );
|
||||
|
||||
set_transient( $key, [
|
||||
'status' => 'complete',
|
||||
'generated' => $result['generated'],
|
||||
'published' => $result['published'],
|
||||
'cost' => $result['cost'],
|
||||
'post_id' => $post_id,
|
||||
], self::STATUS_TTL );
|
||||
set_transient(
|
||||
$key,
|
||||
array(
|
||||
'status' => 'complete',
|
||||
'generated' => $result['generated'],
|
||||
'published' => $result['published'],
|
||||
'cost' => $result['cost'],
|
||||
'post_id' => $post_id,
|
||||
),
|
||||
self::STATUS_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, [ 'status' => 'error', 'message' => $e->getMessage() ], self::STATUS_TTL );
|
||||
set_transient(
|
||||
$key,
|
||||
array(
|
||||
'status' => 'error',
|
||||
'message' => $e->getMessage(),
|
||||
),
|
||||
self::STATUS_TTL
|
||||
);
|
||||
}
|
||||
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
@@ -204,7 +239,7 @@ class PRAutoBlogger_Ideas_Browser {
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$post_id = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
$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
|
||||
)
|
||||
@@ -223,7 +258,7 @@ class PRAutoBlogger_Ideas_Browser {
|
||||
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$row = $wpdb->get_row(
|
||||
$row = $wpdb->get_row( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $idea_id ),
|
||||
ARRAY_A
|
||||
);
|
||||
@@ -231,26 +266,26 @@ class PRAutoBlogger_Ideas_Browser {
|
||||
return null;
|
||||
}
|
||||
|
||||
$meta = json_decode( $row['metadata_json'] ?? '{}', true ) ?: [];
|
||||
return [
|
||||
$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 ) ?: [],
|
||||
'key_points' => $meta['key_points'] ?? [],
|
||||
'target_keywords' => $meta['target_keywords'] ?? [],
|
||||
];
|
||||
'source_ids' => json_decode( $row['source_ids_json'] ?? '[]', true ) ?? array(),
|
||||
'key_points' => $meta['key_points'] ?? array(),
|
||||
'target_keywords' => $meta['target_keywords'] ?? array(),
|
||||
);
|
||||
}
|
||||
|
||||
/** Query analysis results with optional filtering and pagination. */
|
||||
private function query_ideas( int $paged, string $type ): array {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
|
||||
$where = [];
|
||||
$params = [];
|
||||
$where = array();
|
||||
$params = array();
|
||||
|
||||
if ( '' !== $type ) {
|
||||
$where[] = 'analysis_type = %s';
|
||||
@@ -261,10 +296,10 @@ class PRAutoBlogger_Ideas_Browser {
|
||||
$offset = ( $paged - 1 ) * self::PER_PAGE;
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$total = (int) $wpdb->get_var(
|
||||
empty( $params )
|
||||
? "SELECT COUNT(*) FROM {$table} {$where_sql}"
|
||||
: $wpdb->prepare( "SELECT COUNT(*) FROM {$table} {$where_sql}", ...$params )
|
||||
$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';
|
||||
@@ -272,11 +307,14 @@ class PRAutoBlogger_Ideas_Browser {
|
||||
$full_sql = "SELECT * FROM {$table} {$where_sql} {$order_sql} {$limit_sql}";
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$rows = empty( $params )
|
||||
? $wpdb->get_results( $full_sql, ARRAY_A )
|
||||
: $wpdb->get_results( $wpdb->prepare( $full_sql, ...$params ), ARRAY_A );
|
||||
$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 [ 'rows' => $rows ?: [], 'total' => $total ];
|
||||
return array(
|
||||
'rows' => $rows ?? array(),
|
||||
'total' => $total,
|
||||
);
|
||||
}
|
||||
|
||||
/** Get counts per analysis_type for the filter badges. */
|
||||
@@ -285,13 +323,13 @@ class PRAutoBlogger_Ideas_Browser {
|
||||
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$rows = $wpdb->get_results(
|
||||
$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 = [];
|
||||
foreach ( $rows ?: [] as $row ) {
|
||||
$counts = array();
|
||||
foreach ( $rows ?? array() as $row ) {
|
||||
$counts[ $row['analysis_type'] ] = (int) $row['cnt'];
|
||||
}
|
||||
return $counts;
|
||||
|
||||
@@ -2,22 +2,29 @@
|
||||
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 hardcoded list of image models for the admin model picker.
|
||||
* No API discovery — updated manually when providers add/remove 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: None.
|
||||
* 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.
|
||||
* Get all available image generation models (Runware live + OpenRouter static).
|
||||
*
|
||||
* Each entry contains:
|
||||
* - id: Model identifier used in API calls.
|
||||
@@ -33,64 +40,10 @@ class PRAutoBlogger_Image_Model_Registry {
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function get_models(): array {
|
||||
return [
|
||||
[
|
||||
'id' => 'runware:100@1',
|
||||
'name' => 'FLUX.1 schnell (Runware)',
|
||||
'provider' => 'runware',
|
||||
'cost_per_image' => 0.0006,
|
||||
'capabilities' => [ 'image_generation' ],
|
||||
'description' => __( 'Default. Fast, very cheap, comic-friendly fidelity.', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
'id' => 'runware:101@1',
|
||||
'name' => 'FLUX.1 dev (Runware)',
|
||||
'provider' => 'runware',
|
||||
'cost_per_image' => 0.02,
|
||||
'capabilities' => [ 'image_generation' ],
|
||||
'description' => __( 'Higher fidelity FLUX. ~30x schnell cost.', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
'id' => 'google/gemini-2.5-flash-image',
|
||||
'name' => 'Gemini 2.5 Flash Image (Nano Banana)',
|
||||
'provider' => 'openrouter',
|
||||
'cost_per_image' => 0.005,
|
||||
'capabilities' => [ 'image_generation' ],
|
||||
'description' => __( 'Mid-tier quality, proven.', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
'id' => 'google/gemini-3.1-flash-image-preview',
|
||||
'name' => 'Gemini 3.1 Flash Image (Nano Banana 2)',
|
||||
'provider' => 'openrouter',
|
||||
'cost_per_image' => 0.008,
|
||||
'capabilities' => [ 'image_generation' ],
|
||||
'description' => __( 'Latest Google. Better quality.', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
'id' => 'google/gemini-3-pro-image-preview',
|
||||
'name' => 'Gemini 3 Pro Image (Nano Banana Pro)',
|
||||
'provider' => 'openrouter',
|
||||
'cost_per_image' => 0.03,
|
||||
'capabilities' => [ 'image_generation' ],
|
||||
'description' => __( 'Highest quality Google. Premium.', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
'id' => 'openai/gpt-5-image-mini',
|
||||
'name' => 'GPT-5 Image Mini',
|
||||
'provider' => 'openrouter',
|
||||
'cost_per_image' => 0.02,
|
||||
'capabilities' => [ 'image_generation' ],
|
||||
'description' => __( 'OpenAI budget image model.', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
'id' => 'openai/gpt-5-image',
|
||||
'name' => 'GPT-5 Image',
|
||||
'provider' => 'openrouter',
|
||||
'cost_per_image' => 0.08,
|
||||
'capabilities' => [ 'image_generation' ],
|
||||
'description' => __( 'OpenAI premium. Photorealistic.', 'prautoblogger' ),
|
||||
],
|
||||
];
|
||||
$catalog = new PRAutoBlogger_Runware_Model_Catalog();
|
||||
$runware_models = $catalog->get_models();
|
||||
$openrouter_models = self::get_openrouter_models();
|
||||
return array_merge( $runware_models, $openrouter_models );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,4 +61,204 @@ class PRAutoBlogger_Image_Model_Registry {
|
||||
}
|
||||
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);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Admin page displaying structured application log entries.
|
||||
*
|
||||
* Provides level filtering (error/warning/info/debug), text search,
|
||||
@@ -29,7 +31,7 @@ class PRAutoBlogger_Log_Viewer {
|
||||
__( 'Activity Log', 'prautoblogger' ),
|
||||
'manage_options',
|
||||
'prautoblogger-logs',
|
||||
[ $this, 'render_page' ]
|
||||
array( $this, 'render_page' )
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,19 +68,21 @@ class PRAutoBlogger_Log_Viewer {
|
||||
check_ajax_referer( 'prautoblogger_clear_logs', 'nonce' );
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
$days = isset( $_POST['days'] ) ? absint( $_POST['days'] ) : 30;
|
||||
$deleted = PRAutoBlogger_Logger::prune( $days );
|
||||
|
||||
wp_send_json_success( [
|
||||
'message' => sprintf(
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'message' => sprintf(
|
||||
/* translators: %d: number of entries deleted */
|
||||
__( '%d log entries cleared.', 'prautoblogger' ),
|
||||
$deleted
|
||||
),
|
||||
] );
|
||||
__( '%d log entries cleared.', 'prautoblogger' ),
|
||||
$deleted
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
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.
|
||||
*
|
||||
* Shows: monthly spend, daily cost chart, spend by pipeline stage, budget
|
||||
@@ -27,7 +29,7 @@ class PRAutoBlogger_Metrics_Page {
|
||||
__( 'Metrics & Costs', 'prautoblogger' ),
|
||||
'manage_options',
|
||||
'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 );
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
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,
|
||||
@@ -23,11 +25,11 @@ class PRAutoBlogger_Post_List_Columns {
|
||||
* Side effects: adds WordPress filters/actions for post list customization.
|
||||
*/
|
||||
public function register(): void {
|
||||
add_filter( 'manage_posts_columns', [ $this, 'filter_add_columns' ] );
|
||||
add_action( 'manage_posts_custom_column', [ $this, 'on_render_column' ], 10, 2 );
|
||||
add_filter( 'manage_edit-post_sortable_columns', [ $this, 'filter_sortable_columns' ] );
|
||||
add_action( 'pre_get_posts', [ $this, 'on_sort_by_model' ] );
|
||||
add_action( 'admin_head', [ $this, 'on_column_styles' ] );
|
||||
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' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,7 +39,7 @@ class PRAutoBlogger_Post_List_Columns {
|
||||
* @return array<string, string> Modified columns.
|
||||
*/
|
||||
public function filter_add_columns( array $columns ): array {
|
||||
$new = [];
|
||||
$new = array();
|
||||
foreach ( $columns as $key => $label ) {
|
||||
$new[ $key ] = $label;
|
||||
if ( 'title' === $key ) {
|
||||
@@ -109,10 +111,15 @@ class PRAutoBlogger_Post_List_Columns {
|
||||
return;
|
||||
}
|
||||
echo '<style>'
|
||||
. '.wp-list-table .column-title{width:35%;word-wrap:break-word;overflow-wrap:break-word}'
|
||||
. '.column-prab_writing_model{width:100px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}'
|
||||
. '.column-prab_image_model{width:100px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}'
|
||||
. '.column-prab_cost{width:55px;white-space:nowrap;overflow:visible}'
|
||||
. '.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;'
|
||||
@@ -180,8 +187,9 @@ class PRAutoBlogger_Post_List_Columns {
|
||||
// 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",
|
||||
FROM {$table} WHERE post_id = %d ORDER BY id ASC",
|
||||
$post_id
|
||||
),
|
||||
ARRAY_A
|
||||
@@ -192,7 +200,7 @@ class PRAutoBlogger_Post_List_Columns {
|
||||
return;
|
||||
}
|
||||
|
||||
$total = array_sum( array_column( $rows, 'estimated_cost' ) );
|
||||
$total = array_sum( array_column( $rows, 'estimated_cost' ) );
|
||||
$formatted = $total < 0.01
|
||||
? sprintf( '$%.4f', $total )
|
||||
: sprintf( '$%.2f', $total );
|
||||
@@ -243,7 +251,7 @@ class PRAutoBlogger_Post_List_Columns {
|
||||
. '<td><strong>%s</strong></td></tr>',
|
||||
esc_html( $total_f )
|
||||
);
|
||||
$html .= '</table>';
|
||||
$html .= '</table>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
@@ -2,21 +2,24 @@
|
||||
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
|
||||
* _prautoblogger_generated post meta).
|
||||
* Adds a metabox to PRAutoBlogger-generated posts.
|
||||
*
|
||||
* M2: Slimmed to a "View generation dossier" link. Full generation metadata
|
||||
* lives on the Article Dossier page (PRAutoBlogger_Dossier_Page).
|
||||
*
|
||||
* Triggered by: PRAutoBlogger::register_admin_hooks() on `add_meta_boxes`.
|
||||
* Dependencies: WordPress post meta API.
|
||||
* Dependencies: PRAutoBlogger_Dossier_Page (for URL helper).
|
||||
*
|
||||
* @see class-prautoblogger.php — Registers the hook.
|
||||
* @see core/class-publisher.php — Writes the metadata this class displays.
|
||||
* @see class-prautoblogger.php -- Registers the hook.
|
||||
* @see admin/class-dossier-page.php -- Dossier target; provides url_for_post().
|
||||
* @see core/class-publisher.php -- Writes the run_id meta this class reads.
|
||||
*/
|
||||
class PRAutoBlogger_Post_Metabox {
|
||||
|
||||
/**
|
||||
* Register the metabox for the post editor.
|
||||
* Register the metabox for PRAutoBlogger-generated posts only.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
@@ -33,8 +36,8 @@ class PRAutoBlogger_Post_Metabox {
|
||||
|
||||
add_meta_box(
|
||||
'prautoblogger_generation_info',
|
||||
__( 'PRAutoBlogger Generation Info', 'prautoblogger' ),
|
||||
[ $this, 'render_metabox' ],
|
||||
__( 'PRAutoBlogger', 'prautoblogger' ),
|
||||
array( $this, 'render_metabox' ),
|
||||
'post',
|
||||
'side',
|
||||
'default'
|
||||
@@ -42,13 +45,12 @@ class PRAutoBlogger_Post_Metabox {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the generation metadata metabox.
|
||||
*
|
||||
* @param \WP_Post $post
|
||||
* Render the dossier-link metabox.
|
||||
*
|
||||
* @param \WP_Post $post Current post.
|
||||
* @return void
|
||||
*/
|
||||
public function render_metabox( \WP_Post $post ): void {
|
||||
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/metabox-generation-info.php';
|
||||
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/metabox-dossier-link.php';
|
||||
}
|
||||
}
|
||||
|
||||
103
includes/admin/class-review-queue.php
Executable file → Normal file
103
includes/admin/class-review-queue.php
Executable file → Normal file
@@ -2,6 +2,8 @@
|
||||
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.
|
||||
*
|
||||
* Provides approve (publish), edit (opens WP editor), and reject (trash)
|
||||
@@ -28,11 +30,11 @@ class PRAutoBlogger_Review_Queue {
|
||||
__( 'Review Queue', 'prautoblogger' ),
|
||||
'manage_options',
|
||||
'prautoblogger-review-queue',
|
||||
[ $this, 'render_page' ]
|
||||
array( $this, 'render_page' )
|
||||
);
|
||||
|
||||
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;
|
||||
$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';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@@ -60,20 +75,22 @@ class PRAutoBlogger_Review_Queue {
|
||||
* @return \WP_Query
|
||||
*/
|
||||
public function get_pending_posts( int $paged = 1 ): \WP_Query {
|
||||
return new \WP_Query( [
|
||||
'post_type' => 'post',
|
||||
'post_status' => 'draft',
|
||||
'posts_per_page' => 20,
|
||||
'paged' => $paged,
|
||||
'meta_query' => [
|
||||
[
|
||||
'key' => '_prautoblogger_generated',
|
||||
'value' => '1',
|
||||
],
|
||||
],
|
||||
'orderby' => 'date',
|
||||
'order' => 'DESC',
|
||||
] );
|
||||
return new \WP_Query(
|
||||
array(
|
||||
'post_type' => 'post',
|
||||
'post_status' => 'draft',
|
||||
'posts_per_page' => 20,
|
||||
'paged' => $paged,
|
||||
'meta_query' => array(
|
||||
array(
|
||||
'key' => '_prautoblogger_generated',
|
||||
'value' => '1',
|
||||
),
|
||||
),
|
||||
'orderby' => 'date',
|
||||
'order' => 'DESC',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,7 +117,7 @@ class PRAutoBlogger_Review_Queue {
|
||||
$action = sanitize_text_field( wp_unslash( $_POST['prautoblogger_bulk_action'] ) );
|
||||
$post_ids = isset( $_POST['prautoblogger_post_ids'] ) && is_array( $_POST['prautoblogger_post_ids'] )
|
||||
? array_map( 'absint', $_POST['prautoblogger_post_ids'] )
|
||||
: [];
|
||||
: array();
|
||||
|
||||
if ( empty( $post_ids ) ) {
|
||||
return;
|
||||
@@ -113,25 +130,31 @@ class PRAutoBlogger_Review_Queue {
|
||||
}
|
||||
|
||||
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 ) ) {
|
||||
update_post_meta( $post_id, '_prautoblogger_approved_at', gmdate( 'c' ) );
|
||||
$count++;
|
||||
++$count;
|
||||
}
|
||||
} elseif ( 'reject' === $action ) {
|
||||
$result = wp_trash_post( $post_id );
|
||||
if ( false !== $result ) {
|
||||
$count++;
|
||||
++$count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$redirect = add_query_arg(
|
||||
[
|
||||
'page' => 'prautoblogger-review-queue',
|
||||
array(
|
||||
'page' => 'prautoblogger-review-queue',
|
||||
'prautoblogger_bulk_done' => $count,
|
||||
'prautoblogger_bulk_type' => $action,
|
||||
],
|
||||
),
|
||||
admin_url( 'admin.php' )
|
||||
);
|
||||
|
||||
@@ -150,25 +173,36 @@ class PRAutoBlogger_Review_Queue {
|
||||
check_ajax_referer( 'prautoblogger_review_queue', 'nonce' );
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
$post_id = isset( $_POST['post_id'] ) ? absint( $_POST['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;
|
||||
}
|
||||
|
||||
$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 ) ) {
|
||||
wp_send_json_error( [ 'message' => $result->get_error_message() ] );
|
||||
wp_send_json_error( array( 'message' => $result->get_error_message() ) );
|
||||
return;
|
||||
}
|
||||
|
||||
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' );
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
$post_id = isset( $_POST['post_id'] ) ? absint( $_POST['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;
|
||||
}
|
||||
|
||||
$result = wp_trash_post( $post_id );
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
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_SUFFIX constants,
|
||||
* 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().
|
||||
@@ -33,9 +35,9 @@ class PRAutoBlogger_Settings_Fields_Extended {
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function get_fields(): array {
|
||||
return [
|
||||
return array(
|
||||
// ── Sources (extended) ──────────────────────────────────────
|
||||
[
|
||||
array(
|
||||
'id' => 'prautoblogger_research_model',
|
||||
'label' => __( 'Research Model', 'prautoblogger' ),
|
||||
'type' => 'model_select',
|
||||
@@ -44,43 +46,65 @@ class PRAutoBlogger_Settings_Fields_Extended {
|
||||
'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' ),
|
||||
],
|
||||
[
|
||||
'id' => 'prautoblogger_reasoning_effort',
|
||||
'label' => __( 'Reasoning Effort', 'prautoblogger' ),
|
||||
'type' => 'select',
|
||||
'section' => 'prautoblogger_models',
|
||||
'default' => 'medium',
|
||||
'options' => [
|
||||
),
|
||||
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',
|
||||
@@ -88,8 +112,8 @@ class PRAutoBlogger_Settings_Fields_Extended {
|
||||
'default' => 1,
|
||||
'min' => 1,
|
||||
'max' => 10,
|
||||
],
|
||||
[
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_schedule_time',
|
||||
'label' => __( 'Generation Time', 'prautoblogger' ),
|
||||
'type' => 'time',
|
||||
@@ -100,8 +124,8 @@ class PRAutoBlogger_Settings_Fields_Extended {
|
||||
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',
|
||||
@@ -110,66 +134,66 @@ class PRAutoBlogger_Settings_Fields_Extended {
|
||||
'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' => [
|
||||
'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',
|
||||
@@ -177,8 +201,8 @@ class PRAutoBlogger_Settings_Fields_Extended {
|
||||
'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',
|
||||
@@ -187,34 +211,34 @@ class PRAutoBlogger_Settings_Fields_Extended {
|
||||
'min' => 0,
|
||||
'max' => 32,
|
||||
'description' => __( 'Base font size for article body text. 0 uses the theme default (13px). Recommended: 16–18px for comfortable reading.', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_table_borders',
|
||||
'label' => __( 'Table Borders', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_display',
|
||||
'default' => '1',
|
||||
'description' => __( 'Add visible borders, padding, and alternating row colors to tables in generated articles.', 'prautoblogger' ),
|
||||
],
|
||||
),
|
||||
|
||||
// ── Images ─────────────────────────────────────────────────
|
||||
[
|
||||
array(
|
||||
'id' => 'prautoblogger_image_enabled',
|
||||
'label' => __( 'Enable Image Generation', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => '0',
|
||||
'description' => __( 'Generate images for each published article.', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_image_b_enabled',
|
||||
'label' => __( 'Generate Second Image (B)', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => '1',
|
||||
'description' => __( 'Generate a second image from source data for A/B testing. Disabling saves one image generation + one LLM prompt rewrite per article.', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_image_model',
|
||||
'label' => __( 'Image Model', 'prautoblogger' ),
|
||||
'type' => 'model_select',
|
||||
@@ -223,39 +247,70 @@ class PRAutoBlogger_Settings_Fields_Extended {
|
||||
'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' => '🔑',
|
||||
],
|
||||
[
|
||||
'id' => 'prautoblogger_image_style_suffix',
|
||||
'label' => __( 'Style Suffix', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_image_style_template',
|
||||
'label' => __( 'Style Template', 'prautoblogger' ),
|
||||
'type' => 'textarea',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX,
|
||||
'description' => __( 'Appended to every image prompt. Controls visual look. Changing mid-run causes visible style drift.', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
'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 + CAPTION for the image generator. Changing this reshapes the look of all future images. Leave blank to use the default.', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
'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,6 +2,8 @@
|
||||
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.
|
||||
*
|
||||
* Centralizes all field and section definitions in one place, making it trivial
|
||||
@@ -24,53 +26,53 @@ class PRAutoBlogger_Settings_Fields {
|
||||
* @return array<string, array{title: string, icon: string, description: string}>
|
||||
*/
|
||||
public static function get_sections(): array {
|
||||
return [
|
||||
'prautoblogger_api' => [
|
||||
return array(
|
||||
'prautoblogger_api' => array(
|
||||
'title' => __( 'API Keys', 'prautoblogger' ),
|
||||
'icon' => 'dashicons-admin-network',
|
||||
'description' => __( 'Connect your external services. Keys are encrypted at rest.', 'prautoblogger' ),
|
||||
],
|
||||
'prautoblogger_models' => [
|
||||
),
|
||||
'prautoblogger_models' => array(
|
||||
'title' => __( 'AI Models', 'prautoblogger' ),
|
||||
'icon' => 'dashicons-superhero-alt',
|
||||
'description' => __( 'Choose which models power each stage of the pipeline.', 'prautoblogger' ),
|
||||
],
|
||||
'prautoblogger_content' => [
|
||||
),
|
||||
'prautoblogger_content' => array(
|
||||
'title' => __( 'Content', 'prautoblogger' ),
|
||||
'icon' => 'dashicons-edit-large',
|
||||
'description' => __( 'Control tone, length, pipeline mode, and topic guardrails.', 'prautoblogger' ),
|
||||
],
|
||||
'prautoblogger_sources' => [
|
||||
),
|
||||
'prautoblogger_sources' => array(
|
||||
'title' => __( 'Sources', 'prautoblogger' ),
|
||||
'icon' => 'dashicons-rss',
|
||||
'description' => __( 'Configure where PRAutoBlogger finds trending topics.', 'prautoblogger' ),
|
||||
],
|
||||
'prautoblogger_schedule' => [
|
||||
),
|
||||
'prautoblogger_schedule' => array(
|
||||
'title' => __( 'Schedule & Budget', 'prautoblogger' ),
|
||||
'icon' => 'dashicons-calendar-alt',
|
||||
'description' => __( 'Set daily generation schedule, volume, and spending limits.', 'prautoblogger' ),
|
||||
],
|
||||
'prautoblogger_publishing' => [
|
||||
),
|
||||
'prautoblogger_publishing' => array(
|
||||
'title' => __( 'Publishing', 'prautoblogger' ),
|
||||
'icon' => 'dashicons-megaphone',
|
||||
'description' => __( 'Control how generated content is published.', 'prautoblogger' ),
|
||||
],
|
||||
'prautoblogger_analytics' => [
|
||||
),
|
||||
'prautoblogger_analytics' => array(
|
||||
'title' => __( 'Analytics', 'prautoblogger' ),
|
||||
'icon' => 'dashicons-chart-area',
|
||||
'description' => __( 'Connect Google Analytics 4 for post performance scoring.', 'prautoblogger' ),
|
||||
],
|
||||
'prautoblogger_display' => [
|
||||
),
|
||||
'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' => [
|
||||
),
|
||||
'prautoblogger_images' => array(
|
||||
'title' => __( 'Images', 'prautoblogger' ),
|
||||
'icon' => 'dashicons-format-image',
|
||||
'description' => __( 'Image provider, model, and style controls. OpenRouter reuses your existing API key.', 'prautoblogger' ),
|
||||
],
|
||||
];
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,25 +90,25 @@ class PRAutoBlogger_Settings_Fields {
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private static function get_core_fields(): array {
|
||||
return [
|
||||
return array(
|
||||
// ── API Keys ────────────────────────────────────────────────
|
||||
[
|
||||
array(
|
||||
'id' => 'prautoblogger_openrouter_api_key',
|
||||
'label' => __( 'OpenRouter API Key', 'prautoblogger' ),
|
||||
'type' => 'password',
|
||||
'section' => 'prautoblogger_api',
|
||||
'description' => __( 'Get your key at openrouter.ai/keys', 'prautoblogger' ),
|
||||
'icon' => '🔑',
|
||||
],
|
||||
[
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_reddit_source_status',
|
||||
'label' => __( 'Reddit Data Source', 'prautoblogger' ),
|
||||
'type' => 'source_status',
|
||||
'section' => 'prautoblogger_api',
|
||||
'description' => __( 'Uses Reddit RSS (primary) and .json (fallback). No API key required.', 'prautoblogger' ),
|
||||
'icon' => '📡',
|
||||
],
|
||||
[
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_ai_gateway_base_url',
|
||||
'label' => __( 'Cloudflare AI Gateway URL', 'prautoblogger' ),
|
||||
'type' => 'text',
|
||||
@@ -114,8 +116,8 @@ class PRAutoBlogger_Settings_Fields {
|
||||
'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' ),
|
||||
'icon' => '☁️',
|
||||
],
|
||||
[
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_ai_gateway_cache_ttl',
|
||||
'label' => __( 'AI Gateway Cache TTL (seconds)', 'prautoblogger' ),
|
||||
'type' => 'number',
|
||||
@@ -124,10 +126,10 @@ class PRAutoBlogger_Settings_Fields {
|
||||
'min' => 0,
|
||||
'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' ),
|
||||
],
|
||||
),
|
||||
|
||||
// ── AI Models ───────────────────────────────────────────────
|
||||
[
|
||||
array(
|
||||
'id' => 'prautoblogger_analysis_model',
|
||||
'label' => __( 'Analysis Model', 'prautoblogger' ),
|
||||
'type' => 'model_select',
|
||||
@@ -136,8 +138,8 @@ class PRAutoBlogger_Settings_Fields {
|
||||
'capability' => 'text→text',
|
||||
'description' => __( 'Used for source analysis. Pick a cheap, fast model.', 'prautoblogger' ),
|
||||
'badge' => __( 'Low cost', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_writing_model',
|
||||
'label' => __( 'Writing Model', 'prautoblogger' ),
|
||||
'type' => 'model_select',
|
||||
@@ -146,8 +148,8 @@ class PRAutoBlogger_Settings_Fields {
|
||||
'capability' => 'text→text',
|
||||
'description' => __( 'Used for article generation. Quality matters here.', 'prautoblogger' ),
|
||||
'badge' => __( 'Quality', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_editor_model',
|
||||
'label' => __( 'Editor Model', 'prautoblogger' ),
|
||||
'type' => 'model_select',
|
||||
@@ -156,110 +158,110 @@ class PRAutoBlogger_Settings_Fields {
|
||||
'capability' => 'text→text',
|
||||
'description' => __( 'Used for the chief editor review pass.', 'prautoblogger' ),
|
||||
'badge' => __( 'Quality', 'prautoblogger' ),
|
||||
],
|
||||
),
|
||||
|
||||
// ── Content ─────────────────────────────────────────────────
|
||||
[
|
||||
array(
|
||||
'id' => 'prautoblogger_niche_description',
|
||||
'label' => __( 'Niche Description', 'prautoblogger' ),
|
||||
'type' => 'textarea',
|
||||
'section' => 'prautoblogger_content',
|
||||
'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',
|
||||
'label' => __( 'Content Tone', 'prautoblogger' ),
|
||||
'type' => 'select',
|
||||
'section' => 'prautoblogger_content',
|
||||
'default' => 'informational',
|
||||
'options' => [
|
||||
'options' => array(
|
||||
'informational' => __( 'Informational', 'prautoblogger' ),
|
||||
'conversational' => __( 'Conversational', 'prautoblogger' ),
|
||||
'professional' => __( 'Professional', 'prautoblogger' ),
|
||||
'casual' => __( 'Casual', 'prautoblogger' ),
|
||||
'authoritative' => __( 'Authoritative', 'prautoblogger' ),
|
||||
],
|
||||
],
|
||||
[
|
||||
),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_writing_pipeline',
|
||||
'label' => __( 'Writing Pipeline', 'prautoblogger' ),
|
||||
'type' => 'select',
|
||||
'section' => 'prautoblogger_content',
|
||||
'default' => 'multi_step',
|
||||
'options' => [
|
||||
'options' => array(
|
||||
'multi_step' => __( 'Multi-step (outline → draft → polish)', 'prautoblogger' ),
|
||||
'single_pass' => __( 'Single-pass (one LLM call)', 'prautoblogger' ),
|
||||
],
|
||||
],
|
||||
[
|
||||
),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_min_word_count',
|
||||
'label' => __( 'Min Word Count', 'prautoblogger' ),
|
||||
'type' => 'number',
|
||||
'section' => 'prautoblogger_content',
|
||||
'default' => 800,
|
||||
'min' => 200,
|
||||
],
|
||||
[
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_max_word_count',
|
||||
'label' => __( 'Max Word Count', 'prautoblogger' ),
|
||||
'type' => 'number',
|
||||
'section' => 'prautoblogger_content',
|
||||
'default' => 2000,
|
||||
'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',
|
||||
'label' => __( 'Topic Exclusions', 'prautoblogger' ),
|
||||
'type' => 'textarea',
|
||||
'section' => 'prautoblogger_content',
|
||||
'description' => __( 'Comma-separated topics to never write about.', 'prautoblogger' ),
|
||||
],
|
||||
),
|
||||
|
||||
// ── Sources ─────────────────────────────────────────────────
|
||||
[
|
||||
array(
|
||||
'id' => 'prautoblogger_target_subreddits',
|
||||
'label' => __( 'Target Subreddits', 'prautoblogger' ),
|
||||
'type' => 'textarea',
|
||||
'section' => 'prautoblogger_sources',
|
||||
'description' => __( 'Comma-separated, without r/. E.g.: peptides, Nootropics, biohackers', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_enabled_sources',
|
||||
'label' => __( 'Enabled Sources', 'prautoblogger' ),
|
||||
'type' => 'checkboxes',
|
||||
'section' => 'prautoblogger_sources',
|
||||
'options' => [
|
||||
'options' => array(
|
||||
'reddit' => __( 'Reddit (RSS + .json)', 'prautoblogger' ),
|
||||
'llm_research' => __( 'LLM Deep Research (reasoning model)', 'prautoblogger' ),
|
||||
],
|
||||
),
|
||||
'default' => '["reddit"]',
|
||||
'description' => __( 'Select which platforms to monitor for topics.', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_pullpush_cache_ttl',
|
||||
'label' => __( 'Research Cache (hours)', 'prautoblogger' ),
|
||||
'type' => 'number',
|
||||
@@ -268,21 +270,21 @@ class PRAutoBlogger_Settings_Fields {
|
||||
'min' => 1,
|
||||
'max' => 72,
|
||||
'description' => __( 'How long to cache Reddit research results before re-fetching. Saves bandwidth on low-traffic sites.', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_reddit_time_filter',
|
||||
'label' => __( 'Reddit Time Window', 'prautoblogger' ),
|
||||
'type' => 'select',
|
||||
'section' => 'prautoblogger_sources',
|
||||
'default' => 'day',
|
||||
'options' => [
|
||||
'options' => array(
|
||||
'day' => __( 'Past 24 hours', 'prautoblogger' ),
|
||||
'week' => __( 'Past week', 'prautoblogger' ),
|
||||
'month' => __( 'Past month', 'prautoblogger' ),
|
||||
],
|
||||
),
|
||||
'description' => __( 'How far back to search for trending posts and comments.', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_reddit_posts_per_subreddit',
|
||||
'label' => __( 'Posts per Subreddit', 'prautoblogger' ),
|
||||
'type' => 'number',
|
||||
@@ -291,7 +293,7 @@ class PRAutoBlogger_Settings_Fields {
|
||||
'min' => 5,
|
||||
'max' => 100,
|
||||
'description' => __( 'Maximum posts to fetch per subreddit per collection run.', 'prautoblogger' ),
|
||||
],
|
||||
];
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
72
includes/admin/class-wp-cli-commands.php
Normal file
72
includes/admin/class-wp-cli-commands.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?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 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 );
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,22 @@
|
||||
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).
|
||||
* 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 {
|
||||
|
||||
@@ -25,9 +30,8 @@ class PRAutoBlogger_OpenRouter_Model_Field {
|
||||
*/
|
||||
public static function render( string $id, string $value, array $args ): void {
|
||||
$capability = $args['capability'] ?? 'text→text';
|
||||
$display_name = $value ?: __( '— Select a model —', 'prautoblogger' );
|
||||
$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();
|
||||
@@ -70,6 +74,75 @@ class PRAutoBlogger_OpenRouter_Model_Field {
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
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
|
||||
|
||||
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 )
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
80
includes/class-activator.php
Executable file → Normal file
80
includes/class-activator.php
Executable file → Normal file
@@ -2,6 +2,8 @@
|
||||
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,
|
||||
* and schedules the initial cron job.
|
||||
*
|
||||
@@ -29,14 +31,22 @@ class PRAutoBlogger_Activator {
|
||||
self::set_default_options();
|
||||
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.
|
||||
update_option( 'prautoblogger_db_version', PRAUTOBLOGGER_DB_VERSION, false );
|
||||
}
|
||||
|
||||
/** Delegate to `PRAutoBlogger_Schema_Installer::install()` — see that class. */
|
||||
/**
|
||||
* Delegate to the schema installers — see those classes.
|
||||
*
|
||||
* `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
|
||||
* dbDelta-idempotent and safe to re-run on any version mismatch.
|
||||
*/
|
||||
private static function create_tables(): void {
|
||||
PRAutoBlogger_Schema_Installer::install();
|
||||
PRAutoBlogger_Pipeline_Schema_Installer::install();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,27 +59,29 @@ class PRAutoBlogger_Activator {
|
||||
* @return void
|
||||
*/
|
||||
private static function set_default_options(): void {
|
||||
$defaults = [
|
||||
$defaults = array(
|
||||
'prautoblogger_analysis_model' => PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL,
|
||||
'prautoblogger_writing_model' => PRAUTOBLOGGER_DEFAULT_WRITING_MODEL,
|
||||
'prautoblogger_editor_model' => PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL,
|
||||
'prautoblogger_daily_article_target' => 1,
|
||||
'prautoblogger_writing_pipeline' => 'multi_step',
|
||||
'prautoblogger_niche_description' => '',
|
||||
'prautoblogger_target_subreddits' => '[]',
|
||||
'prautoblogger_monthly_budget_usd' => 50.00,
|
||||
'prautoblogger_tone' => 'informational',
|
||||
'prautoblogger_min_word_count' => 800,
|
||||
'prautoblogger_max_word_count' => 2000,
|
||||
'prautoblogger_topic_exclusions' => '[]',
|
||||
'prautoblogger_enabled_sources' => '["reddit"]',
|
||||
'prautoblogger_article_font_family' => 'default',
|
||||
'prautoblogger_article_font_size' => 0,
|
||||
'prautoblogger_table_borders' => '1',
|
||||
'prautoblogger_schedule_time' => '03:00',
|
||||
'prautoblogger_log_level' => 'info',
|
||||
'prautoblogger_image_nsfw_retry' => '1',
|
||||
];
|
||||
'prautoblogger_daily_article_target' => 1,
|
||||
'prautoblogger_writing_pipeline' => 'multi_step',
|
||||
'prautoblogger_niche_description' => '',
|
||||
'prautoblogger_target_subreddits' => '[]',
|
||||
'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_min_word_count' => 800,
|
||||
'prautoblogger_max_word_count' => 2000,
|
||||
'prautoblogger_topic_exclusions' => '[]',
|
||||
'prautoblogger_enabled_sources' => '["reddit"]',
|
||||
'prautoblogger_article_font_family' => 'default',
|
||||
'prautoblogger_article_font_size' => 0,
|
||||
'prautoblogger_table_borders' => '1',
|
||||
'prautoblogger_schedule_time' => '03:00',
|
||||
'prautoblogger_log_level' => 'info',
|
||||
'prautoblogger_image_nsfw_retry' => '1',
|
||||
);
|
||||
|
||||
foreach ( $defaults as $key => $value ) {
|
||||
add_option( $key, $value );
|
||||
@@ -101,13 +113,16 @@ class PRAutoBlogger_Activator {
|
||||
// in class-prautoblogger.php may not be hooked yet.
|
||||
$schedules = wp_get_schedules();
|
||||
if ( ! isset( $schedules['prautoblogger_six_hours'] ) ) {
|
||||
add_filter( 'cron_schedules', static function ( array $scheds ): array {
|
||||
$scheds['prautoblogger_six_hours'] = [
|
||||
'interval' => 6 * HOUR_IN_SECONDS,
|
||||
'display' => __( 'Every Six Hours', 'prautoblogger' ),
|
||||
];
|
||||
return $scheds;
|
||||
} );
|
||||
add_filter(
|
||||
'cron_schedules',
|
||||
static function ( array $scheds ): array {
|
||||
$scheds['prautoblogger_six_hours'] = array(
|
||||
'interval' => 6 * HOUR_IN_SECONDS,
|
||||
'display' => __( 'Every Six Hours', 'prautoblogger' ),
|
||||
);
|
||||
return $scheds;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Schedule a separate metrics collection job (runs every 6 hours).
|
||||
@@ -125,6 +140,11 @@ class PRAutoBlogger_Activator {
|
||||
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' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,7 +166,7 @@ class PRAutoBlogger_Activator {
|
||||
$site_tz = function_exists( 'wp_timezone' )
|
||||
? wp_timezone()
|
||||
: new \DateTimeZone( 'UTC' );
|
||||
$local = ( new \DateTimeImmutable( 'tomorrow', $site_tz ) )
|
||||
$local = ( new \DateTimeImmutable( 'tomorrow', $site_tz ) )
|
||||
->setTime( $hour, $minute, 0 );
|
||||
return (int) $local->getTimestamp();
|
||||
} catch ( \Exception $e ) {
|
||||
@@ -222,8 +242,8 @@ class PRAutoBlogger_Activator {
|
||||
// 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
|
||||
$pairs = $wpdb->get_results(
|
||||
// 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"
|
||||
@@ -256,7 +276,7 @@ class PRAutoBlogger_Activator {
|
||||
return;
|
||||
}
|
||||
$curr = (string) get_option( 'prautoblogger_image_model', '' );
|
||||
if ( in_array( $curr, [ '', 'google/gemini-2.5-flash-image' ], true ) ) {
|
||||
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' );
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
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.
|
||||
@@ -38,7 +40,7 @@ class PRAutoBlogger_Ajax_Handlers {
|
||||
check_ajax_referer( 'prautoblogger_generate_image', 'nonce' );
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -48,29 +50,29 @@ class PRAutoBlogger_Ajax_Handlers {
|
||||
|
||||
$post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0;
|
||||
if ( 0 === $post_id ) {
|
||||
wp_send_json_error( [ 'message' => __( 'Missing post_id parameter.', 'prautoblogger' ) ] );
|
||||
wp_send_json_error( array( 'message' => __( 'Missing post_id parameter.', 'prautoblogger' ) ) );
|
||||
return;
|
||||
}
|
||||
|
||||
$post = get_post( $post_id );
|
||||
if ( ! $post ) {
|
||||
wp_send_json_error( [ 'message' => sprintf( __( 'Post %d not found.', 'prautoblogger' ), $post_id ) ] );
|
||||
wp_send_json_error( array( 'message' => sprintf( __( 'Post %d not found.', 'prautoblogger' ), $post_id ) ) );
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$pipeline = new PRAutoBlogger_Image_Pipeline();
|
||||
$article_data = [
|
||||
$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 ) : [];
|
||||
$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 ) : []
|
||||
is_array( $source_ids ) ? array_map( 'absint', $source_ids ) : array()
|
||||
);
|
||||
|
||||
// The pipeline sets featured image and Image B meta internally.
|
||||
@@ -82,7 +84,7 @@ class PRAutoBlogger_Ajax_Handlers {
|
||||
sprintf( 'Retroactive image gen %s for post %d: %s', get_class( $e ), $post_id, $e->getMessage() ),
|
||||
'image_pipeline'
|
||||
);
|
||||
wp_send_json_error( [ 'message' => $e->getMessage() ] );
|
||||
wp_send_json_error( array( 'message' => $e->getMessage() ) );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +100,7 @@ class PRAutoBlogger_Ajax_Handlers {
|
||||
check_ajax_referer( 'prautoblogger_get_models', 'nonce' );
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -123,23 +125,85 @@ class PRAutoBlogger_Ajax_Handlers {
|
||||
public function on_ajax_test_connection(): void {
|
||||
check_ajax_referer( 'prautoblogger_test_connection', 'nonce' );
|
||||
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;
|
||||
}
|
||||
|
||||
$service = isset( $_POST['service'] ) ? sanitize_text_field( wp_unslash( $_POST['service'] ) ) : '';
|
||||
$results = [];
|
||||
$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();
|
||||
$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' ) ];
|
||||
? 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);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* PSR-4-inspired autoloader for PRAutoBlogger classes.
|
||||
*
|
||||
* Maps class names like `PRAutoBlogger_Content_Generator` to file paths like
|
||||
@@ -19,7 +21,7 @@ class PRAutoBlogger_Autoloader {
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
private static array $directories = [
|
||||
private static array $directories = array(
|
||||
'', // includes/ root
|
||||
'admin/',
|
||||
'admin/fields/',
|
||||
@@ -28,7 +30,9 @@ class PRAutoBlogger_Autoloader {
|
||||
'models/',
|
||||
'frontend/',
|
||||
'services/',
|
||||
];
|
||||
'services/opik/',
|
||||
'ajax/',
|
||||
);
|
||||
|
||||
/**
|
||||
* Register the autoloader with SPL.
|
||||
@@ -36,7 +40,7 @@ class PRAutoBlogger_Autoloader {
|
||||
* @return 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' );
|
||||
}
|
||||
}
|
||||
}
|
||||
11
includes/class-deactivator.php
Executable file → Normal file
11
includes/class-deactivator.php
Executable file → Normal file
@@ -2,6 +2,8 @@
|
||||
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.
|
||||
*
|
||||
* Note: does NOT delete data or options — that happens in uninstall.php.
|
||||
@@ -33,11 +35,12 @@ class PRAutoBlogger_Deactivator {
|
||||
* @return void
|
||||
*/
|
||||
private static function clear_cron(): void {
|
||||
$hooks = [
|
||||
$hooks = array(
|
||||
'prautoblogger_daily_generation',
|
||||
'prautoblogger_collect_metrics',
|
||||
'prautoblogger_reap_orphan_research_rows',
|
||||
];
|
||||
'prautoblogger_sync_runware_models',
|
||||
);
|
||||
|
||||
foreach ( $hooks as $hook ) {
|
||||
$timestamp = wp_next_scheduled( $hook );
|
||||
@@ -55,11 +58,11 @@ class PRAutoBlogger_Deactivator {
|
||||
* @return void
|
||||
*/
|
||||
private static function clear_transients(): void {
|
||||
$transients = [
|
||||
$transients = array(
|
||||
'prautoblogger_reddit_token',
|
||||
'prautoblogger_generation_lock',
|
||||
'prautoblogger_openrouter_models',
|
||||
];
|
||||
);
|
||||
|
||||
foreach ( $transients as $transient ) {
|
||||
delete_transient( $transient );
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
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).
|
||||
*
|
||||
* Uses OpenSSL AES-256-CBC with a key derived from wp_salt('auth').
|
||||
|
||||
@@ -2,25 +2,28 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Cron/AJAX handlers for content generation, metrics collection, model registry.
|
||||
* 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-prautoblogger.php — Hook wiring.
|
||||
* @see core/class-pipeline-runner.php — Actual generation pipeline.
|
||||
* @see class-generation-lock.php — DB mutex.
|
||||
* @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;
|
||||
|
||||
/** Transient key for background generation status. */
|
||||
private const STATUS_TRANSIENT = 'prautoblogger_generation_status';
|
||||
|
||||
/** How long to keep the result available for polling (seconds). */
|
||||
private const STATUS_TTL = 600;
|
||||
/** @var PRAutoBlogger_Generation_Status_Poller|null Lazy-loaded poller. */
|
||||
private ?PRAutoBlogger_Generation_Status_Poller $poller = null;
|
||||
|
||||
/**
|
||||
* Handle the daily generation cron event.
|
||||
@@ -51,6 +54,7 @@ class PRAutoBlogger_Executor {
|
||||
sprintf( 'Daily generation %s: %s', get_class( $e ), $e->getMessage() ),
|
||||
'scheduler'
|
||||
);
|
||||
$this->mark_current_run_failed();
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
}
|
||||
}
|
||||
@@ -72,71 +76,12 @@ class PRAutoBlogger_Executor {
|
||||
|
||||
/**
|
||||
* AJAX handler: kick off manual generation as a background cron job.
|
||||
*
|
||||
* Returns immediately so the browser never hits Hostinger's 120-second
|
||||
* connection timeout. The frontend polls on_ajax_generation_status()
|
||||
* every few seconds to get progress and final results.
|
||||
* Delegates to PRAutoBlogger_Generation_Status_Poller.
|
||||
*
|
||||
* 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( [ '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( [
|
||||
'background' => true,
|
||||
'message' => __( 'Generation already in progress.', 'prautoblogger' ),
|
||||
] );
|
||||
return;
|
||||
}
|
||||
|
||||
// Write initial "running" status for the frontend to poll.
|
||||
set_transient( self::STATUS_TRANSIENT, [
|
||||
'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
|
||||
// that is not subject to the 120-second 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 ) ) ),
|
||||
[
|
||||
'timeout' => 0.01,
|
||||
'blocking' => false,
|
||||
'sslverify' => false,
|
||||
]
|
||||
);
|
||||
|
||||
wp_send_json_success( [
|
||||
'background' => true,
|
||||
'message' => __( 'Generation started in background. Polling for status...', 'prautoblogger' ),
|
||||
] );
|
||||
$this->poller()->on_ajax_generate_now();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,15 +98,19 @@ class PRAutoBlogger_Executor {
|
||||
@set_time_limit( 300 );
|
||||
|
||||
if ( ! PRAutoBlogger_Generation_Lock::acquire() ) {
|
||||
set_transient( self::STATUS_TRANSIENT, [
|
||||
'status' => 'error',
|
||||
'message' => __( 'Could not acquire generation lock. Another run may be in progress.', 'prautoblogger' ),
|
||||
], self::STATUS_TTL );
|
||||
set_transient(
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
|
||||
array(
|
||||
'status' => 'error',
|
||||
'message' => __( 'Could not acquire generation lock. Another run may be in progress.', 'prautoblogger' ),
|
||||
),
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->update_generation_stage( __( 'Collecting sources from Reddit...', 'prautoblogger' ) );
|
||||
$this->poller()->update_generation_stage( __( 'Collecting sources from Reddit...', 'prautoblogger' ) );
|
||||
|
||||
$runner = ( new PRAutoBlogger_Pipeline_Runner() )->set_skip_dedup( true );
|
||||
$results = $runner->run();
|
||||
@@ -169,13 +118,17 @@ class PRAutoBlogger_Executor {
|
||||
// If no articles were queued (target=1 or 0 ideas), finalize here.
|
||||
// When articles ARE queued, the pipeline handles status + lock release.
|
||||
if ( ! get_option( 'prautoblogger_article_queue' ) ) {
|
||||
set_transient( self::STATUS_TRANSIENT, [
|
||||
'status' => 'complete',
|
||||
'generated' => $results['generated'],
|
||||
'published' => $results['published'],
|
||||
'rejected' => $results['rejected'],
|
||||
'cost' => $results['cost'],
|
||||
], self::STATUS_TTL );
|
||||
set_transient(
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
|
||||
array(
|
||||
'status' => 'complete',
|
||||
'generated' => $results['generated'],
|
||||
'published' => $results['published'],
|
||||
'rejected' => $results['rejected'],
|
||||
'cost' => $results['cost'],
|
||||
),
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
|
||||
);
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
}
|
||||
} catch ( \Throwable $e ) {
|
||||
@@ -183,10 +136,15 @@ class PRAutoBlogger_Executor {
|
||||
sprintf( 'Manual generation %s: %s', get_class( $e ), $e->getMessage() ),
|
||||
'pipeline'
|
||||
);
|
||||
set_transient( self::STATUS_TRANSIENT, [
|
||||
'status' => 'error',
|
||||
'message' => $e->getMessage(),
|
||||
], self::STATUS_TTL );
|
||||
set_transient(
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
|
||||
array(
|
||||
'status' => 'error',
|
||||
'message' => $e->getMessage(),
|
||||
),
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
|
||||
);
|
||||
$this->mark_current_run_failed();
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
}
|
||||
}
|
||||
@@ -208,75 +166,45 @@ class PRAutoBlogger_Executor {
|
||||
// Clean up on catastrophic failure.
|
||||
delete_option( 'prautoblogger_article_queue' );
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
set_transient( self::STATUS_TRANSIENT, [
|
||||
'status' => 'error',
|
||||
'message' => $e->getMessage(),
|
||||
], self::STATUS_TTL );
|
||||
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. Recovers orphaned runs. */
|
||||
/** AJAX: return generation status for frontend polling. Delegates to poller. */
|
||||
public function on_ajax_generation_status(): void {
|
||||
check_ajax_referer( 'prautoblogger_generate_now', 'nonce' );
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 );
|
||||
return;
|
||||
}
|
||||
|
||||
$status = get_transient( self::STATUS_TRANSIENT );
|
||||
if ( ! is_array( $status ) ) {
|
||||
wp_send_json_success( [ 'status' => 'idle' ] );
|
||||
return;
|
||||
}
|
||||
|
||||
if ( 'running' === $status['status'] ) {
|
||||
$elapsed = time() - ( $status['started'] ?? 0 );
|
||||
|
||||
// Absolute timeout — give up after 10 minutes.
|
||||
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 );
|
||||
}
|
||||
|
||||
/** Clean up an orphaned generation run and report error. */
|
||||
private function abort_orphaned_run( string $message ): void {
|
||||
delete_transient( self::STATUS_TRANSIENT );
|
||||
delete_option( 'prautoblogger_article_queue' );
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
wp_send_json_success( [ 'status' => 'error', 'message' => $message ] );
|
||||
$this->poller()->on_ajax_generation_status();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: update the generation stage for frontend polling.
|
||||
* Lazy-load the generation status poller.
|
||||
*
|
||||
* @param string $stage Human-readable stage description.
|
||||
* @return PRAutoBlogger_Generation_Status_Poller
|
||||
*/
|
||||
private function update_generation_stage( string $stage ): void {
|
||||
$current = get_transient( self::STATUS_TRANSIENT );
|
||||
if ( is_array( $current ) ) {
|
||||
$current['stage'] = $stage;
|
||||
$current['last_updated'] = time();
|
||||
set_transient( self::STATUS_TRANSIENT, $current, self::STATUS_TTL );
|
||||
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' );
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
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.
|
||||
@@ -74,6 +76,29 @@ class PRAutoBlogger_Generation_Lock {
|
||||
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.
|
||||
*
|
||||
|
||||
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' );
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
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.
|
||||
|
||||
147
includes/class-pipeline-schema-installer.php
Normal file
147
includes/class-pipeline-schema-installer.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?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.2.0).
|
||||
*
|
||||
* What: Owns the five v0.18.0 substrate tables — the versioned prompt
|
||||
* registry (`prompts`), the audit child tables (`run_sources`,
|
||||
* `run_decisions`), and the run ledger / state machine tables
|
||||
* (`runs`, `run_stages`). Kept separate from the original
|
||||
* 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 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';
|
||||
|
||||
// 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,
|
||||
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)
|
||||
) {$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,
|
||||
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};";
|
||||
|
||||
dbDelta( $sql_prompts );
|
||||
dbDelta( $sql_run_sources );
|
||||
dbDelta( $sql_run_decisions );
|
||||
dbDelta( $sql_runs );
|
||||
dbDelta( $sql_run_stages );
|
||||
}
|
||||
}
|
||||
@@ -2,22 +2,16 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Main orchestrator for the PRAutoBlogger plugin.
|
||||
*
|
||||
* Registers all WordPress hooks (actions and filters) and wires up dependencies.
|
||||
* This is the ONLY place hooks are registered — individual classes contain only
|
||||
* business logic, not hook registration.
|
||||
* Registers all WordPress hooks and wires up dependencies.
|
||||
* DB/option migrations extracted to PRAutoBlogger_DB_Migrations (v0.19.2).
|
||||
*
|
||||
* Triggered by: prautoblogger() singleton in prautoblogger.php, called on `plugins_loaded`.
|
||||
* Dependencies: All other plugin classes (loaded via autoloader), PRAutoBlogger_Executor.
|
||||
*
|
||||
* @see prautoblogger.php — Plugin bootstrap that instantiates this class.
|
||||
* @see class-executor.php — Cron/AJAX handlers for generation, model registry.
|
||||
* @see class-ajax-handlers.php — Non-generation AJAX endpoints (images, models, test).
|
||||
* @see class-generation-lock.php — DB mutex for single-writer generation.
|
||||
* @see core/class-pipeline-runner.php — Executes the generation pipeline.
|
||||
* @see ARCHITECTURE.md — Data flow diagram showing how classes interact.
|
||||
* @see CONVENTIONS.md — Hook naming conventions.
|
||||
* @see prautoblogger.php -- Plugin bootstrap.
|
||||
* @see class-db-migrations.php -- One-time DB/option migrations.
|
||||
* @see ARCHITECTURE.md -- Data flow diagram.
|
||||
*/
|
||||
class PRAutoBlogger {
|
||||
|
||||
@@ -26,25 +20,25 @@ class PRAutoBlogger {
|
||||
/** @var PRAutoBlogger_Executor Handles generation cron/AJAX, model registry. */
|
||||
private PRAutoBlogger_Executor $executor;
|
||||
|
||||
/** @var PRAutoBlogger_Ajax_Handlers Handles non-generation AJAX (images, models, test). */
|
||||
/** @var PRAutoBlogger_Ajax_Handlers Handles non-generation AJAX. */
|
||||
private PRAutoBlogger_Ajax_Handlers $ajax_handlers;
|
||||
|
||||
/**
|
||||
* Register all hooks and initialize the plugin.
|
||||
* Called once on `plugins_loaded`. Idempotent.
|
||||
*
|
||||
* Side effects: registers WordPress actions and filters.
|
||||
*/
|
||||
public function run(): void {
|
||||
if ( $this->initialized ) {
|
||||
return;
|
||||
}
|
||||
$this->initialized = true;
|
||||
$this->executor = new PRAutoBlogger_Executor();
|
||||
$this->initialized = true;
|
||||
$this->executor = new PRAutoBlogger_Executor();
|
||||
$this->ajax_handlers = new PRAutoBlogger_Ajax_Handlers( $this->executor->get_model_registry() );
|
||||
|
||||
add_action( 'admin_init', [ $this, 'on_check_db_version' ] );
|
||||
add_filter( 'cron_schedules', [ $this, 'filter_add_cron_schedules' ] );
|
||||
add_action( 'admin_init', array( $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' ) );
|
||||
|
||||
if ( is_admin() ) {
|
||||
$this->register_admin_hooks();
|
||||
@@ -53,218 +47,177 @@ class PRAutoBlogger {
|
||||
$this->register_cron_hooks();
|
||||
$this->register_frontend_hooks();
|
||||
$this->register_ajax_hooks();
|
||||
$this->register_cli_hooks();
|
||||
|
||||
/** Fires after PRAutoBlogger has finished registering all hooks. */
|
||||
do_action( 'prautoblogger_loaded' );
|
||||
}
|
||||
|
||||
/** Register admin-only hooks (settings, notices, metabox, dashboard widget). */
|
||||
/**
|
||||
* Register admin-only hooks (settings, notices, metabox, board, dossier, widget).
|
||||
*
|
||||
* Menu-ordering rule (v0.19.1): parent menu must register at priority 10 before
|
||||
* any submenu (priority 11+). WordPress resolves the submenu hookname via
|
||||
* get_plugin_page_hookname() at add_submenu_page() time; if the parent slot in
|
||||
* $admin_page_hooks is unset, WP falls back to admin_page_* -> 404 at request
|
||||
* time. See ARCHITECTURE.md §Board for the full trace.
|
||||
*/
|
||||
private function register_admin_hooks(): void {
|
||||
$admin_page = new PRAutoBlogger_Admin_Page();
|
||||
add_action( 'admin_menu', [ $admin_page, 'on_register_menu' ] );
|
||||
add_action( 'admin_init', [ $admin_page, 'on_register_settings' ] );
|
||||
add_action( 'admin_enqueue_scripts', [ $admin_page, 'on_enqueue_assets' ] );
|
||||
add_action( 'admin_menu', array( $admin_page, 'on_register_menu' ), 10 );
|
||||
add_action( 'admin_init', array( $admin_page, 'on_register_settings' ) );
|
||||
add_action( 'admin_enqueue_scripts', array( $admin_page, 'on_enqueue_assets' ) );
|
||||
|
||||
add_action( 'admin_notices', [ new PRAutoBlogger_Admin_Notices(), 'on_display_notices' ] );
|
||||
add_action( 'add_meta_boxes', [ new PRAutoBlogger_Post_Metabox(), 'on_register_metabox' ] );
|
||||
add_action( 'admin_menu', [ new PRAutoBlogger_Metrics_Page(), 'on_register_menu' ] );
|
||||
add_action( 'wp_dashboard_setup', [ new PRAutoBlogger_Dashboard_Widget(), 'on_register_widget' ] );
|
||||
$board_page = new PRAutoBlogger_Board_Page();
|
||||
add_action( 'admin_menu', array( $board_page, 'on_register_menu' ), 11 );
|
||||
add_action( 'admin_enqueue_scripts', array( $board_page, 'on_enqueue_assets' ) );
|
||||
|
||||
// Dossier: link-accessed hidden submenu, priority 12 (after board).
|
||||
$dossier_page = new PRAutoBlogger_Dossier_Page();
|
||||
add_action( 'admin_menu', array( $dossier_page, 'on_register_menu' ), 12 );
|
||||
add_action( 'admin_enqueue_scripts', array( $dossier_page, 'on_enqueue_assets' ) );
|
||||
|
||||
add_action( 'admin_notices', array( new PRAutoBlogger_Admin_Notices(), 'on_display_notices' ) );
|
||||
add_action( 'add_meta_boxes', array( new PRAutoBlogger_Post_Metabox(), 'on_register_metabox' ) );
|
||||
add_action( 'admin_menu', array( new PRAutoBlogger_Metrics_Page(), 'on_register_menu' ) );
|
||||
add_action( 'wp_dashboard_setup', array( new PRAutoBlogger_Dashboard_Widget(), 'on_register_widget' ) );
|
||||
|
||||
$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' ) );
|
||||
|
||||
$ideas_browser = new PRAutoBlogger_Ideas_Browser();
|
||||
add_action( 'admin_menu', [ $ideas_browser, 'on_register_menu' ] );
|
||||
add_action( 'admin_menu', [ new PRAutoBlogger_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' ) );
|
||||
|
||||
( new PRAutoBlogger_Post_List_Columns() )->register();
|
||||
|
||||
// Block false update notifications — our slug collides with another plugin.
|
||||
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). */
|
||||
private function register_frontend_hooks(): void {
|
||||
$posts_widget = new PRAutoBlogger_Posts_Widget();
|
||||
add_action( 'init', [ $posts_widget, 'on_register_shortcode' ] );
|
||||
add_action( 'rest_api_init', [ $posts_widget, 'on_register_rest_route' ] );
|
||||
add_action( 'init', array( $posts_widget, 'on_register_shortcode' ) );
|
||||
add_action( 'rest_api_init', array( $posts_widget, 'on_register_rest_route' ) );
|
||||
|
||||
$typography = new PRAutoBlogger_Article_Typography();
|
||||
add_action( 'wp_head', [ $typography, 'on_wp_head' ] );
|
||||
add_action( 'wp_enqueue_scripts', [ $typography, 'on_enqueue_fonts' ] );
|
||||
add_filter( 'the_content', [ $typography, 'on_wrap_tables' ], 99 );
|
||||
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. */
|
||||
private function register_cron_hooks(): void {
|
||||
add_action( 'prautoblogger_daily_generation', [ $this->executor, 'on_daily_generation' ] );
|
||||
add_action( 'prautoblogger_collect_metrics', [ $this->executor, 'on_collect_metrics' ] );
|
||||
|
||||
// Manual "Generate Now" runs as a one-shot cron event to avoid
|
||||
// Hostinger's 120-second web-server connection timeout.
|
||||
add_action( 'prautoblogger_manual_generation', [ $this->executor, 'on_manual_generation' ] );
|
||||
|
||||
// Chained article generation — each queued article fires as its own
|
||||
// cron event so it gets a fresh PHP process and execution time budget.
|
||||
add_action( 'prautoblogger_daily_generation', array( $this->executor, 'on_daily_generation' ) );
|
||||
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,
|
||||
[ $this->executor, 'on_process_article_queue' ]
|
||||
array( $this->executor, 'on_process_article_queue' )
|
||||
);
|
||||
|
||||
$registry = $this->executor->get_model_registry();
|
||||
add_action( 'prautoblogger_refresh_model_registry', [ $registry, 'refresh' ] );
|
||||
add_action( 'prautoblogger_refresh_model_registry', array( $registry, 'refresh' ) );
|
||||
add_action( 'prautoblogger_sync_runware_models', array( new PRAutoBlogger_Runware_Model_Catalog(), 'sync' ) );
|
||||
|
||||
// Single-idea generation from the Ideas browser page.
|
||||
add_action( 'prautoblogger_generate_from_idea', [ 'PRAutoBlogger_Ideas_Browser', 'on_cron_generate_from_idea' ] );
|
||||
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() );
|
||||
}
|
||||
);
|
||||
|
||||
// v0.8.1: daily reaper for orphan `llm_research` rows (when a
|
||||
// pipeline dies before amortize_research_costs runs).
|
||||
add_action( 'prautoblogger_reap_orphan_research_rows', [ 'PRAutoBlogger_Research_Reaper', 'on_cron' ] );
|
||||
if ( is_admin() ) {
|
||||
new PRAutoBlogger_Opik_Settings();
|
||||
}
|
||||
|
||||
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' ) );
|
||||
|
||||
// v0.8.1: WP-CLI manual trigger for the reaper. Only registers when
|
||||
// WP-CLI is present; no-op in normal HTTP requests.
|
||||
if ( defined( 'WP_CLI' ) && WP_CLI ) {
|
||||
\WP_CLI::add_command( 'prautoblogger reap-research', static function (): void {
|
||||
$stats = PRAutoBlogger_Research_Reaper::reap();
|
||||
\WP_CLI::success( sprintf(
|
||||
'Reaped %d, deleted %d (stale), skipped %d.',
|
||||
(int) $stats['reaped'],
|
||||
(int) $stats['deleted'],
|
||||
(int) $stats['skipped']
|
||||
) );
|
||||
} );
|
||||
\WP_CLI::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. */
|
||||
private function register_ajax_hooks(): void {
|
||||
// Generation AJAX (start + status polling) stays on executor.
|
||||
add_action( 'wp_ajax_prautoblogger_generate_now', [ $this->executor, 'on_ajax_generate_now' ] );
|
||||
add_action( 'wp_ajax_prautoblogger_generation_status', [ $this->executor, 'on_ajax_generation_status' ] );
|
||||
add_action( 'wp_ajax_prautoblogger_generate_now', array( $this->executor, 'on_ajax_generate_now' ) );
|
||||
add_action( 'wp_ajax_prautoblogger_generation_status', array( $this->executor, 'on_ajax_generation_status' ) );
|
||||
|
||||
// Non-generation AJAX (images, models, test) on dedicated handler.
|
||||
add_action( 'wp_ajax_prautoblogger_generate_image', [ $this->ajax_handlers, 'on_ajax_generate_image' ] );
|
||||
add_action( 'wp_ajax_prautoblogger_test_connection', [ $this->ajax_handlers, 'on_ajax_test_connection' ] );
|
||||
add_action( 'wp_ajax_prautoblogger_get_models', [ $this->ajax_handlers, 'on_ajax_get_models' ] );
|
||||
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();
|
||||
add_action( 'wp_ajax_prautoblogger_approve_post', [ $review_queue, 'on_ajax_approve_post' ] );
|
||||
add_action( 'wp_ajax_prautoblogger_reject_post', [ $review_queue, 'on_ajax_reject_post' ] );
|
||||
add_action( 'wp_ajax_prautoblogger_approve_post', array( $review_queue, 'on_ajax_approve_post' ) );
|
||||
add_action( 'wp_ajax_prautoblogger_reject_post', array( $review_queue, 'on_ajax_reject_post' ) );
|
||||
|
||||
add_action( 'wp_ajax_prautoblogger_clear_logs', [ new PRAutoBlogger_Log_Viewer(), 'on_ajax_clear_logs' ] );
|
||||
add_action( 'wp_ajax_prautoblogger_clear_logs', array( new PRAutoBlogger_Log_Viewer(), 'on_ajax_clear_logs' ) );
|
||||
add_action( 'wp_ajax_' . PRAutoBlogger_Board_Page::AJAX_ACTION, array( new PRAutoBlogger_Board_Page(), 'on_ajax_board_status' ) );
|
||||
|
||||
$ideas = new PRAutoBlogger_Ideas_Browser();
|
||||
add_action( 'wp_ajax_prautoblogger_generate_from_idea', [ $ideas, 'on_ajax_generate_from_idea' ] );
|
||||
add_action( 'wp_ajax_prautoblogger_idea_gen_status', [ $ideas, 'on_ajax_idea_gen_status' ] );
|
||||
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.
|
||||
* Compares stored DB version with PRAUTOBLOGGER_DB_VERSION; re-runs activation if different.
|
||||
*
|
||||
* Side effects: may update database schema and db_version option.
|
||||
* Check if database needs migration. Delegates to PRAutoBlogger_DB_Migrations::run().
|
||||
* Hooked on `admin_init`.
|
||||
*/
|
||||
public function on_check_db_version(): void {
|
||||
$stored_version = get_option( 'prautoblogger_db_version', '0' );
|
||||
if ( version_compare( $stored_version, PRAUTOBLOGGER_DB_VERSION, '<' ) ) {
|
||||
PRAutoBlogger_Activator::activate();
|
||||
}
|
||||
PRAutoBlogger_DB_Migrations::run();
|
||||
}
|
||||
|
||||
// One-time migration: switch to Gemini 2.5 Flash Lite for cost/speed.
|
||||
if ( ! get_option( 'prautoblogger_migrated_gemini_flash_lite' ) ) {
|
||||
update_option( 'prautoblogger_analysis_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL );
|
||||
update_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
|
||||
update_option( 'prautoblogger_editor_model', PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL );
|
||||
update_option( 'prautoblogger_migrated_gemini_flash_lite', '1' );
|
||||
}
|
||||
|
||||
// One-time migration (v0.8.0): the admin no longer has an independent
|
||||
// Image Provider dropdown; provider is derived from the image model on
|
||||
// save. Auto-heal any existing site where the saved provider doesn't
|
||||
// match the saved model's provider (the root cause of posts 650/657
|
||||
// silently missing their featured image on 2026-04-20). Runs once.
|
||||
if ( ! get_option( 'prautoblogger_migrated_image_provider_v080' ) ) {
|
||||
$saved_model = (string) get_option( 'prautoblogger_image_model', '' );
|
||||
$provider = PRAutoBlogger_Image_Model_Registry::provider_for( $saved_model );
|
||||
if ( '' !== $provider ) {
|
||||
update_option( 'prautoblogger_image_provider', $provider );
|
||||
}
|
||||
update_option( 'prautoblogger_migrated_image_provider_v080', '1' );
|
||||
}
|
||||
|
||||
// One-time migration (v0.8.2): reschedule the daily-generation cron in
|
||||
// the site's configured timezone. Pre-v0.8.2 activator interpreted the
|
||||
// admin "Generation Time" input as UTC; after v0.8.2 it honours the
|
||||
// site timezone. Clears the stale UTC-scheduled event and re-queues
|
||||
// using the timezone-aware helper. See class-activator.php.
|
||||
PRAutoBlogger_Activator::reschedule_daily_in_site_timezone_v082();
|
||||
|
||||
// One-time migration (v3): switch to single-panel newspaper comic style.
|
||||
// Replaces both the old infomercial pastiche and the short-lived premium
|
||||
// photography style. Force-update unless the user has a truly custom value.
|
||||
if ( ! get_option( 'prautoblogger_migrated_style_suffix_v3' ) ) {
|
||||
$known_old_prefixes = [
|
||||
'Style: a screengrab from a 1995', // v1: infomercial.
|
||||
'Style: premium scientific lifestyle', // v2: photography.
|
||||
];
|
||||
$current = get_option( 'prautoblogger_image_style_suffix', '' );
|
||||
$is_old = ( '' === $current );
|
||||
foreach ( $known_old_prefixes as $prefix ) {
|
||||
if ( false !== strpos( $current, $prefix ) ) {
|
||||
$is_old = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ( $is_old ) {
|
||||
update_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
|
||||
}
|
||||
update_option( 'prautoblogger_migrated_style_suffix_v3', '1' );
|
||||
}
|
||||
// One-time migration (v4): remove caption-in-image instruction from style suffix.
|
||||
// Captions are now inserted as HTML below the image, not baked into the image.
|
||||
if ( ! get_option( 'prautoblogger_migrated_style_suffix_v4' ) ) {
|
||||
$current = get_option( 'prautoblogger_image_style_suffix', '' );
|
||||
// Detect v3 style suffix (contains "caption text" instruction).
|
||||
if ( false !== strpos( $current, 'caption text in a clean sans-serif font below the panel' ) ) {
|
||||
update_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
|
||||
}
|
||||
update_option( 'prautoblogger_migrated_style_suffix_v4', '1' );
|
||||
}
|
||||
|
||||
// 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 = [ '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' );
|
||||
/**
|
||||
* Run DB migrations during cron requests (hooked on `init`).
|
||||
* Only fires when wp_doing_cron() is true.
|
||||
*/
|
||||
public function on_check_db_version_for_cron(): void {
|
||||
if ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ) {
|
||||
$this->on_check_db_version();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add custom cron schedules (six-hourly for metrics collection).
|
||||
*
|
||||
* @param array<string, array{interval: int, display: string}> $schedules Existing schedules.
|
||||
* @param array<string, array{interval: int, display: string}> $schedules Existing.
|
||||
* @return array<string, array{interval: int, display: string}>
|
||||
*/
|
||||
public function filter_add_cron_schedules( array $schedules ): array {
|
||||
$schedules['prautoblogger_six_hours'] = [
|
||||
$schedules['prautoblogger_six_hours'] = array(
|
||||
'interval' => 6 * HOUR_IN_SECONDS,
|
||||
'display' => __( 'Every Six Hours', 'prautoblogger' ),
|
||||
];
|
||||
);
|
||||
return $schedules;
|
||||
}
|
||||
|
||||
/** Register WP-CLI commands. */
|
||||
private function register_cli_hooks(): void {
|
||||
PRAutoBlogger_WP_CLI_Commands::register();
|
||||
}
|
||||
|
||||
/**
|
||||
* Block false update notifications from wordpress.org.
|
||||
* Our plugin slug may collide with a different plugin in the directory.
|
||||
*
|
||||
* @param object $transient The update_plugins transient data.
|
||||
* @return object Modified transient.
|
||||
@@ -281,20 +234,25 @@ class PRAutoBlogger {
|
||||
return $this->executor;
|
||||
}
|
||||
|
||||
// ── Backward-compatible proxies ──
|
||||
// -- Backward-compatible proxies --
|
||||
|
||||
/** @see PRAutoBlogger_Executor::on_daily_generation() */
|
||||
public function on_daily_generation(): void { $this->executor->on_daily_generation(); }
|
||||
public function on_daily_generation(): void {
|
||||
$this->executor->on_daily_generation(); }
|
||||
|
||||
/** @see PRAutoBlogger_Executor::on_collect_metrics() */
|
||||
public function on_collect_metrics(): void { $this->executor->on_collect_metrics(); }
|
||||
public function on_collect_metrics(): void {
|
||||
$this->executor->on_collect_metrics(); }
|
||||
|
||||
/** @see PRAutoBlogger_Executor::on_ajax_generate_now() */
|
||||
public function on_ajax_generate_now(): void { $this->executor->on_ajax_generate_now(); }
|
||||
public function on_ajax_generate_now(): void {
|
||||
$this->executor->on_ajax_generate_now(); }
|
||||
|
||||
/** @see PRAutoBlogger_Ajax_Handlers::on_ajax_test_connection() */
|
||||
public function on_ajax_test_connection(): void { $this->ajax_handlers->on_ajax_test_connection(); }
|
||||
public function on_ajax_test_connection(): void {
|
||||
$this->ajax_handlers->on_ajax_test_connection(); }
|
||||
|
||||
/** @see PRAutoBlogger_Executor::get_model_registry() */
|
||||
public function get_model_registry(): PRAutoBlogger_OpenRouter_Model_Registry { return $this->executor->get_model_registry(); }
|
||||
public function get_model_registry(): PRAutoBlogger_OpenRouter_Model_Registry {
|
||||
return $this->executor->get_model_registry(); }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
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
|
||||
@@ -75,6 +77,9 @@ class PRAutoBlogger_Schema_Installer {
|
||||
// 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,
|
||||
@@ -88,6 +93,8 @@ class PRAutoBlogger_Schema_Installer {
|
||||
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),
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
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: WordPress $wpdb (for performance lookup), no external APIs.
|
||||
* 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.
|
||||
@@ -23,54 +26,26 @@ class PRAutoBlogger_Analysis_Prompts {
|
||||
* @return string System prompt text.
|
||||
*/
|
||||
public static 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";
|
||||
}
|
||||
// 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();
|
||||
if ( '' !== $recent_context ) {
|
||||
$prompt .= $recent_context . "\n\n";
|
||||
}
|
||||
$recent_block = '' !== $recent_context ? $recent_context . "\n\n" : '';
|
||||
|
||||
// Append user-defined analysis instructions if configured.
|
||||
$instructions = trim( (string) get_option( 'prautoblogger_analysis_instructions', '' ) );
|
||||
if ( '' !== $instructions ) {
|
||||
$prompt .= "Additional instructions:\n" . $instructions . "\n\n";
|
||||
}
|
||||
$instructions = trim( (string) get_option( 'prautoblogger_analysis_instructions', '' ) );
|
||||
$instructions_block = '' !== $instructions ? "Additional instructions:\n" . $instructions . "\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;
|
||||
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,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,9 +56,13 @@ class PRAutoBlogger_Analysis_Prompts {
|
||||
* @return string User prompt text.
|
||||
*/
|
||||
public static 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;
|
||||
return PRAutoBlogger_Prompt_Registry::render(
|
||||
'analysis.user',
|
||||
array(
|
||||
'target_count' => (string) $target_count,
|
||||
'summary' => $summary,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,7 +76,7 @@ class PRAutoBlogger_Analysis_Prompts {
|
||||
global $wpdb;
|
||||
$scores_table = $wpdb->prefix . 'prautoblogger_content_scores';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
// 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
|
||||
@@ -112,7 +91,7 @@ class PRAutoBlogger_Analysis_Prompts {
|
||||
return '';
|
||||
}
|
||||
|
||||
$lines = [ 'Top performing past articles (learn from these):' ];
|
||||
$lines = array( 'Top performing past articles (learn from these):' );
|
||||
foreach ( $top_posts as $post ) {
|
||||
$lines[] = sprintf(
|
||||
'- "%s" (score: %.1f)',
|
||||
@@ -136,27 +115,29 @@ class PRAutoBlogger_Analysis_Prompts {
|
||||
* @return string Formatted context block, or empty string if no recent articles.
|
||||
*/
|
||||
public static function get_recent_articles_context(): string {
|
||||
$query = new \WP_Query( [
|
||||
'post_type' => 'post',
|
||||
'post_status' => [ 'publish', 'draft', 'pending' ],
|
||||
'meta_key' => '_prautoblogger_generated',
|
||||
'meta_value' => '1',
|
||||
'date_query' => [ [ 'after' => '30 days ago' ] ],
|
||||
'posts_per_page' => 100,
|
||||
'fields' => 'ids',
|
||||
'no_found_rows' => true,
|
||||
] );
|
||||
$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 = [];
|
||||
$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 = '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 );
|
||||
|
||||
|
||||
@@ -2,18 +2,31 @@
|
||||
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.
|
||||
* 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 {
|
||||
|
||||
@@ -32,45 +45,80 @@ class PRAutoBlogger_Article_Worker {
|
||||
* 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.
|
||||
* 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 );
|
||||
|
||||
$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' ),
|
||||
[ '1', 'yes' ],
|
||||
array( '1', 'yes' ),
|
||||
true
|
||||
);
|
||||
|
||||
$result = [
|
||||
'generated' => 0,
|
||||
'published' => 0,
|
||||
'rejected' => 0,
|
||||
'cost' => 0.0,
|
||||
];
|
||||
|
||||
try {
|
||||
$this->broadcast_stage( __( 'Generating article draft via AI…', 'prautoblogger' ) );
|
||||
$content = $generator->generate( $idea );
|
||||
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Generating article draft via AI…', 'prautoblogger' ) );
|
||||
$content = $generator->generate( $idea );
|
||||
$result['generated'] = 1;
|
||||
|
||||
$this->broadcast_stage( __( 'Running editorial pass…', 'prautoblogger' ) );
|
||||
$review = $editor->review( $content, $idea );
|
||||
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Running editorial pass…', 'prautoblogger' ) );
|
||||
$review = $this->review_with_state( $editor, $content, $idea, $run_id, $item );
|
||||
|
||||
$this->broadcast_stage( __( 'Saving and publishing…', 'prautoblogger' ) );
|
||||
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
|
||||
$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',
|
||||
@@ -83,9 +131,72 @@ class PRAutoBlogger_Article_Worker {
|
||||
}
|
||||
|
||||
$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.
|
||||
*
|
||||
@@ -113,14 +224,14 @@ class PRAutoBlogger_Article_Worker {
|
||||
: $content;
|
||||
|
||||
if ( $auto_publish ) {
|
||||
$publisher->publish( $final, $idea, $review, $run_id );
|
||||
$result['published']++;
|
||||
$publisher->publish( $final, $idea, $review, $run_id, $this->cost_tracker );
|
||||
++$result['published'];
|
||||
} else {
|
||||
$publisher->save_as_draft( $final, $idea, $review, $run_id );
|
||||
$publisher->save_as_draft( $final, $idea, $review, $run_id, $this->cost_tracker );
|
||||
}
|
||||
} else {
|
||||
$publisher->save_as_draft( $content, $idea, $review, $run_id );
|
||||
$result['rejected']++;
|
||||
$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'
|
||||
@@ -129,17 +240,45 @@ class PRAutoBlogger_Article_Worker {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the generation status transient with the current stage.
|
||||
* Check if Opik tracing is enabled and has credentials.
|
||||
*
|
||||
* @param string $stage Human-readable stage description.
|
||||
* @return bool
|
||||
*/
|
||||
private function broadcast_stage( string $stage ): void {
|
||||
$transient_key = 'prautoblogger_generation_status';
|
||||
$current = get_transient( $transient_key );
|
||||
if ( is_array( $current ) && 'running' === ( $current['status'] ?? '' ) ) {
|
||||
$current['stage'] = $stage;
|
||||
$current['last_updated'] = time();
|
||||
set_transient( $transient_key, $current, 600 );
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
132
includes/core/class-audit-writer.php
Normal file
132
includes/core/class-audit-writer.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?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' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset per-request caches (tests). */
|
||||
public static function flush_cache(): void {
|
||||
self::$tables_ok = null;
|
||||
}
|
||||
}
|
||||
209
includes/core/class-chief-editor.php
Executable file → Normal file
209
includes/core/class-chief-editor.php
Executable file → Normal file
@@ -2,19 +2,14 @@
|
||||
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.
|
||||
* Can approve, request revisions (and produce them), or reject content.
|
||||
* Rejected content is saved as a draft for human review.
|
||||
* Reviews generated articles for quality, accuracy, SEO, tone.
|
||||
* Approves, requests revisions, or rejects content.
|
||||
* Opik instrumentation: spans per review LLM call.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger::run_generation_pipeline() (step 5).
|
||||
* Dependencies: LLM_Provider_Interface, Cost_Tracker.
|
||||
*
|
||||
* @see core/class-content-generator.php — Produces the content we review.
|
||||
* @see core/class-json-extractor.php — Tolerant JSON parsing for LLM output.
|
||||
* @see core/class-publisher.php — Publishes approved content.
|
||||
* @see ARCHITECTURE.md — Data flow step 5.
|
||||
* Triggered by: PRAutoBlogger_Article_Worker.
|
||||
* Dependencies: LLM_Provider_Interface, Cost_Tracker, Prompt_Registry.
|
||||
*/
|
||||
class PRAutoBlogger_Chief_Editor {
|
||||
|
||||
@@ -32,15 +27,8 @@ class PRAutoBlogger_Chief_Editor {
|
||||
/**
|
||||
* 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 PRAutoBlogger_Article_Idea $idea The original article idea.
|
||||
*
|
||||
* @param PRAutoBlogger_Article_Idea $idea The original article idea.
|
||||
* @return PRAutoBlogger_Editorial_Review
|
||||
*/
|
||||
public function review( string $content, PRAutoBlogger_Article_Idea $idea ): PRAutoBlogger_Editorial_Review {
|
||||
@@ -50,17 +38,45 @@ class PRAutoBlogger_Chief_Editor {
|
||||
$system_prompt = $this->build_system_prompt( $niche );
|
||||
$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(
|
||||
[
|
||||
[ 'role' => 'system', 'content' => $system_prompt ],
|
||||
[ 'role' => 'user', 'content' => $user_prompt ],
|
||||
],
|
||||
array(
|
||||
array(
|
||||
'role' => 'system',
|
||||
'content' => $system_prompt,
|
||||
),
|
||||
array(
|
||||
'role' => 'user',
|
||||
'content' => $user_prompt,
|
||||
),
|
||||
),
|
||||
$model,
|
||||
[
|
||||
array(
|
||||
'temperature' => 0.3,
|
||||
'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(
|
||||
@@ -76,112 +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 {
|
||||
$prompt = "You are a senior blog editor";
|
||||
if ( '' !== $niche ) {
|
||||
$prompt .= " specializing in {$niche} content";
|
||||
}
|
||||
$prompt .= ". Your job is to review article drafts before publication.\n\n";
|
||||
$instructions = trim( (string) get_option( 'prautoblogger_editor_instructions', '' ) );
|
||||
$instructions_block = '' !== $instructions ? "\nAdditional instructions:\n" . $instructions . "\n" : '';
|
||||
|
||||
$prompt .= "Evaluate the article on:\n";
|
||||
$prompt .= "1. QUALITY: Is the writing clear, engaging, and well-structured?\n";
|
||||
$prompt .= "2. ACCURACY: Does the content avoid making unsupported health/medical claims? Is the tone appropriately cautious for the topic?\n";
|
||||
$prompt .= "3. SEO: Are headings properly structured? Are target keywords naturally used?\n";
|
||||
$prompt .= "4. COMPLETENESS: Does it cover the key points? Does it have a proper intro and conclusion?\n";
|
||||
$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";
|
||||
$prompt .= "- When revising, PRESERVE all bullet points, numbered lists, hyperlinks, and structural formatting from the original. Do NOT flatten lists into prose or remove links.\n";
|
||||
|
||||
// Append user-defined editor instructions if configured.
|
||||
$instructions = trim( (string) get_option( 'prautoblogger_editor_instructions', '' ) );
|
||||
if ( '' !== $instructions ) {
|
||||
$prompt .= "\nAdditional instructions:\n" . $instructions . "\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
|
||||
return PRAutoBlogger_Prompt_Registry::render(
|
||||
'editor.system',
|
||||
array(
|
||||
'niche_clause' => '' !== $niche ? " specializing in {$niche} content" : '',
|
||||
'instructions_block' => $instructions_block,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
$data = PRAutoBlogger_Json_Extractor::decode( $content );
|
||||
|
||||
if ( ! is_array( $data ) || ! isset( $data['verdict'] ) ) {
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
'Chief editor response was not valid JSON. Defaulting to rejected. Raw (first 500 chars): '
|
||||
. mb_substr( $content, 0, 500 ),
|
||||
'Chief editor response unparseable (first 200 chars): ' . mb_substr( $content, 0, 200 ),
|
||||
'editor'
|
||||
);
|
||||
return new PRAutoBlogger_Editorial_Review( [
|
||||
'verdict' => 'rejected',
|
||||
'notes' => 'Editor response could not be parsed.',
|
||||
'quality_score' => 0.0,
|
||||
'seo_score' => 0.0,
|
||||
'issues' => [ 'Unparseable editor response' ],
|
||||
] );
|
||||
return new PRAutoBlogger_Editorial_Review(
|
||||
array(
|
||||
'verdict' => 'rejected',
|
||||
'notes' => 'Editor response unparseable',
|
||||
'quality_score' => 0.0,
|
||||
'seo_score' => 0.0,
|
||||
'issues' => array( 'Unparseable editor response' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$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';
|
||||
}
|
||||
|
||||
return new PRAutoBlogger_Editorial_Review( [
|
||||
'verdict' => $verdict,
|
||||
'notes' => sanitize_textarea_field( $data['notes'] ?? '' ),
|
||||
'revised_content' => isset( $data['revised_content'] ) ? wp_kses_post( $data['revised_content'] ) : null,
|
||||
'quality_score' => (float) ( $data['quality_score'] ?? 0.0 ),
|
||||
'seo_score' => (float) ( $data['seo_score'] ?? 0.0 ),
|
||||
'issues' => array_map( 'sanitize_text_field', $data['issues'] ?? [] ),
|
||||
] );
|
||||
return new PRAutoBlogger_Editorial_Review(
|
||||
array(
|
||||
'verdict' => $verdict,
|
||||
'notes' => sanitize_textarea_field( $data['notes'] ?? '' ),
|
||||
'revised_content' => isset( $data['revised_content'] ) ? wp_kses_post( $data['revised_content'] ) : null,
|
||||
'quality_score' => (float) ( $data['quality_score'] ?? 0.0 ),
|
||||
'seo_score' => (float) ( $data['seo_score'] ?? 0.0 ),
|
||||
'issues' => array_map( 'sanitize_text_field', $data['issues'] ?? array() ),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
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:
|
||||
* questions people ask, complaints/pain points, and product comparisons.
|
||||
*
|
||||
@@ -49,7 +51,7 @@ class PRAutoBlogger_Content_Analyzer {
|
||||
|
||||
if ( empty( $source_items ) ) {
|
||||
PRAutoBlogger_Logger::instance()->info( 'No new source data to analyze.', 'analyzer' );
|
||||
return [];
|
||||
return array();
|
||||
}
|
||||
|
||||
$niche = get_option( 'prautoblogger_niche_description', '' );
|
||||
@@ -61,16 +63,23 @@ class PRAutoBlogger_Content_Analyzer {
|
||||
$user_prompt = PRAutoBlogger_Analysis_Prompts::build_user_prompt( $summary, $target_idea_count );
|
||||
|
||||
$response = $this->llm->send_chat_completion(
|
||||
[
|
||||
[ 'role' => 'system', 'content' => $system_prompt ],
|
||||
[ 'role' => 'user', 'content' => $user_prompt ],
|
||||
],
|
||||
array(
|
||||
array(
|
||||
'role' => 'system',
|
||||
'content' => $system_prompt,
|
||||
),
|
||||
array(
|
||||
'role' => 'user',
|
||||
'content' => $user_prompt,
|
||||
),
|
||||
),
|
||||
$model,
|
||||
[
|
||||
array(
|
||||
'temperature' => 0.5,
|
||||
'max_tokens' => 8000,
|
||||
'response_format' => [ 'type' => 'json_object' ],
|
||||
]
|
||||
'response_format' => array( 'type' => 'json_object' ),
|
||||
'stage' => 'analysis',
|
||||
)
|
||||
);
|
||||
|
||||
$this->cost_tracker->log_api_call(
|
||||
@@ -125,12 +134,12 @@ class PRAutoBlogger_Content_Analyzer {
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
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",
|
||||
gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS )
|
||||
),
|
||||
ARRAY_A
|
||||
) ?: [];
|
||||
) ?? array();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,8 +151,8 @@ class PRAutoBlogger_Content_Analyzer {
|
||||
* @return string Formatted text summary.
|
||||
*/
|
||||
private function build_source_summary( array $items ): string {
|
||||
$parts = [];
|
||||
$char_limit = 30000; // Roughly ~7500 tokens.
|
||||
$parts = array();
|
||||
$char_limit = 30000; // Roughly ~7500 tokens.
|
||||
$current_chars = 0;
|
||||
|
||||
foreach ( $items as $item ) {
|
||||
@@ -163,7 +172,7 @@ class PRAutoBlogger_Content_Analyzer {
|
||||
break;
|
||||
}
|
||||
|
||||
$parts[] = $entry;
|
||||
$parts[] = $entry;
|
||||
$current_chars += strlen( $entry );
|
||||
}
|
||||
|
||||
@@ -185,27 +194,29 @@ class PRAutoBlogger_Content_Analyzer {
|
||||
. mb_substr( $content, 0, 500 ),
|
||||
'analyzer'
|
||||
);
|
||||
return [];
|
||||
return array();
|
||||
}
|
||||
|
||||
$source_ids = array_column( $source_items, 'id' );
|
||||
$results = [];
|
||||
$results = array();
|
||||
|
||||
foreach ( $data['patterns'] as $pattern ) {
|
||||
$results[] = new PRAutoBlogger_Analysis_Result( [
|
||||
'analysis_type' => sanitize_text_field( $pattern['type'] ?? 'question' ),
|
||||
'topic' => sanitize_text_field( $pattern['topic'] ?? '' ),
|
||||
'summary' => sanitize_textarea_field( $pattern['summary'] ?? '' ),
|
||||
'frequency' => absint( $pattern['frequency'] ?? 1 ),
|
||||
'relevance_score' => (float) ( $pattern['relevance_score'] ?? 0.0 ),
|
||||
'source_ids' => $source_ids,
|
||||
'analyzed_at' => current_time( 'mysql' ),
|
||||
'metadata' => [
|
||||
'suggested_title' => $pattern['suggested_title'] ?? '',
|
||||
'key_points' => $pattern['key_points'] ?? [],
|
||||
'target_keywords' => $pattern['target_keywords'] ?? [],
|
||||
],
|
||||
] );
|
||||
$results[] = new PRAutoBlogger_Analysis_Result(
|
||||
array(
|
||||
'analysis_type' => sanitize_text_field( $pattern['type'] ?? 'question' ),
|
||||
'topic' => sanitize_text_field( $pattern['topic'] ?? '' ),
|
||||
'summary' => sanitize_textarea_field( $pattern['summary'] ?? '' ),
|
||||
'frequency' => absint( $pattern['frequency'] ?? 1 ),
|
||||
'relevance_score' => (float) ( $pattern['relevance_score'] ?? 0.0 ),
|
||||
'source_ids' => $source_ids,
|
||||
'analyzed_at' => current_time( 'mysql' ),
|
||||
'metadata' => array(
|
||||
'suggested_title' => $pattern['suggested_title'] ?? '',
|
||||
'key_points' => $pattern['key_points'] ?? array(),
|
||||
'target_keywords' => $pattern['target_keywords'] ?? array(),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return $results;
|
||||
|
||||
314
includes/core/class-content-generator.php
Executable file → Normal file
314
includes/core/class-content-generator.php
Executable file → Normal file
@@ -4,23 +4,27 @@ declare(strict_types=1);
|
||||
/**
|
||||
* Writer agent — generates blog post content from article ideas.
|
||||
*
|
||||
* Supports two configurable pipeline modes:
|
||||
* - single_pass: One LLM call produces the complete article (cheaper, faster).
|
||||
* - multi_step: Outline → Draft → Polish pipeline (higher quality, more cost).
|
||||
* Supports: single_pass (one LLM call) or multi_step (outline → draft → polish).
|
||||
* Opik instrumentation: spans per LLM call when feature-flag enabled.
|
||||
* 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, Content_Prompts.
|
||||
*
|
||||
* @see core/class-content-prompts.php — Builds all LLM prompts used here.
|
||||
* @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 {
|
||||
|
||||
private PRAutoBlogger_LLM_Provider_Interface $llm;
|
||||
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(
|
||||
PRAutoBlogger_LLM_Provider_Interface $llm,
|
||||
PRAutoBlogger_Cost_Tracker $cost_tracker
|
||||
@@ -29,17 +33,36 @@ class PRAutoBlogger_Content_Generator {
|
||||
$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.
|
||||
*
|
||||
* 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.
|
||||
* @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' );
|
||||
|
||||
$request = new PRAutoBlogger_Content_Request(
|
||||
@@ -49,114 +72,223 @@ class PRAutoBlogger_Content_Generator {
|
||||
absint( get_option( 'prautoblogger_min_word_count', 800 ) ),
|
||||
absint( get_option( 'prautoblogger_max_word_count', 2000 ) ),
|
||||
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 $this->generate_single_pass( $request );
|
||||
}
|
||||
|
||||
return $this->generate_multi_step( $request );
|
||||
return 'single_pass' === $mode
|
||||
? $this->generate_single_pass( $request )
|
||||
: $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 Content request with settings.
|
||||
* 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.
|
||||
*/
|
||||
private function generate_single_pass( PRAutoBlogger_Content_Request $request ): string {
|
||||
$model = get_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
|
||||
|
||||
$response = $this->llm->send_chat_completion(
|
||||
[
|
||||
[ 'role' => 'system', 'content' => PRAutoBlogger_Content_Prompts::build_system( $request ) ],
|
||||
[ 'role' => 'user', 'content' => PRAutoBlogger_Content_Prompts::build_single_pass( $request ) ],
|
||||
],
|
||||
return $this->execute_stage(
|
||||
'draft',
|
||||
'single_pass_generation',
|
||||
PRAutoBlogger_Content_Prompts::build_single_pass( $request ),
|
||||
$request,
|
||||
$model,
|
||||
[ 'temperature' => 0.7, 'max_tokens' => 4000 ]
|
||||
array(
|
||||
'temperature' => 0.7,
|
||||
'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 Generation request.
|
||||
* @return string Generated HTML content.
|
||||
*/
|
||||
private function generate_multi_step( PRAutoBlogger_Content_Request $request ): string {
|
||||
$model = get_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
|
||||
|
||||
$outline = $this->stage_outline( $request, $model );
|
||||
$draft = $this->stage_draft( $request, $outline, $model );
|
||||
$polished = $this->stage_polish( $request, $draft, $model );
|
||||
$outline = $this->execute_stage(
|
||||
'outline',
|
||||
'outline_generation',
|
||||
PRAutoBlogger_Content_Prompts::build_outline( $request ),
|
||||
$request,
|
||||
$model,
|
||||
array(
|
||||
'temperature' => 0.5,
|
||||
'max_tokens' => 1500,
|
||||
)
|
||||
);
|
||||
|
||||
return $polished;
|
||||
$draft = $this->execute_stage(
|
||||
'draft',
|
||||
'draft_generation',
|
||||
PRAutoBlogger_Content_Prompts::build_draft( $request, $outline ),
|
||||
$request,
|
||||
$model,
|
||||
array(
|
||||
'temperature' => 0.7,
|
||||
'max_tokens' => 4000,
|
||||
)
|
||||
);
|
||||
|
||||
return $this->execute_stage(
|
||||
'polish',
|
||||
'polish_generation',
|
||||
PRAutoBlogger_Content_Prompts::build_polish( $draft ),
|
||||
$request,
|
||||
$model,
|
||||
array(
|
||||
'temperature' => 0.4,
|
||||
'max_tokens' => 4000,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Multi-step stage 1: Generate an article outline. */
|
||||
private function stage_outline( PRAutoBlogger_Content_Request $request, string $model ): string {
|
||||
$system = PRAutoBlogger_Content_Prompts::build_system( $request );
|
||||
/**
|
||||
* Run one writing stage: Opik span → LLM call → span close → cost log.
|
||||
*
|
||||
* Consolidates the per-stage boilerplate that was previously duplicated
|
||||
* across the four stage methods (v0.18.0). The system prompt is rebuilt
|
||||
* per stage exactly as before.
|
||||
*
|
||||
* 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 execute_stage(
|
||||
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 );
|
||||
}
|
||||
|
||||
// v0.18.1: stage metadata for the provider's empty-completion
|
||||
// guard — warning logs and failed-attempt audit rows carry the
|
||||
// real stage + registry key (never sent upstream).
|
||||
$options['stage'] = $stage;
|
||||
$options['prompt_key'] = $prompt_key;
|
||||
|
||||
$ctx = PRAutoBlogger_Opik_Trace_Context::current();
|
||||
|
||||
$span_id = $ctx->start_span(
|
||||
array(
|
||||
'name' => $span_name,
|
||||
'type' => 'llm',
|
||||
'model' => $model,
|
||||
'provider' => 'openrouter',
|
||||
)
|
||||
);
|
||||
|
||||
$response = $this->llm->send_chat_completion(
|
||||
[
|
||||
[ 'role' => 'system', 'content' => $system ],
|
||||
[ 'role' => 'user', 'content' => PRAutoBlogger_Content_Prompts::build_outline( $request ) ],
|
||||
],
|
||||
array(
|
||||
array(
|
||||
'role' => 'system',
|
||||
'content' => PRAutoBlogger_Content_Prompts::build_system( $request ),
|
||||
),
|
||||
array(
|
||||
'role' => 'user',
|
||||
'content' => $user_prompt,
|
||||
),
|
||||
),
|
||||
$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(
|
||||
null, 'outline', $this->llm->get_provider_name(),
|
||||
$response['model'], $response['prompt_tokens'], $response['completion_tokens']
|
||||
null,
|
||||
$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'];
|
||||
}
|
||||
|
||||
/** Multi-step stage 2: Write the full draft from the outline. */
|
||||
private function stage_draft( PRAutoBlogger_Content_Request $request, string $outline, string $model ): string {
|
||||
$system = PRAutoBlogger_Content_Prompts::build_system( $request );
|
||||
|
||||
$response = $this->llm->send_chat_completion(
|
||||
[
|
||||
[ 'role' => 'system', 'content' => $system ],
|
||||
[ 'role' => 'user', 'content' => PRAutoBlogger_Content_Prompts::build_draft( $request, $outline ) ],
|
||||
],
|
||||
$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. */
|
||||
private function stage_polish( PRAutoBlogger_Content_Request $request, string $draft, string $model ): string {
|
||||
$system = PRAutoBlogger_Content_Prompts::build_system( $request );
|
||||
|
||||
$response = $this->llm->send_chat_completion(
|
||||
[
|
||||
[ 'role' => 'system', 'content' => $system ],
|
||||
[ 'role' => 'user', 'content' => PRAutoBlogger_Content_Prompts::build_polish( $draft ) ],
|
||||
],
|
||||
$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'];
|
||||
/**
|
||||
* Generate content in eval mode (no publishing, no image generation).
|
||||
*
|
||||
* @param PRAutoBlogger_Article_Idea $idea The scored idea to generate content for.
|
||||
* @return string Generated HTML content.
|
||||
*/
|
||||
public function generate_eval( PRAutoBlogger_Article_Idea $idea ): string {
|
||||
return $this->generate( $idea, true );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
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
|
||||
@@ -9,7 +11,8 @@ declare(strict_types=1);
|
||||
* independently of the generation orchestration.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Content_Generator (called for every stage).
|
||||
* Dependencies: WordPress query functions (get_posts, get_the_title, get_permalink).
|
||||
* 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.
|
||||
@@ -22,18 +25,22 @@ class PRAutoBlogger_Content_Prompts {
|
||||
* 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 = '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.';
|
||||
$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() );
|
||||
@@ -58,24 +65,17 @@ class PRAutoBlogger_Content_Prompts {
|
||||
public static function build_single_pass( 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\n" .
|
||||
"- Follow EVERY formatting and structural requirement from your system prompt style guide",
|
||||
$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()
|
||||
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(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,21 +88,17 @@ class PRAutoBlogger_Content_Prompts {
|
||||
public static function build_outline( PRAutoBlogger_Content_Request $request ): string {
|
||||
$idea = $request->get_idea();
|
||||
|
||||
return sprintf(
|
||||
"Create a detailed outline for a blog post titled: \"%s\"\n\n" .
|
||||
"Topic: %s\nArticle type: %s\n\nKey points to cover:\n%s\n\n" .
|
||||
"Target keywords: %s\n\n" .
|
||||
"The outline should have 4-6 main sections with bullet points under each. " .
|
||||
"Include an introduction hook and a conclusion with a call to action. " .
|
||||
"Word count target: %d-%d words.\n\n" .
|
||||
"Plan the structure to satisfy EVERY requirement in your system prompt style guide.",
|
||||
$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()
|
||||
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(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,23 +110,15 @@ class PRAutoBlogger_Content_Prompts {
|
||||
* @return string Draft prompt.
|
||||
*/
|
||||
public static function build_draft( PRAutoBlogger_Content_Request $request, string $outline ): string {
|
||||
return sprintf(
|
||||
"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\n" .
|
||||
"- Follow EVERY formatting and structural requirement from your system prompt style guide",
|
||||
$outline,
|
||||
$request->get_tone(),
|
||||
$request->get_min_word_count(),
|
||||
$request->get_max_word_count(),
|
||||
implode( ', ', $request->get_idea()->get_target_keywords() )
|
||||
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() ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -141,18 +129,10 @@ class PRAutoBlogger_Content_Prompts {
|
||||
* @return string Polish prompt.
|
||||
*/
|
||||
public static function build_polish( string $draft ): string {
|
||||
return "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" .
|
||||
"IMPORTANT: Preserve all bullet points, numbered lists, hyperlinks, and " .
|
||||
"structural elements from the draft. Do NOT flatten lists into prose or " .
|
||||
"remove links. Ensure every requirement from your system prompt style " .
|
||||
"guide is satisfied in the final output.\n\n" .
|
||||
"Return the polished HTML content only. Do not add commentary.\n\n" .
|
||||
"DRAFT:\n" . $draft;
|
||||
return PRAutoBlogger_Prompt_Registry::render(
|
||||
'content.polish',
|
||||
array( 'draft' => $draft )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,7 +152,7 @@ class PRAutoBlogger_Content_Prompts {
|
||||
|
||||
$rules .= self::build_article_links();
|
||||
|
||||
$rules .= "--- END LINKING RULES ---";
|
||||
$rules .= '--- END LINKING RULES ---';
|
||||
|
||||
return $rules;
|
||||
}
|
||||
@@ -183,20 +163,22 @@ class PRAutoBlogger_Content_Prompts {
|
||||
* @return string Formatted list, or a fallback note.
|
||||
*/
|
||||
private static function build_article_links(): string {
|
||||
$posts = get_posts( [
|
||||
'post_type' => 'post',
|
||||
'post_status' => 'publish',
|
||||
'posts_per_page' => 30,
|
||||
'orderby' => 'date',
|
||||
'order' => 'DESC',
|
||||
'fields' => 'ids',
|
||||
] );
|
||||
$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 = [ "Available article links (use where topically relevant):\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 );
|
||||
@@ -207,5 +189,4 @@ class PRAutoBlogger_Content_Prompts {
|
||||
|
||||
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);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Provides historical cost reporting and analysis methods.
|
||||
*
|
||||
* 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' );
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$result = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"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 {
|
||||
global $wpdb;
|
||||
if ( null === $wpdb ) {
|
||||
return [];
|
||||
return array();
|
||||
}
|
||||
|
||||
$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 ) );
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$results = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT DATE(created_at) as day, SUM(estimated_cost) as total_cost
|
||||
@@ -74,8 +78,8 @@ class PRAutoBlogger_Cost_Reporter {
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
$daily = [];
|
||||
foreach ( ( $results ?: [] ) as $row ) {
|
||||
$daily = array();
|
||||
foreach ( ( $results ?? array() ) as $row ) {
|
||||
$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 {
|
||||
global $wpdb;
|
||||
if ( null === $wpdb ) {
|
||||
return [];
|
||||
return array();
|
||||
}
|
||||
|
||||
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$results = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT stage,
|
||||
@@ -114,13 +119,13 @@ class PRAutoBlogger_Cost_Reporter {
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
$breakdown = [];
|
||||
foreach ( ( $results ?: [] ) as $row ) {
|
||||
$breakdown[ $row['stage'] ] = [
|
||||
$breakdown = array();
|
||||
foreach ( ( $results ?? array() ) as $row ) {
|
||||
$breakdown[ $row['stage'] ] = array(
|
||||
'cost' => (float) $row['total_cost'],
|
||||
'tokens' => (int) $row['total_tokens'],
|
||||
'calls' => (int) $row['call_count'],
|
||||
];
|
||||
);
|
||||
}
|
||||
|
||||
return $breakdown;
|
||||
@@ -138,4 +143,63 @@ class PRAutoBlogger_Cost_Reporter {
|
||||
}
|
||||
return ( $this->get_monthly_spend() / $budget ) * 100.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get average input/output token counts for given stages over a time period.
|
||||
*
|
||||
* 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 ),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
138
includes/core/class-cost-tracker.php
Executable file → Normal file
138
includes/core/class-cost-tracker.php
Executable file → Normal file
@@ -2,6 +2,8 @@
|
||||
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.
|
||||
*
|
||||
* 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 {
|
||||
$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 );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,14 +71,20 @@ class PRAutoBlogger_Cost_Tracker {
|
||||
*
|
||||
* Side effects: database insert into prab_generation_log.
|
||||
*
|
||||
* @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 $provider Provider name: 'OpenRouter'.
|
||||
* @param string $model Model identifier used.
|
||||
* @param int $prompt_tokens Input tokens consumed.
|
||||
* @param int $completion_tokens Output tokens generated.
|
||||
* @param string $response_status 'success', 'error', or 'timeout'.
|
||||
* @param string $error_message Error details if status is not 'success'.
|
||||
* @param int|null $post_id WordPress post ID (null during generation, set later).
|
||||
* @param string $stage Pipeline stage (see PRAutoBlogger_Stage_Display_Map).
|
||||
* @param string $provider Provider name: 'OpenRouter'.
|
||||
* @param string $model Model identifier used.
|
||||
* @param int $prompt_tokens Input tokens consumed.
|
||||
* @param int $completion_tokens Output tokens generated.
|
||||
* @param string $response_status 'success', 'error', or 'timeout'.
|
||||
* @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
|
||||
*/
|
||||
@@ -80,34 +96,50 @@ class PRAutoBlogger_Cost_Tracker {
|
||||
int $prompt_tokens,
|
||||
int $completion_tokens,
|
||||
string $response_status = 'success',
|
||||
string $error_message = ''
|
||||
string $error_message = '',
|
||||
?string $agent_role = null,
|
||||
?string $prompt_key = null
|
||||
): void {
|
||||
$llm = new PRAutoBlogger_OpenRouter_Provider();
|
||||
$cost = $llm->estimate_cost( $model, $prompt_tokens, $completion_tokens );
|
||||
|
||||
$this->current_run_cost += $cost;
|
||||
|
||||
$log_entry = new PRAutoBlogger_Generation_Log( [
|
||||
'post_id' => $post_id,
|
||||
'run_id' => $this->run_id,
|
||||
'stage' => $stage,
|
||||
'provider' => $provider,
|
||||
'model' => $model,
|
||||
'prompt_tokens' => $prompt_tokens,
|
||||
'completion_tokens' => $completion_tokens,
|
||||
'estimated_cost' => $cost,
|
||||
'response_status' => $response_status,
|
||||
'error_message' => $error_message,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
] );
|
||||
$log_entry = new PRAutoBlogger_Generation_Log(
|
||||
array(
|
||||
'post_id' => $post_id,
|
||||
'run_id' => $this->run_id,
|
||||
'stage' => $stage,
|
||||
'provider' => $provider,
|
||||
'model' => $model,
|
||||
'prompt_tokens' => $prompt_tokens,
|
||||
'completion_tokens' => $completion_tokens,
|
||||
'estimated_cost' => $cost,
|
||||
'response_status' => $response_status,
|
||||
'error_message' => $error_message,
|
||||
'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' ),
|
||||
)
|
||||
);
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||
|
||||
// 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' );
|
||||
}
|
||||
}
|
||||
@@ -157,8 +189,7 @@ class PRAutoBlogger_Cost_Tracker {
|
||||
}
|
||||
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->insert( $table, [
|
||||
$row = array(
|
||||
'post_id' => $post_id,
|
||||
'run_id' => $this->run_id,
|
||||
'stage' => $stage,
|
||||
@@ -169,8 +200,44 @@ class PRAutoBlogger_Cost_Tracker {
|
||||
'estimated_cost' => $cost_usd,
|
||||
'response_status' => 'success',
|
||||
'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' ),
|
||||
] );
|
||||
);
|
||||
|
||||
// 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 +270,21 @@ class PRAutoBlogger_Cost_Tracker {
|
||||
public function get_current_run_cost(): float {
|
||||
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);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Google Analytics 4 API client for fetching post performance metrics.
|
||||
*
|
||||
* Handles OAuth2 service account authentication and the GA4 Data API.
|
||||
@@ -29,32 +31,32 @@ class PRAutoBlogger_GA4_Client {
|
||||
public function fetch_data( array $posts ): array {
|
||||
$property_id = get_option( 'prautoblogger_ga4_property_id', '' );
|
||||
if ( '' === $property_id ) {
|
||||
return [];
|
||||
return array();
|
||||
}
|
||||
|
||||
$credentials_encrypted = get_option( 'prautoblogger_ga4_credentials_json', '' );
|
||||
if ( '' === $credentials_encrypted ) {
|
||||
return [];
|
||||
return array();
|
||||
}
|
||||
|
||||
$credentials = PRAutoBlogger_Encryption::decrypt( $credentials_encrypted );
|
||||
if ( '' === $credentials ) {
|
||||
return [];
|
||||
return array();
|
||||
}
|
||||
|
||||
// Build page paths for the GA4 query.
|
||||
$page_paths = [];
|
||||
$path_to_id = [];
|
||||
$page_paths = array();
|
||||
$path_to_id = array();
|
||||
foreach ( $posts as $post ) {
|
||||
$path = wp_parse_url( get_permalink( $post->ID ), PHP_URL_PATH );
|
||||
if ( $path ) {
|
||||
$page_paths[] = $path;
|
||||
$page_paths[] = $path;
|
||||
$path_to_id[ $path ] = (int) $post->ID;
|
||||
}
|
||||
}
|
||||
|
||||
if ( empty( $page_paths ) ) {
|
||||
return [];
|
||||
return array();
|
||||
}
|
||||
|
||||
// GA4 Data API request.
|
||||
@@ -62,40 +64,45 @@ class PRAutoBlogger_GA4_Client {
|
||||
$access_token = $this->get_access_token( $credentials );
|
||||
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' );
|
||||
return [];
|
||||
return array();
|
||||
}
|
||||
|
||||
$request_body = [
|
||||
'dateRanges' => [ [ 'startDate' => '30daysAgo', 'endDate' => 'today' ] ],
|
||||
'dimensions' => [ [ 'name' => 'pagePath' ] ],
|
||||
'metrics' => [
|
||||
[ 'name' => 'screenPageViews' ],
|
||||
[ 'name' => 'averageSessionDuration' ],
|
||||
[ 'name' => 'bounceRate' ],
|
||||
],
|
||||
'dimensionFilter' => [
|
||||
'filter' => [
|
||||
$request_body = array(
|
||||
'dateRanges' => array(
|
||||
array(
|
||||
'startDate' => '30daysAgo',
|
||||
'endDate' => 'today',
|
||||
),
|
||||
),
|
||||
'dimensions' => array( array( 'name' => 'pagePath' ) ),
|
||||
'metrics' => array(
|
||||
array( 'name' => 'screenPageViews' ),
|
||||
array( 'name' => 'averageSessionDuration' ),
|
||||
array( 'name' => 'bounceRate' ),
|
||||
),
|
||||
'dimensionFilter' => array(
|
||||
'filter' => array(
|
||||
'fieldName' => 'pagePath',
|
||||
'inListFilter' => [ 'values' => $page_paths ],
|
||||
],
|
||||
],
|
||||
];
|
||||
'inListFilter' => array( 'values' => $page_paths ),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
$response = wp_remote_post(
|
||||
"https://analyticsdata.googleapis.com/v1beta/properties/{$property_id}:runReport",
|
||||
[
|
||||
array(
|
||||
'timeout' => 30,
|
||||
'headers' => [
|
||||
'headers' => array(
|
||||
'Authorization' => 'Bearer ' . $access_token,
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
),
|
||||
'body' => wp_json_encode( $request_body ),
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
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 );
|
||||
@@ -105,20 +112,20 @@ class PRAutoBlogger_GA4_Client {
|
||||
sprintf( 'GA4 API request failed (HTTP %d): %s', $ga4_status, substr( $ga4_body, 0, 500 ) ),
|
||||
'ga4'
|
||||
);
|
||||
return [];
|
||||
return array();
|
||||
}
|
||||
|
||||
$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'] ?? '';
|
||||
if ( isset( $path_to_id[ $path ] ) ) {
|
||||
$results[ $path_to_id[ $path ] ] = [
|
||||
$results[ $path_to_id[ $path ] ] = array(
|
||||
'pageviews' => (int) ( $row['metricValues'][0]['value'] ?? 0 ),
|
||||
'avg_time_on_page' => (float) ( $row['metricValues'][1]['value'] ?? 0 ),
|
||||
'bounce_rate' => (float) ( $row['metricValues'][2]['value'] ?? 0 ),
|
||||
];
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,19 +147,26 @@ class PRAutoBlogger_GA4_Client {
|
||||
|
||||
// Build JWT for service account auth.
|
||||
$now = time();
|
||||
$header = wp_json_encode( [ 'alg' => 'RS256', 'typ' => 'JWT' ] );
|
||||
$claim = wp_json_encode( [
|
||||
'iss' => $creds['client_email'],
|
||||
'scope' => 'https://www.googleapis.com/auth/analytics.readonly',
|
||||
'aud' => 'https://oauth2.googleapis.com/token',
|
||||
'exp' => $now + 3600,
|
||||
'iat' => $now,
|
||||
] );
|
||||
$header = wp_json_encode(
|
||||
array(
|
||||
'alg' => 'RS256',
|
||||
'typ' => 'JWT',
|
||||
)
|
||||
);
|
||||
$claim = wp_json_encode(
|
||||
array(
|
||||
'iss' => $creds['client_email'],
|
||||
'scope' => 'https://www.googleapis.com/auth/analytics.readonly',
|
||||
'aud' => 'https://oauth2.googleapis.com/token',
|
||||
'exp' => $now + 3600,
|
||||
'iat' => $now,
|
||||
)
|
||||
);
|
||||
|
||||
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
|
||||
$base64_header = rtrim( strtr( base64_encode( $header ), '+/', '-_' ), '=' );
|
||||
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
|
||||
$base64_claim = rtrim( strtr( base64_encode( $claim ), '+/', '-_' ), '=' );
|
||||
$base64_claim = rtrim( strtr( base64_encode( $claim ), '+/', '-_' ), '=' );
|
||||
|
||||
$signing_input = $base64_header . '.' . $base64_claim;
|
||||
$signature = '';
|
||||
@@ -171,13 +185,13 @@ class PRAutoBlogger_GA4_Client {
|
||||
|
||||
$response = wp_remote_post(
|
||||
'https://oauth2.googleapis.com/token',
|
||||
[
|
||||
array(
|
||||
'timeout' => 15,
|
||||
'body' => [
|
||||
'body' => array(
|
||||
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
'assertion' => $jwt,
|
||||
],
|
||||
]
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
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.
|
||||
*
|
||||
* Uses PRAutoBlogger_Semantic_Dedup for similarity detection — embedding-based
|
||||
@@ -44,8 +46,8 @@ class PRAutoBlogger_Idea_Scorer {
|
||||
* @return PRAutoBlogger_Article_Idea[] Top-ranked ideas, sorted by score desc.
|
||||
*/
|
||||
public function score_and_rank( array $analysis_results, int $target_count ): array {
|
||||
$exclusions = json_decode( get_option( 'prautoblogger_topic_exclusions', '[]' ), true ) ?: [];
|
||||
$ideas = [];
|
||||
$exclusions = json_decode( get_option( 'prautoblogger_topic_exclusions', '[]' ), true ) ?? array();
|
||||
$ideas = array();
|
||||
$excluded_count = 0;
|
||||
$deduped_count = 0;
|
||||
|
||||
@@ -55,11 +57,11 @@ class PRAutoBlogger_Idea_Scorer {
|
||||
}
|
||||
|
||||
// Keyword sets kept for fallback (passed through to Semantic_Dedup).
|
||||
$accepted_keyword_sets = [];
|
||||
$accepted_keyword_sets = array();
|
||||
|
||||
foreach ( $analysis_results as $result ) {
|
||||
if ( $this->is_excluded( $result->get_topic(), $exclusions ) ) {
|
||||
$excluded_count++;
|
||||
++$excluded_count;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -68,7 +70,7 @@ class PRAutoBlogger_Idea_Scorer {
|
||||
$topic_keywords = $this->extract_meaningful_keywords( $topic );
|
||||
|
||||
if ( $dedup->is_batch_duplicate( $topic, $topic_keywords, $accepted_keyword_sets ) ) {
|
||||
$deduped_count++;
|
||||
++$deduped_count;
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf( 'Dedup(batch): skipping "%s"', substr( $topic, 0, 80 ) ),
|
||||
'scorer'
|
||||
@@ -77,7 +79,7 @@ class PRAutoBlogger_Idea_Scorer {
|
||||
}
|
||||
|
||||
if ( $dedup->is_recent_duplicate( $topic, $topic_keywords ) ) {
|
||||
$deduped_count++;
|
||||
++$deduped_count;
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf( 'Dedup(recent): skipping "%s"', substr( $topic, 0, 80 ) ),
|
||||
'scorer'
|
||||
@@ -89,19 +91,21 @@ class PRAutoBlogger_Idea_Scorer {
|
||||
$dedup->record_accepted( $topic );
|
||||
}
|
||||
|
||||
$metadata = $result->get_metadata() ?? [];
|
||||
$metadata = $result->get_metadata() ?? array();
|
||||
|
||||
$ideas[] = new PRAutoBlogger_Article_Idea( [
|
||||
'topic' => $result->get_topic(),
|
||||
'article_type' => $this->map_type( $result->get_analysis_type() ),
|
||||
'suggested_title' => $metadata['suggested_title'] ?? $result->get_topic(),
|
||||
'summary' => $result->get_summary() ?? '',
|
||||
'score' => $this->compute_score( $result ),
|
||||
'analysis_id' => $result->get_id(),
|
||||
'source_ids' => $result->get_source_ids(),
|
||||
'key_points' => $metadata['key_points'] ?? [],
|
||||
'target_keywords' => $metadata['target_keywords'] ?? [],
|
||||
] );
|
||||
$ideas[] = new PRAutoBlogger_Article_Idea(
|
||||
array(
|
||||
'topic' => $result->get_topic(),
|
||||
'article_type' => $this->map_type( $result->get_analysis_type() ),
|
||||
'suggested_title' => $metadata['suggested_title'] ?? $result->get_topic(),
|
||||
'summary' => $result->get_summary() ?? '',
|
||||
'score' => $this->compute_score( $result ),
|
||||
'analysis_id' => $result->get_id(),
|
||||
'source_ids' => $result->get_source_ids(),
|
||||
'key_points' => $metadata['key_points'] ?? array(),
|
||||
'target_keywords' => $metadata['target_keywords'] ?? array(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
@@ -116,9 +120,12 @@ class PRAutoBlogger_Idea_Scorer {
|
||||
'scorer'
|
||||
);
|
||||
|
||||
usort( $ideas, static function ( PRAutoBlogger_Article_Idea $a, PRAutoBlogger_Article_Idea $b ): int {
|
||||
return $b->get_score() <=> $a->get_score();
|
||||
} );
|
||||
usort(
|
||||
$ideas,
|
||||
static function ( PRAutoBlogger_Article_Idea $a, PRAutoBlogger_Article_Idea $b ): int {
|
||||
return $b->get_score() <=> $a->get_score();
|
||||
}
|
||||
);
|
||||
|
||||
return array_slice( $ideas, 0, $target_count );
|
||||
}
|
||||
@@ -165,27 +172,122 @@ class PRAutoBlogger_Idea_Scorer {
|
||||
* @return string[] Unique lowercase keywords.
|
||||
*/
|
||||
private function extract_meaningful_keywords( string $text ): array {
|
||||
static $stopwords = [
|
||||
'a', 'an', 'the', 'and', 'or', 'but', 'is', 'are', 'was', 'were',
|
||||
'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'from', 'as',
|
||||
'it', 'its', 'this', 'that', 'your', 'you', 'how', 'what', 'when',
|
||||
'why', 'where', 'which', 'who', 'do', 'does', 'did', 'not', 'no',
|
||||
'can', 'will', 'should', 'could', 'would', 'may', 'might', 'have',
|
||||
'has', 'had', 'be', 'been', 'being', 'about', 'into', 'through',
|
||||
'during', 'before', 'after', 'above', 'below', 'between', 'same',
|
||||
'up', 'down', 'out', 'off', 'over', 'under', 'again', 'then',
|
||||
'here', 'there', 'all', 'each', 'every', 'both', 'few', 'more',
|
||||
'most', 'other', 'some', 'such', 'than', 'too', 'very', 'just',
|
||||
'also', 'only', 'own', 'so', 'if', 'while', 'because', 'until',
|
||||
'vs', 'versus', 'guide', 'complete', 'ultimate', 'best', 'top',
|
||||
'new', 'first', 'need', 'know', 'everything',
|
||||
];
|
||||
static $stopwords = array(
|
||||
'a',
|
||||
'an',
|
||||
'the',
|
||||
'and',
|
||||
'or',
|
||||
'but',
|
||||
'is',
|
||||
'are',
|
||||
'was',
|
||||
'were',
|
||||
'in',
|
||||
'on',
|
||||
'at',
|
||||
'to',
|
||||
'for',
|
||||
'of',
|
||||
'with',
|
||||
'by',
|
||||
'from',
|
||||
'as',
|
||||
'it',
|
||||
'its',
|
||||
'this',
|
||||
'that',
|
||||
'your',
|
||||
'you',
|
||||
'how',
|
||||
'what',
|
||||
'when',
|
||||
'why',
|
||||
'where',
|
||||
'which',
|
||||
'who',
|
||||
'do',
|
||||
'does',
|
||||
'did',
|
||||
'not',
|
||||
'no',
|
||||
'can',
|
||||
'will',
|
||||
'should',
|
||||
'could',
|
||||
'would',
|
||||
'may',
|
||||
'might',
|
||||
'have',
|
||||
'has',
|
||||
'had',
|
||||
'be',
|
||||
'been',
|
||||
'being',
|
||||
'about',
|
||||
'into',
|
||||
'through',
|
||||
'during',
|
||||
'before',
|
||||
'after',
|
||||
'above',
|
||||
'below',
|
||||
'between',
|
||||
'same',
|
||||
'up',
|
||||
'down',
|
||||
'out',
|
||||
'off',
|
||||
'over',
|
||||
'under',
|
||||
'again',
|
||||
'then',
|
||||
'here',
|
||||
'there',
|
||||
'all',
|
||||
'each',
|
||||
'every',
|
||||
'both',
|
||||
'few',
|
||||
'more',
|
||||
'most',
|
||||
'other',
|
||||
'some',
|
||||
'such',
|
||||
'than',
|
||||
'too',
|
||||
'very',
|
||||
'just',
|
||||
'also',
|
||||
'only',
|
||||
'own',
|
||||
'so',
|
||||
'if',
|
||||
'while',
|
||||
'because',
|
||||
'until',
|
||||
'vs',
|
||||
'versus',
|
||||
'guide',
|
||||
'complete',
|
||||
'ultimate',
|
||||
'best',
|
||||
'top',
|
||||
'new',
|
||||
'first',
|
||||
'need',
|
||||
'know',
|
||||
'everything',
|
||||
);
|
||||
|
||||
$text = strtolower( $text );
|
||||
$words = preg_split( '/[^a-z0-9-]+/', $text, -1, PREG_SPLIT_NO_EMPTY );
|
||||
$words = array_filter( $words, static function ( string $w ) use ( $stopwords ): bool {
|
||||
return strlen( $w ) >= 2 && ! in_array( $w, $stopwords, true );
|
||||
} );
|
||||
$words = array_filter(
|
||||
$words,
|
||||
static function ( string $w ) use ( $stopwords ): bool {
|
||||
return strlen( $w ) >= 2 && ! in_array( $w, $stopwords, true );
|
||||
}
|
||||
);
|
||||
|
||||
return array_values( array_unique( $words ) );
|
||||
}
|
||||
@@ -197,13 +299,13 @@ class PRAutoBlogger_Idea_Scorer {
|
||||
* @return string Article type label.
|
||||
*/
|
||||
private function map_type( string $analysis_type ): string {
|
||||
$map = [
|
||||
$map = array(
|
||||
'question' => 'guide',
|
||||
'complaint' => 'solution',
|
||||
'comparison' => 'comparison',
|
||||
'news' => 'news',
|
||||
'guide' => 'guide',
|
||||
];
|
||||
);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
173
includes/core/class-image-composer-layout.php
Normal file
173
includes/core/class-image-composer-layout.php
Normal file
@@ -0,0 +1,173 @@
|
||||
<?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).
|
||||
*
|
||||
* @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_margin_right' => 40,
|
||||
'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_side_padding' => 64,
|
||||
'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);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Downloads generated images and imports them into the WordPress media library.
|
||||
*
|
||||
* Handles sideloading (importing external images as WordPress attachments),
|
||||
@@ -34,11 +36,13 @@ class PRAutoBlogger_Image_Media_Sideloader {
|
||||
* latency_ms: int,
|
||||
* } $image_data Raw image bytes + metadata from PRAutoBlogger_Image_Provider_Interface.
|
||||
* @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.
|
||||
*/
|
||||
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
|
||||
@@ -61,10 +65,10 @@ class PRAutoBlogger_Image_Media_Sideloader {
|
||||
}
|
||||
|
||||
// Prepare file array for media_handle_sideload().
|
||||
$file_array = [
|
||||
'name' => $this->generate_filename( $image_data['model'], $image_data['width'], $image_data['height'] ),
|
||||
$file_array = array(
|
||||
'name' => $this->generate_filename( $image_data['model'], $image_data['width'], $image_data['height'], $filename_suffix ),
|
||||
'tmp_name' => $temp_file,
|
||||
];
|
||||
);
|
||||
|
||||
// Import the file into media library.
|
||||
$attachment_id = media_handle_sideload( $file_array, $post_id );
|
||||
@@ -108,8 +112,8 @@ class PRAutoBlogger_Image_Media_Sideloader {
|
||||
* @return string|\WP_Error Path to temporary file on success, WP_Error on failure.
|
||||
*/
|
||||
private function create_temp_file( string $bytes, string $mime_type ) {
|
||||
$temp_dir = get_temp_dir();
|
||||
$filename = 'prautoblogger_img_' . wp_generate_uuid4() . $this->get_extension_for_mime( $mime_type );
|
||||
$temp_dir = get_temp_dir();
|
||||
$filename = 'prautoblogger_img_' . wp_generate_uuid4() . $this->get_extension_for_mime( $mime_type );
|
||||
$temp_path = $temp_dir . $filename;
|
||||
|
||||
$written = file_put_contents( $temp_path, $bytes );
|
||||
@@ -133,16 +137,18 @@ class PRAutoBlogger_Image_Media_Sideloader {
|
||||
* @param string $model Model name (e.g., 'flux-1-schnell').
|
||||
* @param int $width Image width.
|
||||
* @param int $height Image height.
|
||||
* @param string $suffix Optional role hint (e.g. 'og', 'square'); back-compat default ''.
|
||||
*
|
||||
* @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(
|
||||
'prautoblogger_%s_%dx%d_%s',
|
||||
'prautoblogger_%s_%dx%d_%s%s',
|
||||
sanitize_title( $model ),
|
||||
$width,
|
||||
$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 — most image models output PNG.
|
||||
@@ -156,11 +162,11 @@ class PRAutoBlogger_Image_Media_Sideloader {
|
||||
* @return string File extension with leading dot (e.g., '.png').
|
||||
*/
|
||||
private function get_extension_for_mime( string $mime_type ): string {
|
||||
$map = [
|
||||
$map = array(
|
||||
'image/png' => '.png',
|
||||
'image/jpeg' => '.jpg',
|
||||
'image/jpg' => '.jpg',
|
||||
];
|
||||
);
|
||||
|
||||
return $map[ strtolower( $mime_type ) ] ?? '.png';
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
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
|
||||
@@ -106,15 +108,15 @@ class PRAutoBlogger_Image_NSFW_Retry {
|
||||
'image_pipeline'
|
||||
);
|
||||
|
||||
$retry_request = [
|
||||
$retry_request = array(
|
||||
'prompt' => $fallback['prompt'],
|
||||
'width' => (int) ( $batch_requests[ $key ]['width'] ?? 0 ),
|
||||
'width' => (int) ( $batch_requests[ $key ]['width'] ?? 0 ),
|
||||
'height' => (int) ( $batch_requests[ $key ]['height'] ?? 0 ),
|
||||
'options' => $batch_requests[ $key ]['options'] ?? [],
|
||||
];
|
||||
'options' => $batch_requests[ $key ]['options'] ?? array(),
|
||||
);
|
||||
|
||||
$retry_results = $this->provider->generate_image_batch( [ $key => $retry_request ] );
|
||||
$retry_entry = $retry_results[ $key ] ?? [ 'error' => 'Retry provider returned no result.' ];
|
||||
$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(
|
||||
|
||||
@@ -2,17 +2,23 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Orchestrates image generation for published articles.
|
||||
*
|
||||
* Generates two images per article (A/B): Image A (article-driven) as featured
|
||||
* image, Image B (source-driven) in post meta. Each is independently fallible.
|
||||
* Since v0.17.0 every result passes through the deterministic image composer
|
||||
* (branded variants for A, corner mark for B) before sideloading; composition
|
||||
* degrades gracefully and never blocks a publish.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Pipeline_Runner after publisher creates the post.
|
||||
* Dependencies: Image provider, Prompt builder, Sideloader, Cost tracker, Logger.
|
||||
* Dependencies: Image provider, Prompt builder, Composer, Attacher, Cost tracker, Logger.
|
||||
*
|
||||
* @see core/class-pipeline-runner.php — Calls this after publisher.
|
||||
* @see core/class-image-prompt-builder.php — Prompt generation (scene + caption).
|
||||
* @see core/class-image-media-sideloader.php — Media library integration.
|
||||
* @see core/class-image-composer.php — Deterministic variant composition.
|
||||
* @see core/class-image-attacher.php — Sideloading + variant meta-linking.
|
||||
*/
|
||||
class PRAutoBlogger_Image_Pipeline {
|
||||
|
||||
@@ -32,25 +38,36 @@ class PRAutoBlogger_Image_Pipeline {
|
||||
private PRAutoBlogger_Image_Media_Sideloader $sideloader;
|
||||
/** @var PRAutoBlogger_Cost_Tracker Logs image generation spend. */
|
||||
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.
|
||||
*
|
||||
* When no provider is injected, the constructor reads the
|
||||
* `prautoblogger_image_provider` setting and instantiates the
|
||||
* matching concrete class ('runware' or 'openrouter').
|
||||
* 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 override.
|
||||
* @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(
|
||||
?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 ?? self::create_default_provider();
|
||||
$this->prompt_builder = new PRAutoBlogger_Image_Prompt_Builder();
|
||||
$this->sideloader = new PRAutoBlogger_Image_Media_Sideloader();
|
||||
$this->cost_tracker = $cost_tracker ?? new PRAutoBlogger_Cost_Tracker();
|
||||
$this->provider = $provider ?? self::create_default_provider();
|
||||
$this->prompt_builder = new PRAutoBlogger_Image_Prompt_Builder( $trace_context );
|
||||
$this->sideloader = new PRAutoBlogger_Image_Media_Sideloader();
|
||||
$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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,7 +102,10 @@ class PRAutoBlogger_Image_Pipeline {
|
||||
array $article_data,
|
||||
?array $source_data = null
|
||||
): array {
|
||||
$result = [ 'cost_usd' => 0.0, 'errors' => [] ];
|
||||
$result = array(
|
||||
'cost_usd' => 0.0,
|
||||
'errors' => array(),
|
||||
);
|
||||
|
||||
if ( ! get_option( 'prautoblogger_image_enabled' ) ) {
|
||||
PRAutoBlogger_Logger::instance()->info( 'Image generation disabled in settings.', 'image_pipeline' );
|
||||
@@ -105,49 +125,69 @@ class PRAutoBlogger_Image_Pipeline {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// v0.18.0 — per-run governor: reserve the curl_multi batch's SUMMED
|
||||
// worst-case estimate before dispatch (no-op outside a governed
|
||||
// run). A breach halts the run and skips the whole batch.
|
||||
try {
|
||||
$batch_reservation = PRAutoBlogger_Cost_Governor::open_amount_reservation( $estimated_cost, 'image_batch' );
|
||||
} catch ( PRAutoBlogger_Cost_Ceiling_Exception $e ) {
|
||||
$result['errors'][] = $e->getMessage();
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Build all prompts upfront (LLM calls happen here, sequentially).
|
||||
$article_prompt = $this->prompt_builder->build_article_prompt( $article_data );
|
||||
$batch_requests = [
|
||||
'image_a' => [
|
||||
$batch_requests = array(
|
||||
'image_a' => array(
|
||||
'prompt' => $article_prompt['prompt'],
|
||||
'width' => self::DEFAULT_WIDTH,
|
||||
'height' => self::DEFAULT_HEIGHT,
|
||||
],
|
||||
];
|
||||
$captions = [ 'image_a' => $article_prompt['caption'] ];
|
||||
),
|
||||
);
|
||||
$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'] = [
|
||||
$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'];
|
||||
);
|
||||
$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_image_a( $post_id, $batch_results, $captions, $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_image_b( $post_id, $batch_results, $captions, $result );
|
||||
$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 ),
|
||||
@@ -159,140 +199,98 @@ class PRAutoBlogger_Image_Pipeline {
|
||||
}
|
||||
|
||||
/**
|
||||
* Process Image A batch result: sideload, set featured image, insert caption.
|
||||
* Process one slot's batch result: compose, sideload, link to the post.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param array $batch_results Keyed results from generate_image_batch().
|
||||
* @param array $captions Keyed captions from prompt builder.
|
||||
* @param array $result Pipeline result array (modified by reference).
|
||||
* 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 array $batch_results Keyed results from generate_image_batch().
|
||||
* @param array $captions Keyed captions from prompt builder.
|
||||
* @param string $title Post title (composer context).
|
||||
* @param array $result Pipeline result array (modified by reference).
|
||||
*/
|
||||
private function process_image_a( int $post_id, array $batch_results, array $captions, array &$result ): void {
|
||||
$image_data = $batch_results['image_a'] ?? null;
|
||||
private function process_slot( string $slot, int $post_id, array $batch_results, array $captions, string $title, array &$result ): void {
|
||||
$label = 'image_a' === $slot ? 'Image A' : 'Image B';
|
||||
$image_data = $batch_results[ $slot ] ?? null;
|
||||
|
||||
if ( ! $image_data || isset( $image_data['error'] ) ) {
|
||||
$err_msg = $image_data['error'] ?? 'Image A missing from batch results.';
|
||||
$err_msg = $image_data['error'] ?? sprintf( '%s missing from batch results.', $label );
|
||||
$result['errors'][] = $err_msg;
|
||||
// Log at WARNING so the path is visible even if the outer catch in the
|
||||
// caller doesn't fire. See thread image-mime-bug Issue 2.
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf( 'Image A generation failed for post %d: %s', $post_id, $err_msg ),
|
||||
sprintf( '%s generation failed for post %d: %s', $label, $post_id, $err_msg ),
|
||||
'image_pipeline'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
$attachment_id = $this->sideload_and_log( $image_data, $post_id, 'image_a', 'Image A' );
|
||||
$caption = (string) ( $captions[ $slot ] ?? '' );
|
||||
$variants = $this->compose_variants( $image_data, $post_id, $caption, $title, $slot );
|
||||
$featured = array_merge( $image_data, array_shift( $variants ) );
|
||||
|
||||
$attachment_id = $this->attacher->sideload_and_log( $featured, $post_id, $slot, $label, $caption );
|
||||
if ( is_wp_error( $attachment_id ) ) {
|
||||
$result['errors'][] = $attachment_id->get_error_message();
|
||||
return;
|
||||
}
|
||||
|
||||
$result['image_a_id'] = $attachment_id;
|
||||
$result['cost_usd'] += $image_data['cost_usd'];
|
||||
set_post_thumbnail( $post_id, $attachment_id );
|
||||
$result[ $slot . '_id' ] = $attachment_id;
|
||||
$result['cost_usd'] += $image_data['cost_usd'];
|
||||
|
||||
if ( '' !== ( $captions['image_a'] ?? '' ) ) {
|
||||
update_post_meta( $attachment_id, '_prautoblogger_image_caption', $captions['image_a'] );
|
||||
$this->prepend_caption_to_post( $post_id, $attachment_id, $captions['image_a'] );
|
||||
if ( '' !== $caption ) {
|
||||
update_post_meta( $attachment_id, '_prautoblogger_image_caption', $caption );
|
||||
}
|
||||
|
||||
if ( 'image_a' === $slot ) {
|
||||
set_post_thumbnail( $post_id, $attachment_id );
|
||||
update_post_meta( $attachment_id, '_prautoblogger_image_role', 'featured' );
|
||||
if ( '' !== $caption ) {
|
||||
$this->attacher->prepend_caption_to_post( $post_id, $attachment_id, $caption );
|
||||
}
|
||||
$this->attacher->attach_variants( $variants, $post_id, $attachment_id, $caption, (string) ( $image_data['model'] ?? 'unknown' ) );
|
||||
} else {
|
||||
update_post_meta( $post_id, '_prautoblogger_image_b_id', $attachment_id );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process Image B batch result: sideload, store meta.
|
||||
* 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 $batch_results Keyed results from generate_image_batch().
|
||||
* @param array $captions Keyed captions from prompt builder.
|
||||
* @param array $result Pipeline result array (modified by reference).
|
||||
* @param array $image_data Provider result for one slot.
|
||||
* @param int $post_id Post ID (composer context).
|
||||
* @param string $caption Editorial caption (composer context).
|
||||
* @param string $title Post title (composer context).
|
||||
* @param string $slot 'image_a' or 'image_b'.
|
||||
* @return array<int, array<string, mixed>> Featured-first variant list (never empty).
|
||||
*/
|
||||
private function process_image_b( int $post_id, array $batch_results, array $captions, array &$result ): void {
|
||||
$image_data = $batch_results['image_b'] ?? null;
|
||||
if ( ! $image_data || isset( $image_data['error'] ) ) {
|
||||
$err_msg = $image_data['error'] ?? 'Image B missing from batch results.';
|
||||
$result['errors'][] = $err_msg;
|
||||
// Log at WARNING so the path is visible even if the outer catch in the
|
||||
// caller doesn't fire. See thread image-mime-bug Issue 2.
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf( 'Image B generation failed for post %d: %s', $post_id, $err_msg ),
|
||||
'image_pipeline'
|
||||
private function compose_variants( array $image_data, int $post_id, string $caption, string $title, string $slot ): array {
|
||||
try {
|
||||
$variants = $this->composer->compose(
|
||||
$image_data,
|
||||
array(
|
||||
'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'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
$attachment_id = $this->sideload_and_log( $image_data, $post_id, 'image_b', 'Image B' );
|
||||
if ( is_wp_error( $attachment_id ) ) {
|
||||
$result['errors'][] = $attachment_id->get_error_message();
|
||||
return;
|
||||
}
|
||||
|
||||
$result['image_b_id'] = $attachment_id;
|
||||
$result['cost_usd'] += $image_data['cost_usd'];
|
||||
update_post_meta( $post_id, '_prautoblogger_image_b_id', $attachment_id );
|
||||
|
||||
if ( '' !== ( $captions['image_b'] ?? '' ) ) {
|
||||
update_post_meta( $attachment_id, '_prautoblogger_image_caption', $captions['image_b'] );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sideload image bytes into the media library and log the cost.
|
||||
*
|
||||
* @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.
|
||||
* @return int|\WP_Error Attachment ID on success.
|
||||
*/
|
||||
private function sideload_and_log( array $image_data, int $post_id, string $slot, string $label ) {
|
||||
$attachment_id = $this->sideloader->sideload_image(
|
||||
$image_data,
|
||||
$post_id,
|
||||
substr( $image_data['model'] ?? 'image', 0, 100 )
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend the comic caption as styled text at the top of the post content.
|
||||
*
|
||||
* 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 (for meta storage only).
|
||||
* @param string $caption Caption text (the comic punchline).
|
||||
*/
|
||||
private 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( [
|
||||
'ID' => $post_id,
|
||||
'post_content' => $caption_html . "\n\n" . $post->post_content,
|
||||
] );
|
||||
return array( array_merge( $image_data, array( 'role' => 'featured' ) ) );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,20 @@
|
||||
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.
|
||||
*
|
||||
* Uses a cheap LLM (via OpenRouter) to distill article content into concise
|
||||
* visual scene descriptions optimised for image generation. Falls back to rule-based
|
||||
* Uses a cheap LLM (via OpenRouter) to distill article content into a concise
|
||||
* 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.
|
||||
* Dependencies: PRAutoBlogger_OpenRouter_Provider (LLM call),
|
||||
* PRAutoBlogger_Image_Template_Filler (template substitution),
|
||||
* PRAutoBlogger_Cost_Tracker (logs prompt-rewrite cost),
|
||||
* PRAutoBlogger_Logger (diagnostics).
|
||||
*
|
||||
@@ -25,34 +30,39 @@ class PRAutoBlogger_Image_Prompt_Builder {
|
||||
* 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 are a comedy writer and single-panel cartoon creator, like Gary Larson (The Far Side) meets science humor.
|
||||
You write subject descriptions for a text-free editorial science illustration about peptides, supplements, or biohacking.
|
||||
|
||||
Given an article title and summary about peptides, supplements, or biohacking, create a SINGLE-PANEL COMIC concept. Output TWO parts separated by a blank line:
|
||||
Given an article title and summary, output TWO parts separated by a blank line:
|
||||
|
||||
SCENE: One sentence describing the visual gag — a funny, absurd, or ironic situation related to the article's topic. Favor CONCRETE, HUMAN-SCALE scenes: people reacting to things, doctors and patients, scientists in a lab, gym bros with peptide vials, partners at a kitchen table, ordinary objects in ordinary rooms. Keep it simple — one clear visual joke, 1-3 characters max.
|
||||
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, punchy caption or speech bubble line (under 15 words) that delivers the punchline. The humor should be dry, nerdy, and accessible — the reader should chuckle even if they only half-understand the science.
|
||||
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 joke MUST relate to the article's actual subject matter, not generic science humor
|
||||
- DO NOT personify molecules, peptides, hormones, proteins, or other chemical structures (no "anthropomorphic molecule", no "cartoon hormone"). Image models render anthropomorphized chemistry as incoherent blobs. If the article is about a substance, show a PERSON reacting to or using the substance; if the article is abstract (a mechanism, a process, a concept), fall back on a human observer reacting to its effect.
|
||||
- Characters can have simple cartoon faces (round heads, dot eyes, expressive eyebrows)
|
||||
- Keep the scene physically simple — few objects, clear staging, easy to read at thumbnail size
|
||||
- The caption should work as a standalone joke even without the image
|
||||
- No logos, watermarks, or branding
|
||||
- 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 muscular gym bro stares, deflated, at a tiny glass vial in his hand while his friend squats a barbell effortlessly in the background.
|
||||
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.
|
||||
|
||||
"They told me the peptide did the work, not the squats."
|
||||
A concentrated look at how the compound is prepared before use.
|
||||
PROMPT;
|
||||
|
||||
/**
|
||||
* Max tokens for the rewriter response. Comic concepts need more room
|
||||
* for the scene description plus the caption punchline.
|
||||
* 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;
|
||||
|
||||
@@ -63,12 +73,29 @@ PROMPT;
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* Tries LLM rewriting first; falls back to rule-based synthesis on
|
||||
* failure. Splits scene (for image gen) from caption (HTML below the
|
||||
* image) and appends the style suffix.
|
||||
* failure. Splits scene (the topic summary, for image gen) from caption
|
||||
* (HTML below the image) and substitutes the scene into the editorial
|
||||
* style template's {{ topic_summary }} slot.
|
||||
*
|
||||
* @param array{post_title?: string, post_content?: string, suggested_title?: string} $article_data
|
||||
* @return array{prompt: string, caption: string}
|
||||
@@ -76,16 +103,14 @@ PROMPT;
|
||||
public function build_article_prompt( array $article_data ): array {
|
||||
$title = $article_data['post_title'] ?? $article_data['suggested_title'] ?? 'Product';
|
||||
$content = $article_data['post_content'] ?? '';
|
||||
$first_para = $this->extract_first_paragraph( $content );
|
||||
$first_para = PRAutoBlogger_Image_Scene_Parser::extract_first_paragraph( $content );
|
||||
|
||||
$parsed = $this->rewrite_via_llm( $title, $first_para );
|
||||
|
||||
$style_suffix = get_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
|
||||
|
||||
return [
|
||||
'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ),
|
||||
return array(
|
||||
'prompt' => PRAutoBlogger_Image_Template_Filler::fill( $parsed['scene'] ),
|
||||
'caption' => $parsed['caption'],
|
||||
];
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,17 +122,15 @@ PROMPT;
|
||||
*/
|
||||
public function build_source_prompt( array $source_data ): array {
|
||||
$title = $source_data['title'] ?? 'Reddit Discussion';
|
||||
$comments = $source_data['comments'] ?? [];
|
||||
$comments = $source_data['comments'] ?? array();
|
||||
$context = is_array( $comments ) && ! empty( $comments ) ? $comments[0] : '';
|
||||
|
||||
$parsed = $this->rewrite_via_llm( $title, $context );
|
||||
|
||||
$style_suffix = get_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
|
||||
|
||||
return [
|
||||
'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ),
|
||||
return array(
|
||||
'prompt' => PRAutoBlogger_Image_Template_Filler::fill( $parsed['scene'] ),
|
||||
'caption' => $parsed['caption'],
|
||||
];
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,21 +163,45 @@ PROMPT;
|
||||
$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(
|
||||
[
|
||||
[ 'role' => 'system', 'content' => $system ],
|
||||
[ 'role' => 'user', 'content' => $user_message ],
|
||||
],
|
||||
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',
|
||||
)
|
||||
);
|
||||
|
||||
$raw = trim( $result['content'] ?? '' );
|
||||
@@ -180,8 +227,24 @@ PROMPT;
|
||||
'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 $this->parse_scene_and_caption( $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.
|
||||
@@ -191,58 +254,7 @@ PROMPT;
|
||||
);
|
||||
}
|
||||
|
||||
return $this->synthesize_visual_concepts_fallback( $title, $context );
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}
|
||||
*/
|
||||
private 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 [ 'scene' => $scene, 'caption' => $caption ];
|
||||
}
|
||||
|
||||
// No blank-line separator found — treat entire response as scene.
|
||||
return [ '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.
|
||||
*/
|
||||
private 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;
|
||||
return PRAutoBlogger_Image_Scene_Parser::synthesize_visual_concepts_fallback( $title, $context );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -254,34 +266,11 @@ PROMPT;
|
||||
* @return array{prompt: string, caption: string}
|
||||
*/
|
||||
public function build_fallback_prompt( string $title ): array {
|
||||
$parsed = $this->synthesize_visual_concepts_fallback( $title, '' );
|
||||
$style_suffix = get_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
|
||||
return [
|
||||
'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ),
|
||||
$parsed = PRAutoBlogger_Image_Scene_Parser::synthesize_visual_concepts_fallback( $title, '' );
|
||||
return array(
|
||||
'prompt' => PRAutoBlogger_Image_Template_Filler::fill( $parsed['scene'] ),
|
||||
'caption' => $parsed['caption'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rule-based fallback when LLM rewriting is unavailable.
|
||||
*
|
||||
* Kept deliberately simple — this only runs if OpenRouter is down.
|
||||
*
|
||||
* @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.
|
||||
*/
|
||||
private function synthesize_visual_concepts_fallback( string $title, string $supporting_text ): array {
|
||||
// Fallback produces a simple comic concept when the LLM is unavailable.
|
||||
$scene = sprintf(
|
||||
'A cartoon scientist in a lab coat looking bewildered while examining something related to: %s.',
|
||||
$title
|
||||
);
|
||||
|
||||
return [
|
||||
'scene' => trim( $scene ),
|
||||
'caption' => 'Science is full of surprises.',
|
||||
];
|
||||
}
|
||||
|
||||
/** Lazy-load the OpenRouter provider. */
|
||||
|
||||
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' => '',
|
||||
);
|
||||
}
|
||||
}
|
||||
185
includes/core/class-image-template-filler.php
Normal file
185
includes/core/class-image-template-filler.php
Normal file
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Fills the admin-editable editorial image-prompt template with the
|
||||
* rewriter-produced topic summary, replacing the old "scene + style suffix"
|
||||
* concatenation.
|
||||
*
|
||||
* The template (option `prautoblogger_image_style_template`, default
|
||||
* PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE) contains exactly one
|
||||
* `{{ topic_summary }}` token. This class:
|
||||
* - resolves the active template (admin override wins; blank → default),
|
||||
* - sanitises and length-clamps the topic summary (brief A5),
|
||||
* - substitutes the token, and
|
||||
* - degrades safely (brief A5/A6) so a blank or token-only prompt is never
|
||||
* emitted to the image provider.
|
||||
*
|
||||
* Who calls it: PRAutoBlogger_Image_Prompt_Builder (static call from each of
|
||||
* build_article_prompt / build_source_prompt / build_fallback_prompt).
|
||||
* Dependencies: PRAutoBlogger_Logger (diagnostics only).
|
||||
*
|
||||
* @see core/class-image-prompt-builder.php — Sole caller.
|
||||
* @see ARCHITECTURE.md — Image generation data flow.
|
||||
*/
|
||||
class PRAutoBlogger_Image_Template_Filler {
|
||||
|
||||
/** The single substitution token the template must contain. */
|
||||
public const TOKEN = '{{ topic_summary }}';
|
||||
|
||||
/**
|
||||
* Max characters retained from the rewriter topic summary before it is
|
||||
* substituted into the template. Keeps the final provider prompt bounded
|
||||
* even if the rewriter ignores its length guidance.
|
||||
*/
|
||||
public const MAX_SUMMARY_CHARS = 320;
|
||||
|
||||
/**
|
||||
* Build the final image-generation prompt from a rewriter scene.
|
||||
*
|
||||
* @param string $scene Raw topic/mechanism summary from the rewriter (the
|
||||
* "scene" half of the scene/caption contract).
|
||||
* @return string Provider-ready prompt; never empty.
|
||||
*/
|
||||
public static function fill( string $scene ): string {
|
||||
$template = self::resolve_template();
|
||||
$summary = self::sanitize_summary( $scene );
|
||||
|
||||
// Brief A6: never ship a blank or token-only prompt. If the rewriter
|
||||
// produced nothing usable, return the template with the token removed
|
||||
// only as a last resort — but the caller guards this by passing a
|
||||
// rule-based fallback scene, so an empty summary here is unexpected.
|
||||
if ( '' === $summary ) {
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
'Editorial prompt fill received an empty topic summary; emitting style-only prompt.',
|
||||
'image_template_filler'
|
||||
);
|
||||
return self::strip_token( $template );
|
||||
}
|
||||
|
||||
$token_count = substr_count( $template, self::TOKEN );
|
||||
|
||||
if ( 1 === $token_count ) {
|
||||
return trim( str_replace( self::TOKEN, $summary, $template ) );
|
||||
}
|
||||
|
||||
// Brief A5: admin removed or duplicated the token. Fall back to
|
||||
// appending the summary to the style text and log a warning rather
|
||||
// than shipping a malformed prompt.
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf(
|
||||
'Image style template has %d "%s" tokens (expected 1); appending summary instead of substituting.',
|
||||
$token_count,
|
||||
self::TOKEN
|
||||
),
|
||||
'image_template_filler'
|
||||
);
|
||||
|
||||
$base = self::strip_token( $template );
|
||||
return trim( $base . ' ' . $summary );
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitise and validate the admin-submitted style template on save.
|
||||
*
|
||||
* Multi-line, so sanitised with sanitize_textarea_field() (not
|
||||
* sanitize_text_field(), which would strip newlines). Per brief A5 the
|
||||
* template must contain exactly one TOKEN; otherwise the previous value is
|
||||
* kept and a settings error is surfaced. A blank submission falls back to
|
||||
* the editorial default so image generation can never be bricked.
|
||||
*
|
||||
* @param string $value Submitted template.
|
||||
* @return string Sanitised, validated template.
|
||||
*/
|
||||
public static function sanitize_for_save( string $value ): string {
|
||||
$clean = sanitize_textarea_field( $value );
|
||||
|
||||
if ( '' === trim( $clean ) ) {
|
||||
return PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE;
|
||||
}
|
||||
|
||||
$token_count = substr_count( $clean, self::TOKEN );
|
||||
if ( 1 !== $token_count ) {
|
||||
add_settings_error(
|
||||
'prautoblogger_image_style_template',
|
||||
'prautoblogger_image_style_template_token',
|
||||
sprintf(
|
||||
/* translators: 1: required token, 2: number of tokens found. */
|
||||
esc_html__( 'The Style Template must contain exactly one %1$s token (found %2$d). Keeping the previous value.', 'prautoblogger' ),
|
||||
esc_html( self::TOKEN ),
|
||||
(int) $token_count
|
||||
)
|
||||
);
|
||||
return (string) get_option( 'prautoblogger_image_style_template', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE );
|
||||
}
|
||||
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active template: admin override wins; blank → default.
|
||||
*
|
||||
* @return string Non-empty template string.
|
||||
*/
|
||||
private static function resolve_template(): string {
|
||||
$override = (string) get_option( 'prautoblogger_image_style_template', '' );
|
||||
if ( '' !== trim( $override ) ) {
|
||||
return $override;
|
||||
}
|
||||
return PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip control characters and clamp length of the rewriter summary
|
||||
* (brief A5). Whitespace runs (including newlines) are collapsed to single
|
||||
* spaces so the summary sits cleanly inside the template.
|
||||
*
|
||||
* @param string $scene Raw rewriter scene.
|
||||
* @return string Sanitised, length-clamped summary (possibly empty).
|
||||
*/
|
||||
private static function sanitize_summary( string $scene ): string {
|
||||
// Remove control characters (C0/C1) except via whitespace collapse below.
|
||||
$clean = preg_replace( '/[\x00-\x1F\x7F]+/u', ' ', $scene );
|
||||
if ( null === $clean ) {
|
||||
// preg_replace can return null on bad UTF-8; degrade conservatively.
|
||||
$clean = preg_replace( '/[\x00-\x1F\x7F]+/', ' ', $scene );
|
||||
$clean = is_string( $clean ) ? $clean : '';
|
||||
}
|
||||
|
||||
// Collapse whitespace runs to single spaces.
|
||||
$collapsed = preg_replace( '/\s+/u', ' ', $clean );
|
||||
$clean = is_string( $collapsed ) ? $collapsed : $clean;
|
||||
$clean = trim( $clean );
|
||||
|
||||
if ( '' === $clean ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Clamp length (multibyte-aware where available).
|
||||
if ( function_exists( 'mb_strlen' ) ) {
|
||||
if ( mb_strlen( $clean ) > self::MAX_SUMMARY_CHARS ) {
|
||||
$clean = rtrim( mb_substr( $clean, 0, self::MAX_SUMMARY_CHARS ) ) . '...';
|
||||
}
|
||||
} elseif ( strlen( $clean ) > self::MAX_SUMMARY_CHARS ) {
|
||||
$clean = rtrim( substr( $clean, 0, self::MAX_SUMMARY_CHARS ) ) . '...';
|
||||
}
|
||||
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove every occurrence of the token from a template, tidying the
|
||||
* leftover whitespace so the residual style text is still well-formed.
|
||||
*
|
||||
* @param string $template Template possibly containing the token.
|
||||
* @return string Token-free, trimmed template.
|
||||
*/
|
||||
private static function strip_token( string $template ): string {
|
||||
$stripped = str_replace( self::TOKEN, '', $template );
|
||||
$stripped = preg_replace( '/[ \t]+/', ' ', $stripped );
|
||||
$stripped = is_string( $stripped ) ? $stripped : $template;
|
||||
return trim( $stripped );
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Extracts valid JSON from potentially dirty LLM response strings.
|
||||
*
|
||||
* Many models ignore the `response_format: json_object` instruction and wrap
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Structured logging with configurable verbosity levels.
|
||||
*
|
||||
* Writes log entries to a custom database table and optionally forwards
|
||||
@@ -26,19 +28,19 @@ class PRAutoBlogger_Logger {
|
||||
public const LEVEL_INFO = 2;
|
||||
public const LEVEL_DEBUG = 3;
|
||||
|
||||
private const LEVEL_MAP = [
|
||||
private const LEVEL_MAP = array(
|
||||
'error' => self::LEVEL_ERROR,
|
||||
'warning' => self::LEVEL_WARNING,
|
||||
'info' => self::LEVEL_INFO,
|
||||
'debug' => self::LEVEL_DEBUG,
|
||||
];
|
||||
);
|
||||
|
||||
private const LEVEL_LABELS = [
|
||||
private const LEVEL_LABELS = array(
|
||||
self::LEVEL_ERROR => 'error',
|
||||
self::LEVEL_WARNING => 'warning',
|
||||
self::LEVEL_INFO => 'info',
|
||||
self::LEVEL_DEBUG => 'debug',
|
||||
];
|
||||
);
|
||||
|
||||
/** Singleton instance. */
|
||||
private static ?self $instance = null;
|
||||
@@ -66,7 +68,7 @@ class PRAutoBlogger_Logger {
|
||||
* @param string $context Origin class or pipeline stage.
|
||||
* @param array<string, mixed> $meta Optional structured data.
|
||||
*/
|
||||
public function error( string $message, string $context = '', array $meta = [] ): void {
|
||||
public function error( string $message, string $context = '', array $meta = array() ): void {
|
||||
$this->log( self::LEVEL_ERROR, $message, $context, $meta );
|
||||
}
|
||||
|
||||
@@ -77,7 +79,7 @@ class PRAutoBlogger_Logger {
|
||||
* @param string $context
|
||||
* @param array<string, mixed> $meta
|
||||
*/
|
||||
public function warning( string $message, string $context = '', array $meta = [] ): void {
|
||||
public function warning( string $message, string $context = '', array $meta = array() ): void {
|
||||
$this->log( self::LEVEL_WARNING, $message, $context, $meta );
|
||||
}
|
||||
|
||||
@@ -88,7 +90,7 @@ class PRAutoBlogger_Logger {
|
||||
* @param string $context
|
||||
* @param array<string, mixed> $meta
|
||||
*/
|
||||
public function info( string $message, string $context = '', array $meta = [] ): void {
|
||||
public function info( string $message, string $context = '', array $meta = array() ): void {
|
||||
$this->log( self::LEVEL_INFO, $message, $context, $meta );
|
||||
}
|
||||
|
||||
@@ -99,7 +101,7 @@ class PRAutoBlogger_Logger {
|
||||
* @param string $context
|
||||
* @param array<string, mixed> $meta
|
||||
*/
|
||||
public function debug( string $message, string $context = '', array $meta = [] ): void {
|
||||
public function debug( string $message, string $context = '', array $meta = array() ): void {
|
||||
$this->log( self::LEVEL_DEBUG, $message, $context, $meta );
|
||||
}
|
||||
|
||||
@@ -134,13 +136,16 @@ class PRAutoBlogger_Logger {
|
||||
$table = $wpdb->prefix . 'prautoblogger_event_log';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->insert( $table, [
|
||||
'level' => $label,
|
||||
'context' => substr( $context, 0, 100 ),
|
||||
'message' => substr( $message, 0, 2000 ),
|
||||
'meta_json' => ! empty( $meta ) ? wp_json_encode( $meta ) : null,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
] );
|
||||
$wpdb->insert(
|
||||
$table,
|
||||
array(
|
||||
'level' => $label,
|
||||
'context' => substr( $context, 0, 100 ),
|
||||
'message' => substr( $message, 0, 2000 ),
|
||||
'meta_json' => ! empty( $meta ) ? wp_json_encode( $meta ) : null,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,8 +166,8 @@ class PRAutoBlogger_Logger {
|
||||
): array {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'prautoblogger_event_log';
|
||||
$where = [];
|
||||
$params = [];
|
||||
$where = array();
|
||||
$params = array();
|
||||
|
||||
if ( 'all' !== $level_filter && isset( self::LEVEL_MAP[ $level_filter ] ) ) {
|
||||
$where[] = 'level = %s';
|
||||
@@ -181,10 +186,10 @@ class PRAutoBlogger_Logger {
|
||||
|
||||
// Total count.
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$total = (int) $wpdb->get_var(
|
||||
empty( $params )
|
||||
? "SELECT COUNT(*) FROM {$table} {$where_sql}"
|
||||
: $wpdb->prepare( "SELECT COUNT(*) FROM {$table} {$where_sql}", ...$params )
|
||||
$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
|
||||
);
|
||||
|
||||
// Rows.
|
||||
@@ -194,14 +199,17 @@ class PRAutoBlogger_Logger {
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
"SELECT * FROM {$table} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d",
|
||||
...$query_params
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return [ 'rows' => $rows ?: [], 'total' => $total ];
|
||||
return array(
|
||||
'rows' => $rows ?? array(),
|
||||
'total' => $total,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -216,7 +224,7 @@ class PRAutoBlogger_Logger {
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
return (int) $wpdb->query(
|
||||
$wpdb->prepare(
|
||||
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
"DELETE FROM {$table} WHERE created_at < %s",
|
||||
gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) )
|
||||
)
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Collects post performance metrics from WordPress native data and Google Analytics 4.
|
||||
*
|
||||
* Runs on a separate cron schedule (every 6 hours) to gather pageviews, bounce rate,
|
||||
@@ -35,8 +37,8 @@ class PRAutoBlogger_Metrics_Collector {
|
||||
}
|
||||
|
||||
$ga4_client = new PRAutoBlogger_GA4_Client();
|
||||
$ga4_data = $ga4_client->fetch_data( $posts );
|
||||
$scored = 0;
|
||||
$ga4_data = $ga4_client->fetch_data( $posts );
|
||||
$scored = 0;
|
||||
|
||||
foreach ( $posts as $post ) {
|
||||
$post_id = (int) $post->ID;
|
||||
@@ -47,11 +49,11 @@ class PRAutoBlogger_Metrics_Collector {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ga_metrics = $ga4_data[ $post_id ] ?? [];
|
||||
$ga_metrics = $ga4_data[ $post_id ] ?? array();
|
||||
|
||||
$score = $this->compute_composite_score( $wp_metrics, $ga_metrics, $post );
|
||||
$this->store_score( $post_id, $wp_metrics, $ga_metrics, $score );
|
||||
$scored++;
|
||||
++$scored;
|
||||
}
|
||||
|
||||
PRAutoBlogger_Logger::instance()->info( sprintf( 'Metrics collected for %d posts.', $scored ), 'metrics' );
|
||||
@@ -64,15 +66,17 @@ class PRAutoBlogger_Metrics_Collector {
|
||||
* @return \WP_Post[]
|
||||
*/
|
||||
private function get_generated_posts(): array {
|
||||
$query = new \WP_Query( [
|
||||
'post_type' => 'post',
|
||||
'post_status' => 'publish',
|
||||
'meta_key' => '_prautoblogger_generated',
|
||||
'meta_value' => '1',
|
||||
'posts_per_page' => 100,
|
||||
'orderby' => 'date',
|
||||
'order' => 'DESC',
|
||||
] );
|
||||
$query = new \WP_Query(
|
||||
array(
|
||||
'post_type' => 'post',
|
||||
'post_status' => 'publish',
|
||||
'meta_key' => '_prautoblogger_generated',
|
||||
'meta_value' => '1',
|
||||
'posts_per_page' => 100,
|
||||
'orderby' => 'date',
|
||||
'order' => 'DESC',
|
||||
)
|
||||
);
|
||||
|
||||
return $query->posts;
|
||||
}
|
||||
@@ -93,10 +97,10 @@ class PRAutoBlogger_Metrics_Collector {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
return array(
|
||||
'comment_count' => (int) ( $post->comment_count ?? 0 ),
|
||||
'days_published' => max( 1, (int) ( ( time() - strtotime( $post->post_date ) ) / DAY_IN_SECONDS ) ),
|
||||
];
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -110,10 +114,10 @@ class PRAutoBlogger_Metrics_Collector {
|
||||
* @return array{score: float, factors: array<string, float>}
|
||||
*/
|
||||
private function compute_composite_score( array $wp_metrics, array $ga_metrics, \WP_Post $post ): array {
|
||||
$factors = [];
|
||||
$factors = array();
|
||||
|
||||
// Comment engagement (0-25 points).
|
||||
$comments_per_day = $wp_metrics['comment_count'] / max( 1, $wp_metrics['days_published'] );
|
||||
$comments_per_day = $wp_metrics['comment_count'] / max( 1, $wp_metrics['days_published'] );
|
||||
$factors['comments'] = min( 25.0, $comments_per_day * 25.0 );
|
||||
|
||||
// Pageview performance (0-30 points, if GA4 available).
|
||||
@@ -142,10 +146,10 @@ class PRAutoBlogger_Metrics_Collector {
|
||||
|
||||
$total = array_sum( $factors );
|
||||
|
||||
return [
|
||||
return array(
|
||||
'score' => round( $total, 2 ),
|
||||
'factors' => $factors,
|
||||
];
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,16 +166,18 @@ class PRAutoBlogger_Metrics_Collector {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'prautoblogger_content_scores';
|
||||
|
||||
$entry = new PRAutoBlogger_Content_Score( [
|
||||
'post_id' => $post_id,
|
||||
'pageviews' => $ga_metrics['pageviews'] ?? 0,
|
||||
'avg_time_on_page' => $ga_metrics['avg_time_on_page'] ?? 0.0,
|
||||
'bounce_rate' => $ga_metrics['bounce_rate'] ?? 0.0,
|
||||
'comment_count' => $wp_metrics['comment_count'] ?? 0,
|
||||
'composite_score' => $score_data['score'],
|
||||
'score_factors' => $score_data['factors'],
|
||||
'measured_at' => current_time( 'mysql' ),
|
||||
] );
|
||||
$entry = new PRAutoBlogger_Content_Score(
|
||||
array(
|
||||
'post_id' => $post_id,
|
||||
'pageviews' => $ga_metrics['pageviews'] ?? 0,
|
||||
'avg_time_on_page' => $ga_metrics['avg_time_on_page'] ?? 0.0,
|
||||
'bounce_rate' => $ga_metrics['bounce_rate'] ?? 0.0,
|
||||
'comment_count' => $wp_metrics['comment_count'] ?? 0,
|
||||
'composite_score' => $score_data['score'],
|
||||
'score_factors' => $score_data['factors'],
|
||||
'measured_at' => current_time( 'mysql' ),
|
||||
)
|
||||
);
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->insert( $table, $entry->to_db_row() );
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Deterministic post-processor that hyperlinks peptide mentions in HTML content.
|
||||
*
|
||||
* Scans generated article HTML for any peptide name that exists in the PR Core
|
||||
@@ -38,13 +40,19 @@ class PRAutoBlogger_Peptide_Linker {
|
||||
// Build a single regex alternation of all peptide names, longest first
|
||||
// so "BPC-157" matches before a hypothetical "BPC" would.
|
||||
$names = array_keys( $link_map );
|
||||
usort( $names, static function ( string $a, string $b ): int {
|
||||
return strlen( $b ) - strlen( $a );
|
||||
} );
|
||||
usort(
|
||||
$names,
|
||||
static function ( string $a, string $b ): int {
|
||||
return strlen( $b ) - strlen( $a );
|
||||
}
|
||||
);
|
||||
|
||||
$escaped = array_map( static function ( string $name ): string {
|
||||
return preg_quote( $name, '/' );
|
||||
}, $names );
|
||||
$escaped = array_map(
|
||||
static function ( string $name ): string {
|
||||
return preg_quote( $name, '/' );
|
||||
},
|
||||
$names
|
||||
);
|
||||
|
||||
// Word-boundary match, case-insensitive.
|
||||
$pattern = '/\b(' . implode( '|', $escaped ) . ')\b/iu';
|
||||
@@ -63,16 +71,18 @@ class PRAutoBlogger_Peptide_Linker {
|
||||
* @return array<string, string> Name → URL pairs, case-preserved keys.
|
||||
*/
|
||||
private static function build_link_map(): array {
|
||||
$peptides = get_posts( [
|
||||
'post_type' => 'pr_peptide',
|
||||
'post_status' => 'publish',
|
||||
'posts_per_page' => 200,
|
||||
'orderby' => 'title',
|
||||
'order' => 'ASC',
|
||||
'fields' => 'ids',
|
||||
] );
|
||||
$peptides = get_posts(
|
||||
array(
|
||||
'post_type' => 'pr_peptide',
|
||||
'post_status' => 'publish',
|
||||
'posts_per_page' => 200,
|
||||
'orderby' => 'title',
|
||||
'order' => 'ASC',
|
||||
'fields' => 'ids',
|
||||
)
|
||||
);
|
||||
|
||||
$map = [];
|
||||
$map = array();
|
||||
foreach ( $peptides as $peptide_id ) {
|
||||
$title = get_the_title( $peptide_id );
|
||||
$url = get_permalink( $peptide_id );
|
||||
@@ -86,7 +96,7 @@ class PRAutoBlogger_Peptide_Linker {
|
||||
// If the name contains hyphens, also match the space-separated
|
||||
// variant (e.g., "BPC 157" → same URL as "BPC-157").
|
||||
if ( strpos( $title, '-' ) !== false ) {
|
||||
$space_variant = str_replace( '-', ' ', $title );
|
||||
$space_variant = str_replace( '-', ' ', $title );
|
||||
$map[ $space_variant ] = $url;
|
||||
}
|
||||
}
|
||||
@@ -115,7 +125,7 @@ class PRAutoBlogger_Peptide_Linker {
|
||||
}
|
||||
|
||||
// Build a lowercase → URL lookup for case-insensitive matching.
|
||||
$lower_map = [];
|
||||
$lower_map = array();
|
||||
foreach ( $link_map as $name => $url ) {
|
||||
$lower_map[ mb_strtolower( $name ) ] = $url;
|
||||
}
|
||||
|
||||
108
includes/core/class-pipeline-runner.php
Executable file → Normal file
108
includes/core/class-pipeline-runner.php
Executable file → Normal file
@@ -2,6 +2,8 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Executes the content generation pipeline using a chained-job architecture.
|
||||
*
|
||||
* Orchestrates: Budget check → Collect source data → Analyze patterns →
|
||||
@@ -63,7 +65,13 @@ class PRAutoBlogger_Pipeline_Runner {
|
||||
|
||||
$ideas = $this->orchestrate( $cost_tracker );
|
||||
if ( empty( $ideas ) ) {
|
||||
return [ 'generated' => 0, 'published' => 0, 'rejected' => 0, 'cost' => 0.0 ];
|
||||
PRAutoBlogger_Run_State::mark_status( (string) $cost_tracker->get_run_id(), 'done' );
|
||||
return array(
|
||||
'generated' => 0,
|
||||
'published' => 0,
|
||||
'rejected' => 0,
|
||||
'cost' => 0.0,
|
||||
);
|
||||
}
|
||||
|
||||
// Generate article 1 in this process — always fits within time limit.
|
||||
@@ -77,7 +85,8 @@ class PRAutoBlogger_Pipeline_Runner {
|
||||
} else {
|
||||
// Single-article run — amortize research costs now (1 article = full cost).
|
||||
PRAutoBlogger_Post_Assembler::amortize_research_costs( $cost_tracker->get_run_id() );
|
||||
$this->log_summary( $result );
|
||||
PRAutoBlogger_Pipeline_Status::log_summary( $result );
|
||||
PRAutoBlogger_Run_State::mark_status( (string) $cost_tracker->get_run_id(), 'done' );
|
||||
}
|
||||
|
||||
return $result;
|
||||
@@ -108,6 +117,19 @@ class PRAutoBlogger_Pipeline_Runner {
|
||||
'Budget limit reached. Aborting remaining queued articles.',
|
||||
'pipeline'
|
||||
);
|
||||
PRAutoBlogger_Run_State::mark_status( (string) $queue['run_id'], 'failed' );
|
||||
$this->finish_queue();
|
||||
return;
|
||||
}
|
||||
|
||||
// v0.18.0 — a run halted by the per-run cost governor (or failed by
|
||||
// the reaper) must not dispatch its remaining queued articles.
|
||||
$run_status = PRAutoBlogger_Run_State::get_status( $queue['run_id'] );
|
||||
if ( in_array( $run_status, array( 'halted', 'failed' ), true ) ) {
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf( 'Run %s is %s. Aborting remaining queued articles.', $queue['run_id'], $run_status ),
|
||||
'pipeline'
|
||||
);
|
||||
$this->finish_queue();
|
||||
return;
|
||||
}
|
||||
@@ -133,15 +155,16 @@ class PRAutoBlogger_Pipeline_Runner {
|
||||
|
||||
if ( ! empty( $queue['ideas'] ) ) {
|
||||
update_option( self::QUEUE_KEY, $queue, false );
|
||||
$this->update_queue_status( $queue );
|
||||
PRAutoBlogger_Pipeline_Status::update_queue_progress( $queue );
|
||||
$this->schedule_next();
|
||||
} else {
|
||||
// All articles done — amortize shared research costs across them.
|
||||
PRAutoBlogger_Post_Assembler::amortize_research_costs( $queue['run_id'] );
|
||||
$this->write_final_status( $queue['results'] );
|
||||
PRAutoBlogger_Pipeline_Status::write_final( $queue['results'] );
|
||||
delete_option( self::QUEUE_KEY );
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
$this->log_summary( $queue['results'] );
|
||||
PRAutoBlogger_Pipeline_Status::log_summary( $queue['results'] );
|
||||
PRAutoBlogger_Run_State::mark_status( (string) $queue['run_id'], 'done' );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,20 +179,26 @@ class PRAutoBlogger_Pipeline_Runner {
|
||||
private function orchestrate( PRAutoBlogger_Cost_Tracker $cost_tracker ): array {
|
||||
$target = absint( get_option( 'prautoblogger_daily_article_target', 1 ) );
|
||||
|
||||
$enabled = json_decode( get_option( 'prautoblogger_enabled_sources', '["reddit"]' ), true );
|
||||
$run_id = (string) $cost_tracker->get_run_id();
|
||||
|
||||
$enabled = json_decode( get_option( 'prautoblogger_enabled_sources', '["reddit"]' ), true );
|
||||
$source_labels = is_array( $enabled ) ? implode( ', ', $enabled ) : 'reddit';
|
||||
/* translators: %s is a comma-separated list of enabled source names, e.g. "reddit, llm_research". */
|
||||
$this->broadcast_stage( sprintf( __( 'Collecting sources from %s…', 'prautoblogger' ), $source_labels ) );
|
||||
PRAutoBlogger_Pipeline_Status::broadcast( sprintf( __( 'Collecting sources from %s…', 'prautoblogger' ), $source_labels ) );
|
||||
PRAutoBlogger_Run_Stage_State::start( $run_id, 'research', (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( 'research' ) );
|
||||
( new PRAutoBlogger_Source_Collector() )
|
||||
->set_cost_tracker( $cost_tracker )
|
||||
->collect_from_all_sources();
|
||||
PRAutoBlogger_Run_Stage_State::done( $run_id, 'research' );
|
||||
|
||||
$this->broadcast_stage( __( 'Analyzing topics and scoring…', 'prautoblogger' ) );
|
||||
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Analyzing topics and scoring…', 'prautoblogger' ) );
|
||||
PRAutoBlogger_Run_Stage_State::start( $run_id, 'analysis', (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( 'analysis' ) );
|
||||
$llm = new PRAutoBlogger_OpenRouter_Provider();
|
||||
$analyzer = new PRAutoBlogger_Content_Analyzer( $llm, $cost_tracker );
|
||||
$analysis = $analyzer->analyze_recent_data( max( $target * 2, 6 ) );
|
||||
PRAutoBlogger_Run_Stage_State::done( $run_id, 'analysis' );
|
||||
|
||||
$this->broadcast_stage( __( 'Selecting best topic…', 'prautoblogger' ) );
|
||||
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Selecting best topic…', 'prautoblogger' ) );
|
||||
$scorer = new PRAutoBlogger_Idea_Scorer();
|
||||
$scorer->set_skip_dedup( $this->skip_dedup );
|
||||
$ideas = $scorer->score_and_rank( $analysis, $target );
|
||||
@@ -197,14 +226,14 @@ class PRAutoBlogger_Pipeline_Runner {
|
||||
$ideas
|
||||
);
|
||||
|
||||
$queue = [
|
||||
$queue = array(
|
||||
'run_id' => $run_id,
|
||||
'ideas' => $serialized,
|
||||
'results' => $first,
|
||||
];
|
||||
);
|
||||
|
||||
update_option( self::QUEUE_KEY, $queue, false );
|
||||
$this->update_queue_status( $queue );
|
||||
PRAutoBlogger_Pipeline_Status::update_queue_progress( $queue );
|
||||
$this->schedule_next();
|
||||
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
@@ -221,64 +250,21 @@ class PRAutoBlogger_Pipeline_Runner {
|
||||
spawn_cron();
|
||||
wp_remote_post(
|
||||
site_url( 'wp-cron.php?doing_wp_cron=' . sprintf( '%.22F', microtime( true ) ) ),
|
||||
[ 'timeout' => 0.01, 'blocking' => false, 'sslverify' => false ]
|
||||
array(
|
||||
'timeout' => 0.01,
|
||||
'blocking' => false,
|
||||
'sslverify' => false,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Update the frontend status transient with queue progress. */
|
||||
private function update_queue_status( array $queue ): void {
|
||||
$done = $queue['results']['generated'] + $queue['results']['rejected'];
|
||||
$total = $done + count( $queue['ideas'] );
|
||||
|
||||
$current = get_transient( 'prautoblogger_generation_status' );
|
||||
set_transient( 'prautoblogger_generation_status', [
|
||||
'status' => 'running',
|
||||
'stage' => sprintf( __( 'Generating article %1$d of %2$d…', 'prautoblogger' ), $done + 1, $total ),
|
||||
'started' => is_array( $current ) ? ( $current['started'] ?? time() ) : time(),
|
||||
'last_updated' => time(),
|
||||
], 600 );
|
||||
}
|
||||
|
||||
/** Write the final "complete" status transient. */
|
||||
private function write_final_status( array $r ): void {
|
||||
set_transient( 'prautoblogger_generation_status', [
|
||||
'status' => 'complete',
|
||||
'generated' => $r['generated'],
|
||||
'published' => $r['published'],
|
||||
'rejected' => $r['rejected'],
|
||||
'cost' => $r['cost'],
|
||||
], 600 );
|
||||
}
|
||||
|
||||
/** Clean up a stale or empty queue. */
|
||||
private function finish_queue(): void {
|
||||
$queue = get_option( self::QUEUE_KEY );
|
||||
if ( is_array( $queue ) && isset( $queue['results'] ) ) {
|
||||
$this->write_final_status( $queue['results'] );
|
||||
PRAutoBlogger_Pipeline_Status::write_final( $queue['results'] );
|
||||
}
|
||||
delete_option( self::QUEUE_KEY );
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
}
|
||||
|
||||
/** Log the pipeline summary. */
|
||||
private function log_summary( array $r ): void {
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf(
|
||||
'Pipeline complete: %d generated, %d published, %d rejected. Cost: $%.4f',
|
||||
$r['generated'], $r['published'], $r['rejected'], $r['cost']
|
||||
),
|
||||
'pipeline'
|
||||
);
|
||||
}
|
||||
|
||||
/** Update the generation status transient with the current stage. */
|
||||
private function broadcast_stage( string $stage ): void {
|
||||
$key = 'prautoblogger_generation_status';
|
||||
$current = get_transient( $key );
|
||||
if ( is_array( $current ) && 'running' === ( $current['status'] ?? '' ) ) {
|
||||
$current['stage'] = $stage;
|
||||
$current['last_updated'] = time();
|
||||
set_transient( $key, $current, 600 );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
106
includes/core/class-pipeline-status.php
Normal file
106
includes/core/class-pipeline-status.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Frontend status-transient + summary-log helpers for pipeline runs.
|
||||
*
|
||||
* What: The generation-status transient plumbing (stage broadcast, queue
|
||||
* progress, final result) and the pipeline summary log line,
|
||||
* extracted verbatim from Pipeline_Runner / Article_Worker in
|
||||
* v0.18.0 (300-line cap; the two classes carried duplicate
|
||||
* broadcast helpers). Pure presentation/state — no business logic.
|
||||
* Who triggers it: Pipeline_Runner, Article_Worker.
|
||||
* Dependencies: WordPress transients, PRAutoBlogger_Logger.
|
||||
*
|
||||
* @see core/class-pipeline-runner.php — Queue progress + final status writes.
|
||||
* @see core/class-article-worker.php — Per-stage broadcasts.
|
||||
* @see class-executor.php — Poller that reads this transient.
|
||||
*/
|
||||
class PRAutoBlogger_Pipeline_Status {
|
||||
|
||||
/** Transient key polled by the admin frontend. */
|
||||
private const KEY = 'prautoblogger_generation_status';
|
||||
|
||||
/** Seconds the status transient stays readable. */
|
||||
private const TTL = 600;
|
||||
|
||||
/**
|
||||
* Update the live stage text (only while a run is broadcasting).
|
||||
*
|
||||
* @param string $stage Human-readable stage description.
|
||||
* @return void
|
||||
*/
|
||||
public static function broadcast( string $stage ): void {
|
||||
$current = get_transient( self::KEY );
|
||||
if ( is_array( $current ) && 'running' === ( $current['status'] ?? '' ) ) {
|
||||
$current['stage'] = $stage;
|
||||
$current['last_updated'] = time();
|
||||
set_transient( self::KEY, $current, self::TTL );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update queue progress ("Generating article X of Y…").
|
||||
*
|
||||
* @param array $queue Queue array with 'results' + 'ideas'.
|
||||
* @return void
|
||||
*/
|
||||
public static function update_queue_progress( array $queue ): void {
|
||||
$done = $queue['results']['generated'] + $queue['results']['rejected'];
|
||||
$total = $done + count( $queue['ideas'] );
|
||||
|
||||
$current = get_transient( self::KEY );
|
||||
set_transient(
|
||||
self::KEY,
|
||||
array(
|
||||
'status' => 'running',
|
||||
/* translators: 1: current article number, 2: total articles. */
|
||||
'stage' => sprintf( __( 'Generating article %1$d of %2$d…', 'prautoblogger' ), $done + 1, $total ),
|
||||
'started' => is_array( $current ) ? ( $current['started'] ?? time() ) : time(),
|
||||
'last_updated' => time(),
|
||||
),
|
||||
self::TTL
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the final "complete" status.
|
||||
*
|
||||
* @param array $r Result counters (generated/published/rejected/cost).
|
||||
* @return void
|
||||
*/
|
||||
public static function write_final( array $r ): void {
|
||||
set_transient(
|
||||
self::KEY,
|
||||
array(
|
||||
'status' => 'complete',
|
||||
'generated' => $r['generated'],
|
||||
'published' => $r['published'],
|
||||
'rejected' => $r['rejected'],
|
||||
'cost' => $r['cost'],
|
||||
),
|
||||
self::TTL
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the pipeline summary line.
|
||||
*
|
||||
* @param array $r Result counters (generated/published/rejected/cost).
|
||||
* @return void
|
||||
*/
|
||||
public static function log_summary( array $r ): void {
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf(
|
||||
'Pipeline complete: %d generated, %d published, %d rejected. Cost: $%.4f',
|
||||
$r['generated'],
|
||||
$r['published'],
|
||||
$r['rejected'],
|
||||
$r['cost']
|
||||
),
|
||||
'pipeline'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Post-creation assembly helpers: taxonomy, log linking, images, content sanitization.
|
||||
*
|
||||
* What: After wp_insert_post, assigns categories/tags, links generation logs,
|
||||
@@ -21,12 +23,12 @@ class PRAutoBlogger_Post_Assembler {
|
||||
* @param PRAutoBlogger_Article_Idea $idea Article idea with type and keywords.
|
||||
*/
|
||||
public static function assign_taxonomy_terms( int $post_id, PRAutoBlogger_Article_Idea $idea ): void {
|
||||
$type_category_map = [
|
||||
$type_category_map = array(
|
||||
'guide' => 'Guides',
|
||||
'solution' => 'Solutions',
|
||||
'comparison' => 'Comparisons',
|
||||
'article' => 'Articles',
|
||||
];
|
||||
);
|
||||
|
||||
$category_name = $type_category_map[ $idea->get_article_type() ] ?? 'Articles';
|
||||
$category = get_term_by( 'name', $category_name, 'category' );
|
||||
@@ -34,10 +36,10 @@ class PRAutoBlogger_Post_Assembler {
|
||||
if ( ! $category ) {
|
||||
$result = wp_insert_term( $category_name, 'category' );
|
||||
if ( ! is_wp_error( $result ) ) {
|
||||
wp_set_post_categories( $post_id, [ $result['term_id'] ] );
|
||||
wp_set_post_categories( $post_id, array( $result['term_id'] ) );
|
||||
}
|
||||
} else {
|
||||
wp_set_post_categories( $post_id, [ $category->term_id ] );
|
||||
wp_set_post_categories( $post_id, array( $category->term_id ) );
|
||||
}
|
||||
|
||||
$keywords = $idea->get_target_keywords();
|
||||
@@ -63,7 +65,7 @@ class PRAutoBlogger_Post_Assembler {
|
||||
// to a single post. See amortize_research_costs().
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
"UPDATE {$table} SET post_id = %d WHERE post_id IS NULL AND run_id = %s AND stage != 'llm_research'",
|
||||
$post_id,
|
||||
$run_id
|
||||
@@ -73,7 +75,7 @@ class PRAutoBlogger_Post_Assembler {
|
||||
// Legacy fallback: timestamp-based linking for pre-migration log entries.
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
"UPDATE {$table} SET post_id = %d WHERE post_id IS NULL AND created_at >= %s",
|
||||
$post_id,
|
||||
gmdate( 'Y-m-d H:i:s', time() - HOUR_IN_SECONDS )
|
||||
@@ -106,7 +108,7 @@ class PRAutoBlogger_Post_Assembler {
|
||||
// Find the unlinked research cost row for this run.
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$research_row = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
"SELECT id, estimated_cost, provider, model, prompt_tokens, completion_tokens
|
||||
FROM {$table}
|
||||
WHERE run_id = %s AND stage = 'llm_research' AND post_id IS NULL
|
||||
@@ -122,7 +124,7 @@ class PRAutoBlogger_Post_Assembler {
|
||||
// Count distinct articles produced in this run.
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$post_ids = $wpdb->get_col(
|
||||
$wpdb->prepare(
|
||||
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
"SELECT DISTINCT post_id FROM {$table} WHERE run_id = %s AND post_id IS NOT NULL",
|
||||
$run_id
|
||||
)
|
||||
@@ -133,34 +135,37 @@ class PRAutoBlogger_Post_Assembler {
|
||||
return; // No articles produced — nothing to amortize into.
|
||||
}
|
||||
|
||||
$total_cost = (float) $research_row->estimated_cost;
|
||||
$amortized_cost = $total_cost / $post_count;
|
||||
$total_prompt = (int) $research_row->prompt_tokens;
|
||||
$total_completion = (int) $research_row->completion_tokens;
|
||||
$amortized_prompt = (int) round( $total_prompt / $post_count );
|
||||
$amortized_compl = (int) round( $total_completion / $post_count );
|
||||
$total_cost = (float) $research_row->estimated_cost;
|
||||
$amortized_cost = $total_cost / $post_count;
|
||||
$total_prompt = (int) $research_row->prompt_tokens;
|
||||
$total_completion = (int) $research_row->completion_tokens;
|
||||
$amortized_prompt = (int) round( $total_prompt / $post_count );
|
||||
$amortized_compl = (int) round( $total_completion / $post_count );
|
||||
|
||||
// Insert one amortized research row per article.
|
||||
foreach ( $post_ids as $post_id ) {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->insert( $table, [
|
||||
'post_id' => (int) $post_id,
|
||||
'run_id' => $run_id,
|
||||
'stage' => 'llm_research',
|
||||
'provider' => $research_row->provider,
|
||||
'model' => $research_row->model,
|
||||
'prompt_tokens' => $amortized_prompt,
|
||||
'completion_tokens' => $amortized_compl,
|
||||
'estimated_cost' => $amortized_cost,
|
||||
'response_status' => 'success',
|
||||
'error_message' => '',
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
] );
|
||||
$wpdb->insert(
|
||||
$table,
|
||||
array(
|
||||
'post_id' => (int) $post_id,
|
||||
'run_id' => $run_id,
|
||||
'stage' => 'llm_research',
|
||||
'provider' => $research_row->provider,
|
||||
'model' => $research_row->model,
|
||||
'prompt_tokens' => $amortized_prompt,
|
||||
'completion_tokens' => $amortized_compl,
|
||||
'estimated_cost' => $amortized_cost,
|
||||
'response_status' => 'success',
|
||||
'error_message' => '',
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Remove the original unlinked row — it's been replaced by per-article rows.
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->delete( $table, [ 'id' => (int) $research_row->id ] );
|
||||
$wpdb->delete( $table, array( 'id' => (int) $research_row->id ) );
|
||||
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf(
|
||||
@@ -213,7 +218,13 @@ class PRAutoBlogger_Post_Assembler {
|
||||
return $author_id;
|
||||
}
|
||||
|
||||
$admins = get_users( [ 'role' => 'administrator', 'number' => 1, 'fields' => 'ID' ] );
|
||||
$admins = get_users(
|
||||
array(
|
||||
'role' => 'administrator',
|
||||
'number' => 1,
|
||||
'fields' => 'ID',
|
||||
)
|
||||
);
|
||||
return ! empty( $admins ) ? (int) $admins[0] : 1;
|
||||
}
|
||||
|
||||
@@ -221,11 +232,12 @@ class PRAutoBlogger_Post_Assembler {
|
||||
* Generate and attach images to a published post.
|
||||
* Non-blocking — logs errors but doesn't re-throw since the post is already created.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param PRAutoBlogger_Article_Idea $idea Article idea (contains source IDs).
|
||||
* @param array $post_data Post data array (for article content).
|
||||
* @param int $post_id Post ID.
|
||||
* @param PRAutoBlogger_Article_Idea $idea Article idea (contains source IDs).
|
||||
* @param array $post_data Post data array (for article content).
|
||||
* @param ?PRAutoBlogger_Cost_Tracker $cost_tracker Optional cost tracker for image generation cost logging.
|
||||
*/
|
||||
public static function attach_generated_images( int $post_id, PRAutoBlogger_Article_Idea $idea, array $post_data ): void {
|
||||
public static function attach_generated_images( int $post_id, PRAutoBlogger_Article_Idea $idea, array $post_data, ?PRAutoBlogger_Cost_Tracker $cost_tracker = null ): void {
|
||||
// Fetch the original Reddit source data for Image B's source-driven prompt.
|
||||
// The idea's source_ids point to rows in the source_data table collected
|
||||
// during the pipeline's collection step.
|
||||
@@ -236,7 +248,7 @@ class PRAutoBlogger_Post_Assembler {
|
||||
// internally, immediately after each image generates. This
|
||||
// ensures attachment persists even if the process times out
|
||||
// before this method returns.
|
||||
$result = ( new PRAutoBlogger_Image_Pipeline() )->generate_and_attach_images( $post_id, $post_data, $source_data );
|
||||
$result = ( new PRAutoBlogger_Image_Pipeline( null, $cost_tracker ) )->generate_and_attach_images( $post_id, $post_data, $source_data );
|
||||
|
||||
if ( ! empty( $result['errors'] ) ) {
|
||||
foreach ( $result['errors'] as $error ) {
|
||||
|
||||
147
includes/core/class-prompt-defaults-editorial.php
Normal file
147
includes/core/class-prompt-defaults-editorial.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Canonical default bodies for the editor, research, and image registry keys.
|
||||
*
|
||||
* What: Companion to PRAutoBlogger_Prompt_Defaults (split for the 300-line
|
||||
* cap, mirroring the settings-fields/-extended split). Holds the
|
||||
* chief-editor and LLM-research prompt copy extracted verbatim from
|
||||
* their classes, and references the two image keys that form the
|
||||
* image-composer PR seam: `image.rewriter_system` (the illustration
|
||||
* rewriter prompt) and `image.style_template` (the Style Template,
|
||||
* ARCHITECTURE #20). The composer PR CONSUMES these keys — it must
|
||||
* not grow its own prompt storage, and the key names are frozen on
|
||||
* the pipeline-v2-phase1 convo thread.
|
||||
* Who triggers it: PRAutoBlogger_Prompt_Registry (render fallback + seed),
|
||||
* PRAutoBlogger_Activator (seed migration).
|
||||
* Dependencies: PRAutoBlogger_Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT,
|
||||
* PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE (referenced, not duplicated).
|
||||
*
|
||||
* @see core/class-prompt-defaults.php — Content + analysis keys.
|
||||
* @see core/class-prompt-registry.php — Render/seed consumer.
|
||||
* @see core/class-chief-editor.php — Computes the editor token values.
|
||||
* @see providers/class-llm-research-provider.php — research.system consumer.
|
||||
*/
|
||||
class PRAutoBlogger_Prompt_Defaults_Editorial {
|
||||
|
||||
/**
|
||||
* Chief-editor system prompt. instructions_block carries its own
|
||||
* leading newline + trailing newline when non-empty ('' when absent),
|
||||
* matching the historical concatenation byte-for-byte.
|
||||
*/
|
||||
public const EDITOR_SYSTEM = <<<'TPL'
|
||||
You are a senior blog editor{{ niche_clause }}. Review article drafts before publication.
|
||||
|
||||
Evaluate on: QUALITY, ACCURACY, SEO, COMPLETENESS, READABILITY.
|
||||
Respond with JSON: {
|
||||
"verdict": "approved" | "revised" | "rejected",
|
||||
"quality_score": 0.0-1.0,
|
||||
"seo_score": 0.0-1.0,
|
||||
"issues": ["issue1", "issue2"],
|
||||
"notes": "Editorial notes",
|
||||
"revised_content": "Full revised HTML if revised, null otherwise"
|
||||
}
|
||||
|
||||
Rules:
|
||||
- APPROVE if quality_score >= 0.7 and seo_score >= 0.6
|
||||
- REVISE if fixable issues exist — provide full revised HTML
|
||||
- REJECT if fundamentally flawed
|
||||
- Preserve formatting, links, lists when revising
|
||||
{{ instructions_block }}
|
||||
TPL;
|
||||
|
||||
/** Chief-editor review user prompt. */
|
||||
public const EDITOR_REVIEW = <<<'TPL'
|
||||
Review this article draft:
|
||||
|
||||
BRIEF: Title: {{ title }} | Topic: {{ topic }} | Type: {{ article_type }}
|
||||
KEY POINTS: {{ key_points }}
|
||||
TARGET KEYWORDS: {{ keywords }}
|
||||
|
||||
CONTENT:
|
||||
{{ content }}
|
||||
TPL;
|
||||
|
||||
/**
|
||||
* LLM deep-research system prompt (fully static). The user-side
|
||||
* research brief stays the `prautoblogger_research_prompt` SETTING —
|
||||
* it is site configuration, not pipeline copy.
|
||||
*/
|
||||
public const RESEARCH_SYSTEM = <<<'TPL'
|
||||
You are a deep research analyst specializing in emerging trends, scientific developments, and community knowledge in niche health and biohacking domains. Your task is to identify substantive, actionable findings.
|
||||
|
||||
Using your training knowledge, scan across:
|
||||
- Recent scientific literature and research directions
|
||||
- Active community discussions, debates, and evolving consensus
|
||||
- Emerging products, protocols, and methodologies gaining traction
|
||||
- Common questions, misconceptions, and points of confusion
|
||||
- Regulatory changes, safety concerns, and legal shifts
|
||||
- Gaps between what practitioners want to know and what content exists
|
||||
|
||||
Your findings must be:
|
||||
1. SUBSTANTIVE — 2-3 paragraphs of detailed analysis per finding, not a headline
|
||||
2. ACTIONABLE — practical enough to seed a full article
|
||||
3. REALISTIC — grounded in what is actually discussed or researched; do NOT invent citations
|
||||
4. TIMELY — reflect current knowledge, recent shifts, or emerging questions
|
||||
|
||||
Respond with valid JSON only (no preamble, no markdown fences):
|
||||
{"findings": [{"title": "string", "content": "string (2-3 paragraphs)", "relevance_score": 0-100, "category": "question|trend|comparison|guide|misconception|safety"}]}
|
||||
|
||||
Generate 8-12 findings. Prioritize depth over breadth. Avoid generic or surface-level topics.
|
||||
TPL;
|
||||
|
||||
/**
|
||||
* Registry definitions for the editor / research / image keys.
|
||||
*
|
||||
* The image bodies are referenced from their existing single sources
|
||||
* of truth rather than duplicated. Note the image keys seed the
|
||||
* registry for the composer PR; the v0.16.0 option-override chain
|
||||
* (`prautoblogger_image_prompt_instructions`,
|
||||
* `prautoblogger_image_style_template`) is unchanged in Phase 1.
|
||||
*
|
||||
* @return array<string, array{body: string, model_option: ?string, params: array<string, mixed>}>
|
||||
*/
|
||||
public static function defs(): array {
|
||||
return array(
|
||||
'editor.system' => array(
|
||||
'body' => self::EDITOR_SYSTEM,
|
||||
'model_option' => 'prautoblogger_editor_model',
|
||||
'params' => array(
|
||||
'temperature' => 0.3,
|
||||
'max_tokens' => 5000,
|
||||
'response_format' => array( 'type' => 'json_object' ),
|
||||
),
|
||||
),
|
||||
'editor.review' => array(
|
||||
'body' => self::EDITOR_REVIEW,
|
||||
'model_option' => 'prautoblogger_editor_model',
|
||||
'params' => array(),
|
||||
),
|
||||
'research.system' => array(
|
||||
'body' => self::RESEARCH_SYSTEM,
|
||||
'model_option' => 'prautoblogger_research_model',
|
||||
'params' => array(
|
||||
'temperature' => 0.7,
|
||||
'max_tokens' => 8000,
|
||||
'response_format' => array( 'type' => 'json_object' ),
|
||||
),
|
||||
),
|
||||
'image.rewriter_system' => array(
|
||||
'body' => PRAutoBlogger_Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT,
|
||||
'model_option' => 'prautoblogger_analysis_model',
|
||||
'params' => array(
|
||||
'temperature' => 0.7,
|
||||
'max_tokens' => 180,
|
||||
),
|
||||
),
|
||||
'image.style_template' => array(
|
||||
'body' => PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE,
|
||||
'model_option' => 'prautoblogger_image_model',
|
||||
'params' => array(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
210
includes/core/class-prompt-defaults.php
Normal file
210
includes/core/class-prompt-defaults.php
Normal file
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Canonical default bodies for the content + analysis prompt-registry keys.
|
||||
*
|
||||
* What: The v0.16.0 hardcoded prompt texts, extracted verbatim into
|
||||
* `{{ token }}` templates. These constants are the single source of
|
||||
* truth twice over: the v1.2.0 migration seeds them into
|
||||
* `wp_prautoblogger_prompts` as version 1, AND the registry falls
|
||||
* back to them whenever the table is missing/empty — so a
|
||||
* half-migrated site renders byte-identical prompts and never
|
||||
* fatals. Versions in the table are immutable; to change a prompt,
|
||||
* create a new version (see Prompt_Registry).
|
||||
* Who triggers it: PRAutoBlogger_Prompt_Registry (render fallback + seed),
|
||||
* PRAutoBlogger_Activator (seed migration).
|
||||
* Dependencies: none — pure constants.
|
||||
*
|
||||
* @see core/class-prompt-registry.php — Render/seed consumer.
|
||||
* @see core/class-prompt-defaults-editorial.php — Editor/research/image keys.
|
||||
* @see core/class-content-prompts.php — Computes the token values.
|
||||
* @see core/class-analysis-prompts.php — Computes the token values.
|
||||
*/
|
||||
class PRAutoBlogger_Prompt_Defaults {
|
||||
|
||||
/**
|
||||
* System prompt shared across all writing stages. The mandatory style
|
||||
* guide block and the linking rules stay code-side (they are data
|
||||
* injection, not prompt copy) and are appended after this body.
|
||||
*/
|
||||
public const CONTENT_SYSTEM = 'You are an expert blog writer{{ niche_clause }}. Write well-researched, engaging, SEO-friendly content. Use a {{ tone }} tone. Output HTML content only — no markdown, no code fences, no commentary.';
|
||||
|
||||
/** Single-pass writer prompt (Economy tier — logs stage 'draft'). */
|
||||
public const CONTENT_SINGLE_PASS = <<<'TPL'
|
||||
Write a complete blog post in HTML format.
|
||||
|
||||
Title: {{ title }}
|
||||
Topic: {{ topic }}
|
||||
Type: {{ article_type }}
|
||||
|
||||
Key points:
|
||||
- {{ key_points }}
|
||||
|
||||
Keywords: {{ keywords }}
|
||||
|
||||
Requirements:
|
||||
- {{ min_words }}-{{ max_words }} words
|
||||
- Proper HTML (h2, h3, p, ul/li)
|
||||
- Engaging intro, strong conclusion with CTA
|
||||
- Do NOT include the title or <html>/<body> tags
|
||||
- Output HTML only, no markdown or commentary
|
||||
- Follow EVERY formatting and structural requirement from your system prompt style guide
|
||||
TPL;
|
||||
|
||||
/** Multi-step stage 1: outline. */
|
||||
public const CONTENT_OUTLINE = <<<'TPL'
|
||||
Create a detailed outline for a blog post titled: "{{ title }}"
|
||||
|
||||
Topic: {{ topic }}
|
||||
Article type: {{ article_type }}
|
||||
|
||||
Key points to cover:
|
||||
{{ key_points }}
|
||||
|
||||
Target keywords: {{ keywords }}
|
||||
|
||||
The outline should have 4-6 main sections with bullet points under each. Include an introduction hook and a conclusion with a call to action. Word count target: {{ min_words }}-{{ max_words }} words.
|
||||
|
||||
Plan the structure to satisfy EVERY requirement in your system prompt style guide.
|
||||
TPL;
|
||||
|
||||
/** Multi-step stage 2: full draft from outline. */
|
||||
public const CONTENT_DRAFT = <<<'TPL'
|
||||
Using this outline, write the full blog post in HTML format.
|
||||
|
||||
OUTLINE:
|
||||
{{ outline }}
|
||||
|
||||
Requirements:
|
||||
- Write in a {{ tone }} tone
|
||||
- Target {{ min_words }}-{{ max_words }} words
|
||||
- Use proper HTML headings (h2, h3), paragraphs, and lists
|
||||
- Include an engaging introduction and strong conclusion
|
||||
- Naturally incorporate these keywords: {{ keywords }}
|
||||
- Do NOT include the title in the HTML (it will be set separately)
|
||||
- Do NOT wrap in <html>, <head>, or <body> tags — just the article content
|
||||
- Follow EVERY formatting and structural requirement from your system prompt style guide
|
||||
TPL;
|
||||
|
||||
/** Multi-step stage 3: polish (canonical name — ARCHITECTURE once said 'edit'). */
|
||||
public const CONTENT_POLISH = <<<'TPL'
|
||||
Review and polish this blog post draft. Improve:
|
||||
1. Flow and readability
|
||||
2. SEO optimization (headings, keyword placement)
|
||||
3. Engagement (hooks, transitions, call-to-action)
|
||||
4. Accuracy and clarity
|
||||
5. Remove any filler or redundant sentences
|
||||
|
||||
IMPORTANT: Preserve all bullet points, numbered lists, hyperlinks, and structural elements from the draft. Do NOT flatten lists into prose or remove links. Ensure every requirement from your system prompt style guide is satisfied in the final output.
|
||||
|
||||
Return the polished HTML content only. Do not add commentary.
|
||||
|
||||
DRAFT:
|
||||
{{ draft }}
|
||||
TPL;
|
||||
|
||||
/**
|
||||
* Analyzer system prompt. The *_block tokens carry their own trailing
|
||||
* separators when non-empty ('' when absent) so the rendered output is
|
||||
* byte-identical to the historical concatenation.
|
||||
*/
|
||||
public const ANALYSIS_SYSTEM = <<<'TPL'
|
||||
You are a content strategist analyzing social media discussions to find article ideas for a blog{{ niche_clause }}.
|
||||
|
||||
IMPORTANT: You must identify exactly {{ target_count }} DISTINCT article ideas. Each idea must cover a substantially different topic — no two ideas should overlap in their main subject. Aim for diversity across these categories:
|
||||
1. QUESTIONS: Recurring questions people ask ("How do I...", "What is...", "Is it safe to...")
|
||||
2. COMPLAINTS: Pain points, frustrations, or problems people report
|
||||
3. COMPARISONS: Product/method comparisons people discuss ("X vs Y", "Which is better")
|
||||
4. NEWS: Recent developments, rule changes, or trends people are discussing
|
||||
5. GUIDES: How-to topics, best practices, or educational content people need
|
||||
|
||||
Spread your {{ target_count }} ideas across multiple categories. Be specific — "peptide dosing for BPC-157" is better than "peptide information". Each idea should be narrow enough to be a single focused blog post.
|
||||
|
||||
For each idea, provide:
|
||||
- type: 'question', 'complaint', 'comparison', 'news', or 'guide'
|
||||
- topic: A clear, specific, narrow topic description
|
||||
- summary: 1-2 sentence summary of why this topic matters
|
||||
- frequency: How many of the provided posts relate to this topic
|
||||
- relevance_score: 0.0 to 1.0 indicating how relevant and article-worthy this is
|
||||
- suggested_title: A compelling, unique blog post title
|
||||
- key_points: Array of 3-5 key points the article should cover
|
||||
- target_keywords: Array of SEO keywords for this topic
|
||||
|
||||
{{ performance_block }}{{ recent_articles_block }}{{ instructions_block }}Respond with valid JSON containing exactly {{ target_count }} patterns:
|
||||
{"patterns": [{"type": "...", "topic": "...", "summary": "...", "frequency": N, "relevance_score": 0.X, "suggested_title": "...", "key_points": [...], "target_keywords": [...]}]}
|
||||
TPL;
|
||||
|
||||
/** Analyzer user prompt wrapping the source-data summary. */
|
||||
public const ANALYSIS_USER = <<<'TPL'
|
||||
Here are the recent social media posts and comments to analyze. Find {{ target_count }} distinct, diverse article ideas from this data:
|
||||
|
||||
{{ summary }}
|
||||
TPL;
|
||||
|
||||
/**
|
||||
* Registry definitions for the content + analysis keys.
|
||||
*
|
||||
* Each entry: body (the v1 template), model_option (the wp_option that
|
||||
* selects the model at call time — informational in Phase 1) and params
|
||||
* (the call-time LLM parameters, snapshotted for audit comparability).
|
||||
*
|
||||
* @return array<string, array{body: string, model_option: ?string, params: array<string, mixed>}>
|
||||
*/
|
||||
public static function defs(): array {
|
||||
return array(
|
||||
'content.system' => array(
|
||||
'body' => self::CONTENT_SYSTEM,
|
||||
'model_option' => 'prautoblogger_writing_model',
|
||||
'params' => array(),
|
||||
),
|
||||
'content.single_pass' => array(
|
||||
'body' => self::CONTENT_SINGLE_PASS,
|
||||
'model_option' => 'prautoblogger_writing_model',
|
||||
'params' => array(
|
||||
'temperature' => 0.7,
|
||||
'max_tokens' => 4000,
|
||||
),
|
||||
),
|
||||
'content.outline' => array(
|
||||
'body' => self::CONTENT_OUTLINE,
|
||||
'model_option' => 'prautoblogger_writing_model',
|
||||
'params' => array(
|
||||
'temperature' => 0.5,
|
||||
'max_tokens' => 1500,
|
||||
),
|
||||
),
|
||||
'content.draft' => array(
|
||||
'body' => self::CONTENT_DRAFT,
|
||||
'model_option' => 'prautoblogger_writing_model',
|
||||
'params' => array(
|
||||
'temperature' => 0.7,
|
||||
'max_tokens' => 4000,
|
||||
),
|
||||
),
|
||||
'content.polish' => array(
|
||||
'body' => self::CONTENT_POLISH,
|
||||
'model_option' => 'prautoblogger_writing_model',
|
||||
'params' => array(
|
||||
'temperature' => 0.4,
|
||||
'max_tokens' => 4000,
|
||||
),
|
||||
),
|
||||
'analysis.system' => array(
|
||||
'body' => self::ANALYSIS_SYSTEM,
|
||||
'model_option' => 'prautoblogger_analysis_model',
|
||||
'params' => array(
|
||||
'temperature' => 0.5,
|
||||
'max_tokens' => 8000,
|
||||
),
|
||||
),
|
||||
'analysis.user' => array(
|
||||
'body' => self::ANALYSIS_USER,
|
||||
'model_option' => 'prautoblogger_analysis_model',
|
||||
'params' => array(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
143
includes/core/class-prompt-registry-writer.php
Normal file
143
includes/core/class-prompt-registry-writer.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Write side of the versioned prompt registry: create / activate / seed.
|
||||
*
|
||||
* What: Companion to PRAutoBlogger_Prompt_Registry (split for the 300-line
|
||||
* cap). Owns every mutation of `wp_prautoblogger_prompts` and
|
||||
* enforces the immutability rule: a stored body is NEVER updated —
|
||||
* a change is a new (prompt_key, version) row, and `activate()`
|
||||
* flips the single active flag per key. `seed_v1()` writes the
|
||||
* canonical v0.16.0 texts as version 1 (idempotent — keys that
|
||||
* already have rows keep their history). This is the API surface
|
||||
* the Phase-2 in-admin Prompts editor will call; Phase 1 ships no UI.
|
||||
* Who triggers it: PRAutoBlogger_Activator (seed migration on db 1.2.0),
|
||||
* Phase-2 admin (create/activate), WP-CLI (future).
|
||||
* Dependencies: WordPress $wpdb, PRAutoBlogger_Prompt_Registry (table name,
|
||||
* availability probe, defs, cache flush).
|
||||
*
|
||||
* @see core/class-prompt-registry.php — Read side (render, pins, fallback).
|
||||
* @see class-activator.php — Calls seed_v1() once, flag-gated.
|
||||
*/
|
||||
class PRAutoBlogger_Prompt_Registry_Writer {
|
||||
|
||||
/**
|
||||
* All versions of a key, newest first (Phase-2 list/diff support).
|
||||
*
|
||||
* @param string $key Registry key.
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function list_versions( string $key ): array {
|
||||
if ( ! PRAutoBlogger_Prompt_Registry::is_available() ) {
|
||||
return array();
|
||||
}
|
||||
global $wpdb;
|
||||
$table = PRAutoBlogger_Prompt_Registry::table_name();
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare( "SELECT * FROM {$table} WHERE prompt_key = %s ORDER BY version DESC", $key ),
|
||||
ARRAY_A
|
||||
);
|
||||
return is_array( $rows ) ? $rows : array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a NEW immutable version of a key and (optionally) activate it.
|
||||
* Never mutates an existing row's body — that is the whole point.
|
||||
*
|
||||
* @param string $key Registry key.
|
||||
* @param string $body Template body.
|
||||
* @param string|null $model Optional model hint.
|
||||
* @param array<string, mixed>|null $params Optional params snapshot.
|
||||
* @param string $author Who created it (seed tag, login, agent).
|
||||
* @param bool $activate Flip active to this version (default true).
|
||||
* @return int New version number, or 0 on failure / table unavailable.
|
||||
*/
|
||||
public static function create_version( string $key, string $body, ?string $model, ?array $params, string $author, bool $activate = true ): int {
|
||||
if ( ! PRAutoBlogger_Prompt_Registry::is_available() ) {
|
||||
return 0;
|
||||
}
|
||||
global $wpdb;
|
||||
$table = PRAutoBlogger_Prompt_Registry::table_name();
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$max = (int) $wpdb->get_var(
|
||||
$wpdb->prepare( "SELECT MAX(version) FROM {$table} WHERE prompt_key = %s", $key )
|
||||
);
|
||||
$next = $max + 1;
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$ok = $wpdb->insert(
|
||||
$table,
|
||||
array(
|
||||
'prompt_key' => $key,
|
||||
'version' => $next,
|
||||
'body' => $body,
|
||||
'model' => $model,
|
||||
'params_json' => null !== $params ? wp_json_encode( $params ) : null,
|
||||
'author' => $author,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
'active' => 0,
|
||||
)
|
||||
);
|
||||
if ( false === $ok ) {
|
||||
return 0;
|
||||
}
|
||||
if ( $activate ) {
|
||||
self::activate( $key, $next );
|
||||
}
|
||||
return $next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make one version the single active version of its key.
|
||||
*
|
||||
* @param string $key Registry key.
|
||||
* @param int $version Version to activate.
|
||||
* @return bool True when the version is now active.
|
||||
*/
|
||||
public static function activate( string $key, int $version ): bool {
|
||||
if ( ! PRAutoBlogger_Prompt_Registry::is_available() ) {
|
||||
return false;
|
||||
}
|
||||
global $wpdb;
|
||||
$table = PRAutoBlogger_Prompt_Registry::table_name();
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$wpdb->query( $wpdb->prepare( "UPDATE {$table} SET active = 0 WHERE prompt_key = %s", $key ) );
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$updated = $wpdb->query(
|
||||
$wpdb->prepare( "UPDATE {$table} SET active = 1 WHERE prompt_key = %s AND version = %d", $key, $version )
|
||||
);
|
||||
PRAutoBlogger_Prompt_Registry::flush_cache();
|
||||
return 1 === (int) $updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed every registry key with its canonical v1 body. Idempotent: keys
|
||||
* that already have rows are left untouched (their history is theirs).
|
||||
*
|
||||
* Side effects: up to one INSERT + two UPDATEs per missing key.
|
||||
*
|
||||
* @param string $author Audit tag for the seed rows (e.g. 'seed:v0.18.0').
|
||||
* @return int Number of keys seeded.
|
||||
*/
|
||||
public static function seed_v1( string $author = 'seed:v0.18.0' ): int {
|
||||
if ( ! PRAutoBlogger_Prompt_Registry::is_available() ) {
|
||||
return 0;
|
||||
}
|
||||
$seeded = 0;
|
||||
foreach ( PRAutoBlogger_Prompt_Registry::defs() as $key => $def ) {
|
||||
if ( ! empty( self::list_versions( $key ) ) ) {
|
||||
continue;
|
||||
}
|
||||
$model = null !== $def['model_option'] ? (string) get_option( $def['model_option'], '' ) : null;
|
||||
$version = self::create_version( $key, $def['body'], '' !== (string) $model ? $model : null, $def['params'], $author, true );
|
||||
if ( $version > 0 ) {
|
||||
++$seeded;
|
||||
}
|
||||
}
|
||||
PRAutoBlogger_Prompt_Registry::flush_cache();
|
||||
return $seeded;
|
||||
}
|
||||
}
|
||||
214
includes/core/class-prompt-registry.php
Normal file
214
includes/core/class-prompt-registry.php
Normal file
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Versioned prompt registry — immutable versions, one active row per key.
|
||||
*
|
||||
* What: Read/write API over `wp_prautoblogger_prompts`. Rendering resolves
|
||||
* the ACTIVE body for a key and fills `{{ token }}` placeholders;
|
||||
* when the table is missing or has no row for the key (half-migrated
|
||||
* site, failed seed), it falls back to the canonical defaults the
|
||||
* seed itself uses — byte-identical output, no fatals (self-healing).
|
||||
* Versions are IMMUTABLE: there is no update path for a stored body;
|
||||
* changes create a new (prompt_key, version) row and flip `active`
|
||||
* (write side lives in PRAutoBlogger_Prompt_Registry_Writer).
|
||||
* Runs pin the active version of every key at start (stored on the
|
||||
* runs row) so generation_log rows can be stamped with the exact
|
||||
* prompt_version they rendered with. No admin UI in Phase 1 — the
|
||||
* list/activate/create API is what the Phase-2 Prompts screen needs.
|
||||
* Who triggers it: Content_Prompts / Analysis_Prompts / Chief_Editor /
|
||||
* LLM_Research_Provider (render), Cost_Tracker (pins_for_run),
|
||||
* Activator (seed_v1), Cost_Tracker::set_run_id (pin_for_run).
|
||||
* Dependencies: WordPress $wpdb, Prompt_Defaults(+Editorial), wp_json_encode.
|
||||
*
|
||||
* @see core/class-prompt-defaults.php — Canonical v1 bodies (seed + fallback).
|
||||
* @see core/class-prompt-defaults-editorial.php — Editor/research/image bodies.
|
||||
* @see core/class-run-state.php — Owns the runs row the pins live on.
|
||||
* @see ARCHITECTURE.md #21 — Design rationale.
|
||||
*/
|
||||
class PRAutoBlogger_Prompt_Registry {
|
||||
|
||||
/** @var array<string, ?array<string, mixed>> Per-request active-row cache. */
|
||||
private static array $active_cache = array();
|
||||
|
||||
/** @var array<string, array<string, int>> Per-request run-pins cache. */
|
||||
private static array $pins_cache = array();
|
||||
|
||||
/** @var bool|null Per-request "table exists" probe result. */
|
||||
private static ?bool $table_ok = null;
|
||||
|
||||
/** Fully-qualified prompts table name (shared with Prompt_Registry_Writer). */
|
||||
public static function table_name(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . 'prautoblogger_prompts';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the prompts table exists and is queryable. Cached per request;
|
||||
* never throws — any failure reads as "not available" and every consumer
|
||||
* falls back to the in-code defaults.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_available(): bool {
|
||||
if ( null !== self::$table_ok ) {
|
||||
return self::$table_ok;
|
||||
}
|
||||
global $wpdb;
|
||||
if ( null === $wpdb ) {
|
||||
return false; // Not cached: $wpdb may appear later in boot.
|
||||
}
|
||||
$table = self::table_name();
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
|
||||
self::$table_ok = ( $found === $table );
|
||||
return self::$table_ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* All registry definitions (content + analysis + editor + research + image).
|
||||
*
|
||||
* @return array<string, array{body: string, model_option: ?string, params: array<string, mixed>}>
|
||||
*/
|
||||
public static function defs(): array {
|
||||
return array_merge(
|
||||
PRAutoBlogger_Prompt_Defaults::defs(),
|
||||
PRAutoBlogger_Prompt_Defaults_Editorial::defs()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical in-code default body for a key (the same text seeded as v1).
|
||||
*
|
||||
* @param string $key Registry key.
|
||||
* @return string|null Body template, or null for unknown keys.
|
||||
*/
|
||||
public static function default_body( string $key ): ?string {
|
||||
$defs = self::defs();
|
||||
return isset( $defs[ $key ] ) ? $defs[ $key ]['body'] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Active row for a key, or null when none / table unavailable.
|
||||
*
|
||||
* @param string $key Registry key.
|
||||
* @return array<string, mixed>|null Row with prompt_key, version, body, model, params_json, author, created_at, active.
|
||||
*/
|
||||
public static function get_active( string $key ): ?array {
|
||||
if ( array_key_exists( $key, self::$active_cache ) ) {
|
||||
return self::$active_cache[ $key ];
|
||||
}
|
||||
$row = null;
|
||||
if ( self::is_available() ) {
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$row = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE prompt_key = %s AND active = 1 ORDER BY version DESC LIMIT 1",
|
||||
$key
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
$row = is_array( $row ) ? $row : null;
|
||||
}
|
||||
self::$active_cache[ $key ] = $row;
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a prompt: active body for the key (fallback: canonical default),
|
||||
* with `{{ token }}` placeholders substituted.
|
||||
*
|
||||
* @param string $key Registry key (e.g. 'content.single_pass').
|
||||
* @param array<string, string> $tokens Token name => replacement value.
|
||||
* @return string Rendered prompt ('' only if the key is unknown AND absent).
|
||||
*/
|
||||
public static function render( string $key, array $tokens = array() ): string {
|
||||
$active = self::get_active( $key );
|
||||
$body = null !== $active ? (string) $active['body'] : (string) self::default_body( $key );
|
||||
return self::fill( $body, $tokens );
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute `{{ token }}` placeholders in a template body.
|
||||
*
|
||||
* @param string $body Template body.
|
||||
* @param array<string, string> $tokens Token name => replacement value.
|
||||
* @return string
|
||||
*/
|
||||
public static function fill( string $body, array $tokens ): string {
|
||||
foreach ( $tokens as $name => $value ) {
|
||||
$body = str_replace( '{{ ' . $name . ' }}', (string) $value, $body );
|
||||
}
|
||||
return $body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map of prompt_key => active version for every key present in the table.
|
||||
*
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public static function active_versions(): array {
|
||||
if ( ! self::is_available() ) {
|
||||
return array();
|
||||
}
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$rows = $wpdb->get_results( "SELECT prompt_key, version FROM {$table} WHERE active = 1", ARRAY_A );
|
||||
$map = array();
|
||||
foreach ( ( is_array( $rows ) ? $rows : array() ) as $row ) {
|
||||
$map[ (string) $row['prompt_key'] ] = (int) $row['version'];
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin the current active versions onto a run's row (once, at run start).
|
||||
* No-ops when the runs row already carries pins (resume keeps the
|
||||
* versions the run started with) or when tables are unavailable.
|
||||
*
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @return void
|
||||
*/
|
||||
public static function pin_for_run( string $run_id ): void {
|
||||
if ( '' === $run_id || ! self::is_available() ) {
|
||||
return;
|
||||
}
|
||||
$pins = self::active_versions();
|
||||
if ( empty( $pins ) ) {
|
||||
return;
|
||||
}
|
||||
PRAutoBlogger_Run_State::set_pins_if_absent( $run_id, $pins );
|
||||
unset( self::$pins_cache[ $run_id ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* The pinned prompt versions for a run (cached per request). Empty when
|
||||
* the run has no pins (pre-1.2.0 rows, standalone calls, missing table).
|
||||
*
|
||||
* @param string|null $run_id Pipeline run UUID, or null.
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public static function pins_for_run( ?string $run_id ): array {
|
||||
if ( null === $run_id || '' === $run_id ) {
|
||||
return array();
|
||||
}
|
||||
if ( isset( self::$pins_cache[ $run_id ] ) ) {
|
||||
return self::$pins_cache[ $run_id ];
|
||||
}
|
||||
$pins = PRAutoBlogger_Run_State::get_pins( $run_id );
|
||||
self::$pins_cache[ $run_id ] = $pins;
|
||||
return $pins;
|
||||
}
|
||||
|
||||
/** Reset per-request caches (tests). */
|
||||
public static function flush_cache(): void {
|
||||
self::$active_cache = array();
|
||||
self::$pins_cache = array();
|
||||
self::$table_ok = null;
|
||||
}
|
||||
}
|
||||
101
includes/core/class-publisher.php
Executable file → Normal file
101
includes/core/class-publisher.php
Executable file → Normal file
@@ -2,6 +2,8 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Creates WordPress posts from generated and editor-approved content.
|
||||
*
|
||||
* Handles both publishing (editor-approved) and saving as draft (editor-rejected).
|
||||
@@ -26,6 +28,7 @@ class PRAutoBlogger_Publisher {
|
||||
* @param PRAutoBlogger_Article_Idea $idea The original article idea.
|
||||
* @param PRAutoBlogger_Editorial_Review $review The editor's review.
|
||||
* @param string|null $run_id Pipeline run ID for log linking.
|
||||
* @param ?PRAutoBlogger_Cost_Tracker $cost_tracker Optional cost tracker for image generation cost logging.
|
||||
* @return int The created post ID.
|
||||
* @throws \RuntimeException If post creation fails.
|
||||
*/
|
||||
@@ -33,9 +36,10 @@ class PRAutoBlogger_Publisher {
|
||||
string $content,
|
||||
PRAutoBlogger_Article_Idea $idea,
|
||||
PRAutoBlogger_Editorial_Review $review,
|
||||
?string $run_id = null
|
||||
?string $run_id = null,
|
||||
?PRAutoBlogger_Cost_Tracker $cost_tracker = null
|
||||
): int {
|
||||
return $this->create_post( $content, $idea, $review, 'publish', $run_id );
|
||||
return $this->create_post( $content, $idea, $review, 'publish', $run_id, $cost_tracker );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,6 +49,7 @@ class PRAutoBlogger_Publisher {
|
||||
* @param PRAutoBlogger_Article_Idea $idea The original article idea.
|
||||
* @param PRAutoBlogger_Editorial_Review $review The editor's review with rejection notes.
|
||||
* @param string|null $run_id Pipeline run ID for log linking.
|
||||
* @param ?PRAutoBlogger_Cost_Tracker $cost_tracker Optional cost tracker for image generation cost logging.
|
||||
* @return int The created post ID.
|
||||
* @throws \RuntimeException If post creation fails.
|
||||
*/
|
||||
@@ -52,9 +57,10 @@ class PRAutoBlogger_Publisher {
|
||||
string $content,
|
||||
PRAutoBlogger_Article_Idea $idea,
|
||||
PRAutoBlogger_Editorial_Review $review,
|
||||
?string $run_id = null
|
||||
?string $run_id = null,
|
||||
?PRAutoBlogger_Cost_Tracker $cost_tracker = null
|
||||
): int {
|
||||
return $this->create_post( $content, $idea, $review, 'draft', $run_id );
|
||||
return $this->create_post( $content, $idea, $review, 'draft', $run_id, $cost_tracker );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,22 +79,56 @@ class PRAutoBlogger_Publisher {
|
||||
PRAutoBlogger_Article_Idea $idea,
|
||||
PRAutoBlogger_Editorial_Review $review,
|
||||
string $post_status,
|
||||
?string $run_id = null
|
||||
?string $run_id = null,
|
||||
?PRAutoBlogger_Cost_Tracker $cost_tracker = null
|
||||
): int {
|
||||
// v0.18.1 belt-and-braces: never create a post whose content is
|
||||
// empty once tags and whitespace are stripped — an empty "draft
|
||||
// for human review" (2026-06-11 incident, post 921) reviews
|
||||
// nothing and hides the underlying generation failure. The
|
||||
// provider/writer guards make this unreachable on OpenRouter
|
||||
// paths; this protects every other path into post creation.
|
||||
if ( '' === trim( wp_strip_all_tags( $content ) ) ) {
|
||||
throw new \RuntimeException(
|
||||
sprintf(
|
||||
/* translators: 1: intended post status, 2: article title. */
|
||||
__( 'Refusing to create %1$s post "%2$s": generated content is empty.', 'prautoblogger' ),
|
||||
$post_status,
|
||||
$idea->get_suggested_title()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// v0.18.0 idempotency: post creation is keyed by run + idea
|
||||
// (check-before-insert) so a retried/resumed run cannot create a
|
||||
// duplicate post. One run_id spans all N articles of a batch, so
|
||||
// the idea hash disambiguates within the run.
|
||||
$idea_hash = PRAutoBlogger_Run_Stage_State::idea_hash( $idea );
|
||||
if ( null !== $run_id && '' !== $run_id ) {
|
||||
$existing_id = $this->find_existing_post( $run_id, $idea_hash );
|
||||
if ( $existing_id > 0 ) {
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf( 'Post for run %s / idea %s already exists (ID=%d) — skipping duplicate insert.', $run_id, $idea_hash, $existing_id ),
|
||||
'publisher'
|
||||
);
|
||||
return $existing_id;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean LLM artifacts, then inject peptide hyperlinks deterministically
|
||||
// before the content enters WordPress. Peptide linker no-ops gracefully
|
||||
// if PR Core is not active.
|
||||
$clean_content = PRAutoBlogger_Post_Assembler::sanitize_llm_content( $content );
|
||||
$linked_content = PRAutoBlogger_Peptide_Linker::inject_links( $clean_content );
|
||||
|
||||
$post_data = [
|
||||
$post_data = array(
|
||||
'post_title' => sanitize_text_field( $idea->get_suggested_title() ),
|
||||
'post_content' => wp_kses_post( $linked_content ),
|
||||
'post_status' => $post_status,
|
||||
'post_type' => 'post',
|
||||
'post_author' => PRAutoBlogger_Post_Assembler::get_default_author_id(),
|
||||
'meta_input' => $this->build_meta( $idea, $review, $run_id ),
|
||||
];
|
||||
'meta_input' => $this->build_meta( $idea, $review, $run_id, $idea_hash ),
|
||||
);
|
||||
|
||||
/** @see class-prautoblogger.php — listeners registered in main loader. */
|
||||
$post_data = apply_filters( 'prautoblogger_filter_post_data', $post_data, $idea, $review );
|
||||
@@ -104,7 +144,7 @@ class PRAutoBlogger_Publisher {
|
||||
PRAutoBlogger_Post_Assembler::link_generation_logs( $post_id, $run_id );
|
||||
|
||||
if ( 'publish' === $post_status ) {
|
||||
PRAutoBlogger_Post_Assembler::attach_generated_images( $post_id, $idea, $post_data );
|
||||
PRAutoBlogger_Post_Assembler::attach_generated_images( $post_id, $idea, $post_data, $cost_tracker );
|
||||
}
|
||||
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
@@ -133,9 +173,10 @@ class PRAutoBlogger_Publisher {
|
||||
private function build_meta(
|
||||
PRAutoBlogger_Article_Idea $idea,
|
||||
PRAutoBlogger_Editorial_Review $review,
|
||||
?string $run_id = null
|
||||
?string $run_id = null,
|
||||
string $idea_hash = ''
|
||||
): array {
|
||||
$meta = [
|
||||
$meta = array(
|
||||
'_prautoblogger_generated' => '1',
|
||||
'_prautoblogger_analysis_id' => $idea->get_analysis_id(),
|
||||
'_prautoblogger_source_ids' => wp_json_encode( $idea->get_source_ids() ),
|
||||
@@ -149,10 +190,46 @@ class PRAutoBlogger_Publisher {
|
||||
'_prautoblogger_topic' => $idea->get_topic(),
|
||||
'_prautoblogger_article_type' => $idea->get_article_type(),
|
||||
'_prautoblogger_target_keywords' => wp_json_encode( $idea->get_target_keywords() ),
|
||||
];
|
||||
);
|
||||
if ( null !== $run_id && '' !== $run_id ) {
|
||||
$meta['_prautoblogger_run_id'] = $run_id;
|
||||
}
|
||||
if ( '' !== $idea_hash ) {
|
||||
$meta['_prautoblogger_idea_hash'] = $idea_hash;
|
||||
}
|
||||
return $meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a post already created for this (run, idea) pair.
|
||||
*
|
||||
* Direct postmeta self-join (any post_status, including trash) — the
|
||||
* idempotency check must see drafts and pending posts too.
|
||||
*
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @param string $idea_hash Idea hash from Run_Stage_State::idea_hash().
|
||||
* @return int Existing post ID, or 0 when none.
|
||||
*/
|
||||
private function find_existing_post( string $run_id, string $idea_hash ): int {
|
||||
global $wpdb;
|
||||
if ( null === $wpdb ) {
|
||||
return 0;
|
||||
}
|
||||
$postmeta = $wpdb->prefix . 'postmeta';
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$post_id = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT pm_run.post_id FROM {$postmeta} pm_run
|
||||
INNER JOIN {$postmeta} pm_hash
|
||||
ON pm_hash.post_id = pm_run.post_id
|
||||
AND pm_hash.meta_key = '_prautoblogger_idea_hash'
|
||||
AND pm_hash.meta_value = %s
|
||||
WHERE pm_run.meta_key = '_prautoblogger_run_id' AND pm_run.meta_value = %s
|
||||
LIMIT 1",
|
||||
$idea_hash,
|
||||
$run_id
|
||||
)
|
||||
);
|
||||
return null !== $post_id ? (int) $post_id : 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Daily cron that retroactively amortizes orphan `llm_research` rows.
|
||||
*
|
||||
* When a pipeline run dies between post creation and the final
|
||||
@@ -63,7 +65,11 @@ class PRAutoBlogger_Research_Reaper {
|
||||
* @return array{reaped: int, deleted: int, skipped: int}
|
||||
*/
|
||||
public static function reap(): array {
|
||||
$stats = [ 'reaped' => 0, 'deleted' => 0, 'skipped' => 0 ];
|
||||
$stats = array(
|
||||
'reaped' => 0,
|
||||
'deleted' => 0,
|
||||
'skipped' => 0,
|
||||
);
|
||||
|
||||
try {
|
||||
global $wpdb;
|
||||
@@ -72,7 +78,7 @@ class PRAutoBlogger_Research_Reaper {
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$orphans = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
"SELECT id, run_id, created_at FROM {$log_table}
|
||||
WHERE stage = 'llm_research' AND post_id IS NULL AND created_at < %s",
|
||||
$grace
|
||||
@@ -115,10 +121,10 @@ class PRAutoBlogger_Research_Reaper {
|
||||
if ( '' === $run_id ) {
|
||||
if ( $is_stale ) {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->delete( $log_table, [ 'id' => (int) $orphan->id ] );
|
||||
$stats['deleted']++;
|
||||
$wpdb->delete( $log_table, array( 'id' => (int) $orphan->id ) );
|
||||
++$stats['deleted'];
|
||||
} else {
|
||||
$stats['skipped']++;
|
||||
++$stats['skipped'];
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -130,22 +136,22 @@ class PRAutoBlogger_Research_Reaper {
|
||||
sprintf( 'Reaped research cost across %d articles for run %s.', count( $post_ids ), $run_id ),
|
||||
'cost-tracker'
|
||||
);
|
||||
$stats['reaped']++;
|
||||
++$stats['reaped'];
|
||||
return;
|
||||
}
|
||||
|
||||
if ( $is_stale ) {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->delete( $log_table, [ 'id' => (int) $orphan->id ] );
|
||||
$wpdb->delete( $log_table, array( 'id' => (int) $orphan->id ) );
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf( 'Deleted 7-day-stale orphan research row for run %s — no articles to attribute.', $run_id ),
|
||||
'cost-tracker'
|
||||
);
|
||||
$stats['deleted']++;
|
||||
++$stats['deleted'];
|
||||
return;
|
||||
}
|
||||
|
||||
$stats['skipped']++;
|
||||
++$stats['skipped'];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -156,15 +162,17 @@ class PRAutoBlogger_Research_Reaper {
|
||||
* @return int[] Distinct post IDs.
|
||||
*/
|
||||
private static function find_posts_for_run_id( string $run_id ): array {
|
||||
$meta_ids = get_posts( [
|
||||
'post_type' => 'any',
|
||||
'post_status' => 'any',
|
||||
'meta_key' => '_prautoblogger_run_id',
|
||||
'meta_value' => $run_id,
|
||||
'fields' => 'ids',
|
||||
'posts_per_page' => -1,
|
||||
'suppress_filters' => true,
|
||||
] );
|
||||
$meta_ids = get_posts(
|
||||
array(
|
||||
'post_type' => 'any',
|
||||
'post_status' => 'any',
|
||||
'meta_key' => '_prautoblogger_run_id',
|
||||
'meta_value' => $run_id,
|
||||
'fields' => 'ids',
|
||||
'posts_per_page' => -1,
|
||||
'suppress_filters' => true,
|
||||
)
|
||||
);
|
||||
if ( is_array( $meta_ids ) && ! empty( $meta_ids ) ) {
|
||||
return array_map( 'intval', $meta_ids );
|
||||
}
|
||||
@@ -173,11 +181,11 @@ class PRAutoBlogger_Research_Reaper {
|
||||
$log_table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$ids = $wpdb->get_col(
|
||||
$wpdb->prepare(
|
||||
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
"SELECT DISTINCT post_id FROM {$log_table} WHERE run_id = %s AND post_id IS NOT NULL",
|
||||
$run_id
|
||||
)
|
||||
);
|
||||
return is_array( $ids ) ? array_map( 'intval', $ids ) : [];
|
||||
return is_array( $ids ) ? array_map( 'intval', $ids ) : array();
|
||||
}
|
||||
}
|
||||
|
||||
59
includes/core/class-run-context.php
Normal file
59
includes/core/class-run-context.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
|
||||
*
|
||||
* Per-PHP-process holder for the active pipeline run id.
|
||||
*
|
||||
* What: A process-scoped static slot recording which run this PHP request
|
||||
* is executing, set whenever Cost_Tracker::set_run_id() is called
|
||||
* (daily cron, manual generation, queued-article jobs, ideas
|
||||
* browser). It lets components that are constructed without a run
|
||||
* reference — the OpenRouter provider (cost-governor guard) and
|
||||
* standalone Cost_Tracker instances (e.g. the image prompt
|
||||
* rewriter's) — resolve the surrounding run for reservation and
|
||||
* prompt-version stamping without threading a parameter through
|
||||
* every constructor. Mirrors the Opik_Trace_Context precedent.
|
||||
* Who triggers it: PRAutoBlogger_Cost_Tracker::set_run_id() (set);
|
||||
* Cost_Governor + Cost_Tracker (read).
|
||||
* Dependencies: none.
|
||||
*
|
||||
* @see core/class-cost-tracker.php — Sets the context on set_run_id().
|
||||
* @see core/class-cost-governor.php — Reads it to guard LLM calls.
|
||||
*/
|
||||
class PRAutoBlogger_Run_Context {
|
||||
|
||||
/** @var string|null Active run id for this PHP process, or null. */
|
||||
private static ?string $run_id = null;
|
||||
|
||||
/**
|
||||
* Record the active run id for this process.
|
||||
*
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @return void
|
||||
*/
|
||||
public static function set_run_id( string $run_id ): void {
|
||||
if ( '' !== $run_id ) {
|
||||
self::$run_id = $run_id;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The active run id for this process, or null outside any run.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public static function current_run_id(): ?string {
|
||||
return self::$run_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the context (tests / end of run).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function clear(): void {
|
||||
self::$run_id = null;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user