Compare commits
92 Commits
refactor/s
...
feature/v0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b399023291 | ||
|
|
382f2a0a6f | ||
|
|
ef85100501 | ||
|
|
97129ed457 | ||
|
|
21430ce3bf | ||
|
|
1c3d9b50c6 | ||
|
|
8bedecd2dd | ||
|
|
2cacaa3512 | ||
|
|
80a7e11a2b | ||
|
|
33d3ba4ea1 | ||
|
|
188e6603dd | ||
|
|
5c7736bf4e | ||
|
|
24a025c173 | ||
|
|
2a921e963f | ||
|
|
0198407e51 | ||
|
|
01ef135bf9 | ||
|
|
a37fb843a3 | ||
|
|
86ceea0da1 | ||
|
|
4791e54821 | ||
|
|
76235ef4d7 | ||
|
|
14f1cf0351 | ||
|
|
0cde08757b | ||
|
|
246f718630 | ||
|
|
264fcf0f84 | ||
|
|
327cdc08d9 | ||
|
|
c03bdfb69c | ||
|
|
39570e4586 | ||
|
|
10c812d191 | ||
|
|
36d45fd1ed | ||
|
|
7d61c8c76d | ||
|
|
ca8fe1bfba | ||
|
|
b0cc93669d | ||
|
|
65560ea0b5 | ||
|
|
3e1e5efe09 | ||
|
|
499dcc931d | ||
|
|
0a15e266cd | ||
|
|
9e74b596d1 | ||
|
|
281c8cd89c | ||
|
|
00cfb39253 | ||
|
|
cc6979b085 | ||
|
|
ef40ce91d4 | ||
|
|
c866637a95 | ||
|
|
2300fbf1c5 | ||
|
|
42d6d03cb3 | ||
|
|
d17c488ef3 | ||
|
|
c8595e05de | ||
|
|
650d8df325 | ||
|
|
214f57bff2 | ||
|
|
389cb4d380 | ||
|
|
abcc454dfe | ||
|
|
12654b3a45 | ||
|
|
41f1b13497 | ||
|
|
4c49e8593e | ||
|
|
b5bbc54f87 | ||
|
|
b17ed4719b | ||
|
|
1a2af595c6 | ||
|
|
dd406c5ba6 | ||
|
|
a4405a5d32 | ||
|
|
862d7e40e6 | ||
|
|
cf3d97c0c3 | ||
|
|
2c2a2acc7d | ||
|
|
58cb4ed508 | ||
|
|
358a1bbc7e | ||
|
|
1968864073 | ||
|
|
14e4270158 | ||
|
|
d886aee1d7 | ||
|
|
4cc9b39693 | ||
|
|
0a68741945 | ||
|
|
b5e91ba3a6 | ||
|
|
5b5286ca5b | ||
|
|
49aee0b446 | ||
|
|
51b385b938 | ||
|
|
1c1e861f2e | ||
|
|
31f303a831 | ||
|
|
dcc8b7b183 | ||
|
|
0e44c52ad8 | ||
|
|
2a734ba596 | ||
|
|
6cf8ff62fc | ||
|
|
b6363d8474 | ||
|
|
4663011c4c | ||
|
|
47ce4818f0 | ||
|
|
4629406cdc | ||
|
|
0caa2d503c | ||
|
|
dd10b43aab | ||
|
|
8de7b7f68a | ||
|
|
5344642978 | ||
|
|
4d09141550 | ||
|
|
7ef1a917e9 | ||
|
|
eccb336c94 | ||
|
|
8795f849f3 | ||
|
|
d31d378ec1 | ||
|
|
fcc61c0cb8 |
40
.github/workflows/deploy.yml
vendored
40
.github/workflows/deploy.yml
vendored
@@ -6,7 +6,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
validate:
|
validate:
|
||||||
name: Validate Code Quality
|
name: PHP Lint Gate
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -17,37 +17,17 @@ jobs:
|
|||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
php-version: '8.1'
|
php-version: '8.1'
|
||||||
tools: composer, phpcs
|
|
||||||
coverage: none
|
coverage: none
|
||||||
|
|
||||||
- name: PHP Lint Check
|
- name: PHP Lint Check
|
||||||
run: |
|
run: |
|
||||||
find . -name "*.php" -not -path "./vendor/*" -not -path "./node_modules/*" -exec php -l {} \; | (grep -v "No syntax errors" || true) | head -20
|
set -e
|
||||||
test ${PIPESTATUS[0]} -eq 0
|
find . -name '*.php' \
|
||||||
|
-not -path './vendor/*' \
|
||||||
- name: Install Composer dependencies
|
-not -path './node_modules/*' \
|
||||||
run: composer install --no-interaction --prefer-dist
|
-not -path './.git/*' \
|
||||||
|
-not -path './.github/*' \
|
||||||
- name: Install WordPress Coding Standards
|
-print0 | xargs -0 -n1 -P4 php -l > /dev/null
|
||||||
run: |
|
|
||||||
# Composer 2.2+ blocks plugins by default. The WPCS installer needs
|
|
||||||
# dealerdirect/phpcodesniffer-composer-installer to wire standards
|
|
||||||
# into PHPCS. Allow it globally before requiring WPCS.
|
|
||||||
composer global config --no-plugins allow-plugins.dealerdirect/phpcodesniffer-composer-installer true
|
|
||||||
composer global require "wp-coding-standards/wpcs:^3.0" "phpcsstandards/phpcsutils:^1.0" --no-interaction
|
|
||||||
phpcs --config-set installed_paths "$(composer global config home)/vendor/wp-coding-standards/wpcs"
|
|
||||||
|
|
||||||
- name: Run PHPCS (WordPress standards)
|
|
||||||
run: |
|
|
||||||
phpcs --standard=WordPress-Core \
|
|
||||||
--extensions=php \
|
|
||||||
--ignore=vendor/*,tests/*,node_modules/* \
|
|
||||||
--warning-severity=0 \
|
|
||||||
--report=summary \
|
|
||||||
includes/ prautoblogger.php uninstall.php || true
|
|
||||||
|
|
||||||
- name: Run PHPUnit
|
|
||||||
run: vendor/bin/phpunit --testdox
|
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
needs: validate
|
needs: validate
|
||||||
@@ -85,7 +65,9 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
|
ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
|
||||||
${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_HOST }} \
|
${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_HOST }} \
|
||||||
"cd ~/domains/peptiderepo.com/public_html && find wp-content/litespeed/ -type f -delete 2>/dev/null; touch .lscache_purge 2>/dev/null || true"
|
"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
|
- name: Health check
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
142
ARCHITECTURE.md
142
ARCHITECTURE.md
@@ -16,9 +16,10 @@ PRAutoBlogger is a WordPress plugin that monitors social media (starting with Re
|
|||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌─────────────────────────┐
|
┌─────────────────────────┐
|
||||||
│ 1. Source Collector │ Pulls raw posts/comments from social platforms
|
│ 1. Source Collector │ Pulls data from enabled sources:
|
||||||
│ (Reddit RSS primary │ Reddit RSS/Atom feeds (primary) / .json (fallback)
|
│ (Reddit + LLM Research)│ - Reddit RSS/Atom (primary) / .json (fallback)
|
||||||
│ / .json fallback) Stores raw data in `ab_source_data` table
|
│ │ - LLM Deep Research (reasoning model)
|
||||||
|
│ │ Stores raw data in `ab_source_data` table
|
||||||
└────────────┬────────────┘
|
└────────────┬────────────┘
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
@@ -65,10 +66,16 @@ PRAutoBlogger is a WordPress plugin that monitors social media (starting with Re
|
|||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌─────────────────────────┐
|
┌─────────────────────────┐
|
||||||
│ 6b. Image Pipeline │ (commit 1b) Generates two A/B images:
|
│ 6b. Image Pipeline │ Generates two A/B images in parallel:
|
||||||
│ (optional, if enabled) │ - Image A: article-driven (featured)
|
│ (optional, if enabled) │ - Image A: article-driven (featured)
|
||||||
│ │ - Image B: source-driven (post meta)
|
│ │ - Image B: source-driven (post meta)
|
||||||
│ │ Sideloads to media library, logs costs
|
│ │ Provider derived from the saved Image Model
|
||||||
|
│ │ via Image_Model_Registry::provider_for().
|
||||||
|
│ │ NSFW-blocked slots (CF error code 3030) are
|
||||||
|
│ │ retried once with a rule-based fallback
|
||||||
|
│ │ prompt via Image_NSFW_Retry; a second block
|
||||||
|
│ │ logs WARNING and publishes without that image.
|
||||||
|
│ │ Sideloads to media library, logs costs.
|
||||||
└────────────┬────────────┘
|
└────────────┬────────────┘
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
@@ -104,18 +111,24 @@ prautoblogger/
|
|||||||
│ └── posts-widget.js # React component for frontend post cards (wp.element)
|
│ └── posts-widget.js # React component for frontend post cards (wp.element)
|
||||||
│
|
│
|
||||||
├── includes/
|
├── includes/
|
||||||
│ ├── class-prautoblogger.php # Main orchestrator — registers all hooks via loader
|
│ ├── class-prautoblogger.php # Main orchestrator — registers all hooks, delegates execution
|
||||||
|
│ ├── class-executor.php # Cron handlers, generation AJAX (start + status), model registry
|
||||||
|
│ ├── class-ajax-handlers.php # Non-generation AJAX: images, models, test connections
|
||||||
|
│ ├── class-generation-lock.php # DB-level atomic mutex for single-writer generation
|
||||||
│ ├── class-activator.php # Activation: create DB tables, set defaults, schedule cron
|
│ ├── class-activator.php # Activation: create DB tables, set defaults, schedule cron
|
||||||
│ ├── class-deactivator.php # Deactivation: clear cron, cleanup transients
|
│ ├── class-deactivator.php # Deactivation: clear cron, cleanup transients
|
||||||
│ ├── class-autoloader.php # PSR-4-style autoloader for plugin classes
|
│ ├── class-autoloader.php # PSR-4-style autoloader for plugin classes
|
||||||
│ │
|
│ │
|
||||||
│ ├── admin/
|
│ ├── admin/
|
||||||
│ │ ├── class-admin-page.php # Main settings page (tabbed SaaS-style UI)
|
│ │ ├── class-admin-page.php # Main settings page (tabbed SaaS-style UI)
|
||||||
│ │ ├── class-settings-fields.php # Declarative settings definitions (sections + fields)
|
│ │ ├── class-settings-fields.php # Declarative settings: sections + core fields (API/Models/Content/Sources)
|
||||||
|
│ │ ├── class-settings-fields-extended.php # Operational fields: schedule, publishing, display, analytics, images
|
||||||
|
│ │ ├── class-image-model-registry.php # Static list of image generation models for the model picker
|
||||||
│ │ ├── class-admin-notices.php # Onboarding notices, error alerts, budget warnings
|
│ │ ├── class-admin-notices.php # Onboarding notices, error alerts, budget warnings
|
||||||
│ │ ├── class-dashboard-widget.php # WP Dashboard widget showing generation status
|
│ │ ├── class-dashboard-widget.php # WP Dashboard widget showing generation status
|
||||||
│ │ ├── class-post-metabox.php # Metabox on posts showing generation metadata
|
│ │ ├── class-post-metabox.php # Metabox on posts showing generation metadata
|
||||||
│ │ ├── class-metrics-page.php # Admin page for cost dashboard and content scores
|
│ │ ├── class-metrics-page.php # Admin page for cost dashboard and content scores
|
||||||
|
│ │ ├── class-ideas-browser.php # Browse all analysis results (article ideas)
|
||||||
│ │ ├── class-review-queue.php # Approve/edit/reject queue for generated drafts
|
│ │ ├── class-review-queue.php # Approve/edit/reject queue for generated drafts
|
||||||
│ │ └── class-log-viewer.php # Activity log viewer with level filtering
|
│ │ └── class-log-viewer.php # Activity log viewer with level filtering
|
||||||
│ │
|
│ │
|
||||||
@@ -123,40 +136,55 @@ prautoblogger/
|
|||||||
│ │ ├── class-scheduler.php # WP-Cron / Action Scheduler job management
|
│ │ ├── class-scheduler.php # WP-Cron / Action Scheduler job management
|
||||||
│ │ ├── class-source-collector.php # Orchestrates data collection from all sources
|
│ │ ├── class-source-collector.php # Orchestrates data collection from all sources
|
||||||
│ │ ├── class-content-analyzer.php # LLM-powered analysis of collected social data
|
│ │ ├── class-content-analyzer.php # LLM-powered analysis of collected social data
|
||||||
│ │ ├── class-idea-scorer.php # Ranks and deduplicates article ideas
|
│ │ ├── class-analysis-prompts.php # System/user prompt builders + performance context for analyzer
|
||||||
|
│ │ ├── class-idea-scorer.php # Ranks and deduplicates article ideas (delegates to Semantic_Dedup)
|
||||||
|
│ │ ├── class-semantic-dedup.php # Embedding-based cosine similarity dedup with keyword fallback
|
||||||
│ │ ├── class-content-generator.php# Writer agent — manages the generation pipeline
|
│ │ ├── class-content-generator.php# Writer agent — manages the generation pipeline
|
||||||
|
│ │ ├── class-content-prompts.php # Builds all LLM prompts for content generation (system, stage, linking rules)
|
||||||
|
│ │ ├── class-peptide-linker.php # Deterministic post-processor: hyperlinks peptide mentions to /peptides/ pages
|
||||||
│ │ ├── class-chief-editor.php # Editor agent — LLM-powered editorial review
|
│ │ ├── class-chief-editor.php # Editor agent — LLM-powered editorial review
|
||||||
│ │ ├── class-publisher.php # Creates WordPress posts from approved content
|
│ │ ├── class-publisher.php # Creates WordPress posts from approved content
|
||||||
│ │ ├── class-image-pipeline.php # Orchestrates A/B image generation (commit 1b)
|
│ │ ├── class-post-assembler.php # Post-creation helpers: taxonomy, log linking, images, sanitization
|
||||||
|
│ │ ├── class-image-pipeline.php # Orchestrates A/B image generation (parallel via batch)
|
||||||
│ │ ├── class-image-prompt-builder.php # Generates visual prompts from article/source data
|
│ │ ├── class-image-prompt-builder.php # Generates visual prompts from article/source data
|
||||||
│ │ ├── class-image-media-sideloader.php # Imports images into WordPress media library
|
│ │ ├── class-image-media-sideloader.php # Imports images into WordPress media library
|
||||||
│ │ ├── class-cost-tracker.php # Logs all API costs, enforces budget limits
|
│ │ ├── class-cost-tracker.php # Logs all API costs, enforces budget limits
|
||||||
│ │ ├── class-logger.php # Structured logging singleton (error/warning/info/debug)
|
│ │ ├── class-logger.php # Structured logging singleton (error/warning/info/debug)
|
||||||
│ │ ├── class-pipeline-runner.php # Orchestrates the full generation pipeline
|
│ │ ├── class-article-worker.php # Single-article generation (content + edit + publish)
|
||||||
|
│ │ ├── class-pipeline-runner.php # Orchestrates pipeline; chains per-article cron jobs
|
||||||
│ │ ├── class-ga4-client.php # Google Analytics 4 API client (OAuth + Data API)
|
│ │ ├── class-ga4-client.php # Google Analytics 4 API client (OAuth + Data API)
|
||||||
│ │ └── class-metrics-collector.php# Collects post performance data (WP + GA4)
|
│ │ └── class-metrics-collector.php# Collects post performance data (WP + GA4)
|
||||||
│ │
|
│ │
|
||||||
│ ├── providers/
|
│ ├── providers/
|
||||||
│ │ ├── interface-llm-provider.php # Contract for any LLM provider
|
│ │ ├── interface-llm-provider.php # Contract for any LLM provider
|
||||||
│ │ ├── class-open-router-provider.php # OpenRouter API implementation
|
│ │ ├── class-open-router-provider.php # OpenRouter API implementation (chat completions)
|
||||||
|
│ │ ├── class-open-router-embedding-provider.php # Text embeddings for semantic dedup
|
||||||
│ │ ├── class-open-router-pricing.php # Model pricing lookup and cost estimation
|
│ │ ├── class-open-router-pricing.php # Model pricing lookup and cost estimation
|
||||||
│ │ ├── interface-source-provider.php # Contract for any social media source
|
│ │ ├── interface-source-provider.php # Contract for any social media source
|
||||||
│ │ ├── class-reddit-json-client.php # Reddit HTTP client — RSS (primary) + .json (fallback)
|
│ │ ├── class-reddit-json-client.php # Reddit HTTP client — RSS (primary) + .json (fallback)
|
||||||
|
│ │ ├── class-llm-research-provider.php # LLM deep research source (reasoning models)
|
||||||
│ │ ├── class-reddit-provider.php # Reddit data collection orchestrator (RSS primary)
|
│ │ ├── class-reddit-provider.php # Reddit data collection orchestrator (RSS primary)
|
||||||
│ │ ├── interface-image-provider.php # Contract for any image generation provider
|
│ │ ├── interface-image-provider.php # Contract for any image generation provider (incl. batch)
|
||||||
│ │ ├── class-cloudflare-image-provider.php # FLUX.1 via Cloudflare Workers AI
|
│ │ ├── class-open-router-image-provider.php # OpenRouter image gen (single + batch dispatch)
|
||||||
│ │ ├── class-cloudflare-image-pricing.php # Model alias + per-MP cost estimation
|
│ │ ├── class-open-router-image-batch.php # Parallel curl_multi execution for batch image gen
|
||||||
│ │ ├── class-cloudflare-image-validator.php # Non-destructive credential + connectivity check
|
│ │ ├── class-open-router-image-support.php # API key, response parsing, retry/backoff helpers
|
||||||
│ │ ├── class-cloudflare-image-support.php # Token, account, URL, and log helpers for the CF provider
|
│ │ ├── class-open-router-image-pricing.php # Model resolution + per-image cost estimation
|
||||||
|
│ │ ├── class-open-router-config.php # API base URL (direct vs AI Gateway)
|
||||||
|
│ │ ├── class-open-router-request-builder.php # Header building + 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
|
||||||
│ │ └── (new providers go here — see CONVENTIONS.md)
|
│ │ └── (new providers go here — see CONVENTIONS.md)
|
||||||
│ │
|
│ │
|
||||||
│ ├── services/
|
│ ├── services/
|
||||||
│ │ ├── interface-model-registry.php # Contract for provider-specific model registries
|
│ │ ├── interface-model-registry.php # Contract for provider-specific model registries
|
||||||
│ │ ├── class-openrouter-model-registry.php # Fetches, caches, queries the OpenRouter model list
|
│ │ ├── class-open-router-model-registry.php # Fetches, caches, queries the OpenRouter model list
|
||||||
│ │ ├── class-openrouter-model-normalizer.php # Maps raw OpenRouter data → standardized record shape
|
│ │ ├── class-open-router-model-normalizer.php # Maps raw OpenRouter data → standardized record shape
|
||||||
│ │ └── (Phase 3: class-cloudflare-workers-ai-model-registry.php)
|
│ │ └── (Phase 3: class-runware-model-registry.php)
|
||||||
│ │
|
│ │
|
||||||
│ ├── frontend/
|
│ ├── frontend/
|
||||||
|
│ │ ├── class-article-typography.php # Inline CSS for font, size, table borders on generated posts
|
||||||
│ │ └── class-posts-widget.php # [prautoblogger_posts] shortcode + REST endpoint
|
│ │ └── class-posts-widget.php # [prautoblogger_posts] shortcode + REST endpoint
|
||||||
│ │
|
│ │
|
||||||
│ └── models/
|
│ └── models/
|
||||||
@@ -232,6 +260,53 @@ The widget CSS (`posts-widget.css`) uses the Peptide Starter theme's CSS custom
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Background Generation Flow (v0.3.0)
|
||||||
|
|
||||||
|
The "Generate Now" button runs the pipeline in a background WP-Cron process to avoid
|
||||||
|
Hostinger's 120-second LiteSpeed connection timeout. The frontend polls for status.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ Admin clicks "Generate Now" │ admin.js sends AJAX to on_ajax_generate_now()
|
||||||
|
└────────────┬─────────────────┘
|
||||||
|
│ Handler returns immediately (< 1 second):
|
||||||
|
│ 1. Writes "running" transient with started timestamp
|
||||||
|
│ 2. Schedules one-shot cron: prautoblogger_manual_generation
|
||||||
|
│ 3. Fires non-blocking loopback to wp-cron.php
|
||||||
|
│ 4. Returns JSON success to browser
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ admin.js starts polling │ setInterval every 5 seconds
|
||||||
|
│ on_ajax_generation_status() │ Shows stage text + spinner
|
||||||
|
└────────────┬─────────────────┘
|
||||||
|
│
|
||||||
|
▼ (separate PHP process)
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ on_manual_generation() │ WP-Cron fires this in a new request
|
||||||
|
│ (class-executor.php) │ 1. ignore_user_abort(true) — survives HTTP kill
|
||||||
|
│ │ 2. set_time_limit(300)
|
||||||
|
│ │ 3. Acquires PRAutoBlogger_Generation_Lock
|
||||||
|
│ │ 4. Runs PRAutoBlogger_Pipeline_Runner::run()
|
||||||
|
│ │ (pipeline broadcasts stage updates to transient)
|
||||||
|
│ │ 5. Writes final result to transient
|
||||||
|
│ │ 6. Releases lock
|
||||||
|
└────────────┬─────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ Poller detects completion │ Reads transient: status = 'complete' or 'error'
|
||||||
|
│ Shows result message │ Resets button to idle state
|
||||||
|
└──────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fallback detection:** 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Database Schema
|
## Database Schema
|
||||||
|
|
||||||
### Custom Tables
|
### Custom Tables
|
||||||
@@ -360,9 +435,14 @@ All prefixed with `prautoblogger_`:
|
|||||||
| `prautoblogger_log_level` | Logging threshold: error/warning/info/debug |
|
| `prautoblogger_log_level` | Logging threshold: error/warning/info/debug |
|
||||||
| `prautoblogger_db_version` | Schema version for migrations |
|
| `prautoblogger_db_version` | Schema version for migrations |
|
||||||
| `prautoblogger_schedule_time` | Daily generation time (HH:MM, default: '03:00') |
|
| `prautoblogger_schedule_time` | Daily generation time (HH:MM, default: '03:00') |
|
||||||
| `prautoblogger_cloudflare_ai_token` | Encrypted Cloudflare Workers AI API token |
|
| `prautoblogger_image_model` | Image model slug from `Image_Model_Registry::get_models()`; provider is derived from this on save |
|
||||||
| `prautoblogger_cloudflare_account_id` | Cloudflare account UUID (plaintext — identifier, not secret) |
|
| `prautoblogger_image_provider` | Derived from the chosen model on save (v0.8.0+); not editable in admin UI |
|
||||||
| `prautoblogger_image_model` | Image model alias: `flux-1-schnell` (default) or `flux-1-dev` |
|
| `prautoblogger_image_prompt_instructions` | System prompt given to the image rewriter LLM (v0.8.0+); falls back to `Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT` when empty |
|
||||||
|
| `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_suffix` | Text appended to every image prompt (default: CEO-locked 90s infomercial prompt) |
|
||||||
| `prautoblogger_openrouter_model_registry` | Normalized OpenRouter model list (JSON array, daily refresh, serves as durable cache) |
|
| `prautoblogger_openrouter_model_registry` | Normalized OpenRouter model list (JSON array, daily refresh, serves as durable cache) |
|
||||||
| `prautoblogger_openrouter_model_registry_fetched_at` | Unix timestamp of last successful model registry refresh |
|
| `prautoblogger_openrouter_model_registry_fetched_at` | Unix timestamp of last successful model registry refresh |
|
||||||
@@ -396,7 +476,7 @@ Stored on every PRAutoBlogger-generated post:
|
|||||||
| Reddit RSS | Primary Reddit data source — Atom feeds for subreddit hot posts | None (unauthenticated) | No known rate limit; reliable from datacenter IPs | `providers/class-reddit-json-client.php` |
|
| Reddit RSS | Primary Reddit data source — Atom feeds for subreddit hot posts | None (unauthenticated) | No known rate limit; reliable from datacenter IPs | `providers/class-reddit-json-client.php` |
|
||||||
| Reddit .json | Fallback for posts + only source for comments | None (unauthenticated) | ~10 req/min (datacenter IPs often blocked) | `providers/class-reddit-json-client.php` |
|
| Reddit .json | Fallback for posts + only source for comments | None (unauthenticated) | ~10 req/min (datacenter IPs often blocked) | `providers/class-reddit-json-client.php` |
|
||||||
| Google Analytics 4 | Post performance metrics | OAuth2 service account | Standard GA4 limits | `core/class-ga4-client.php`, `core/class-metrics-collector.php` |
|
| Google Analytics 4 | Post performance metrics | OAuth2 service account | Standard GA4 limits | `core/class-ga4-client.php`, `core/class-metrics-collector.php` |
|
||||||
| Cloudflare Workers AI | Image generation (FLUX.1 schnell / dev) for article hero, thumbnail, and IG placements | API token (encrypted in wp_options) + account ID | Workers AI per-account quotas | `providers/class-cloudflare-image-provider.php`, `providers/class-cloudflare-image-pricing.php`, `providers/class-cloudflare-image-validator.php` |
|
| Runware (FLUX.1 via runware.ai) | **Default** image backend (v0.9.0+): schnell ~$0.0006/image, dev ~$0.02/image. True parallel generation via curl_multi. | 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` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -439,17 +519,25 @@ The posts widget uses WordPress-bundled React (`wp.element`) with raw `createEle
|
|||||||
The widget fetches posts asynchronously from a dedicated REST endpoint rather than rendering server-side. This keeps initial page load fast and allows the endpoint to be cached independently (5-minute Cache-Control). The permission callback uses a filter (`prautoblogger_rest_posts_public`) so sites can restrict access if needed.
|
The widget fetches posts asynchronously from a dedicated REST endpoint rather than rendering server-side. This keeps initial page load fast and allows the endpoint to be cached independently (5-minute Cache-Control). The permission callback uses a filter (`prautoblogger_rest_posts_public`) so sites can restrict access if needed.
|
||||||
|
|
||||||
### #13: Run-ID based log linking
|
### #13: Run-ID based log linking
|
||||||
Each pipeline execution generates a UUID (`run_id`) that tags every `prab_generation_log` entry. When a post is published, `link_generation_logs()` uses `UPDATE WHERE run_id = X` to associate entries with the `post_id`. This is more reliable than the previous timestamp-window approach, especially in batch runs where multiple posts are generated in quick succession.
|
Each pipeline execution generates a UUID (`run_id`) that tags every `prab_generation_log` entry. When a post is published, `link_generation_logs()` uses `UPDATE WHERE run_id = X` to associate entries with the `post_id`. This is more reliable than the previous timestamp-window approach, especially in batch runs where multiple posts are generated in quick succession. **v0.8.1+**: `run_id` is also persisted on each post as `_prautoblogger_run_id` post_meta, giving the orphan-research reaper a direct path from run to siblings without walking the gen_log table (see #19).
|
||||||
|
|
||||||
|
### #19: Daily orphan-research-row reaper (v0.8.1)
|
||||||
|
When a pipeline dies between post creation and the final `Post_Assembler::amortize_research_costs()` call (Hostinger exec kill, OOM, LiteSpeed cut, fatal), the shared `llm_research` cost row stays in `prab_generation_log` with `post_id = NULL` and sibling articles understate their true cost in the breakdown popover. A new daily WP-Cron event `prautoblogger_reap_orphan_research_rows` (hooked to `PRAutoBlogger_Research_Reaper::on_cron`, 03:15 server time — 15 min after the primary generation cron) scans for orphan rows older than a 1-hour grace window, looks up sibling posts by `_prautoblogger_run_id` post_meta (fallback: distinct `post_id`s in gen_log for the same run_id), and delegates to the existing idempotent `amortize_research_costs()` on match. Orphans that still have no siblings after 7 days are deleted outright as sunk cost. A one-time activator migration (`prautoblogger_migrated_run_id_meta_v081`) back-populates `_prautoblogger_run_id` on existing posts from gen_log so the meta-primary path works on upgrade. Manual ops trigger: `wp prautoblogger reap-research`.
|
||||||
|
|
||||||
### #14: Reddit RSS replaces PullPush.io (and earlier Reddit OAuth)
|
### #14: Reddit RSS replaces PullPush.io (and earlier Reddit OAuth)
|
||||||
Reddit rejected our OAuth API application (April 2026). We initially switched to PullPush.io, but its index was frequently stale or unavailable. Reddit's RSS/Atom feeds (`/r/{sub}/hot.rss`) proved the most reliable option — they work from datacenter IPs where .json gets 403, require no auth, and have no apparent rate limit. The .json endpoints are kept as a fallback for posts and as the sole source for comment data. Each collected item's metadata includes a `data_source` field (`reddit_rss` or `reddit_json`) for auditability.
|
Reddit rejected our OAuth API application (April 2026). We initially switched to PullPush.io, but its index was frequently stale or unavailable. Reddit's RSS/Atom feeds (`/r/{sub}/hot.rss`) proved the most reliable option — they work from datacenter IPs where .json gets 403, require no auth, and have no apparent rate limit. The .json endpoints are kept as a fallback for posts and as the sole source for comment data. Each collected item's metadata includes a `data_source` field (`reddit_rss` or `reddit_json`) for auditability.
|
||||||
|
|
||||||
### #18: OpenRouter model registry — daily refresh, WP option + transient cache, zero-coupling
|
### #18: OpenRouter model registry — daily refresh, WP option + transient cache, zero-coupling
|
||||||
|
|
||||||
The admin model picker (v1) needs to list OpenRouter models with pricing and capabilities. We fetch `https://openrouter.ai/api/v1/models` (free, unauthenticated) once daily and store the normalized payload in a WP option fronted by a 24h transient. On stale-and-fetch-fails, we serve last-good + surface a warning. The registry class (`class-openrouter-model-registry.php`) takes all config via constructor (option name, transient name, endpoint URL) — no PRAUTOBLOGGER_* constants inside the class body. Phase 2 lifts it into a shared Composer package with zero internal edits. Phase 3 adds a parallel `Cloudflare_WorkersAI_Model_Registry` behind the same interface. Capability vocabulary: `text→text`, `text+image→text`, `text+audio→text`, `text→image`, `text→audio`, `text→video`, `text→embedding`. Cost: $0/month.
|
The admin model picker (v1) needs to list OpenRouter models with pricing and capabilities. We fetch `https://openrouter.ai/api/v1/models` (free, unauthenticated) once daily and store the normalized payload in a WP option fronted by a 24h transient. On stale-and-fetch-fails, we serve last-good + surface a warning. The registry class (`class-open-router-model-registry.php`) takes all config via constructor (option name, transient name, endpoint URL) — no PRAUTOBLOGGER_* constants inside the class body. Phase 2 lifts it into a shared Composer package with zero internal edits. Phase 3 adds a parallel `Cloudflare_WorkersAI_Model_Registry` behind the same interface. Capability vocabulary: `text→text`, `text+image→text`, `text+audio→text`, `text→image`, `text→audio`, `text→video`, `text→embedding`. Cost: $0/month.
|
||||||
|
|
||||||
|
### #17: Runware as default image backend (v0.9.0, Apr 2026)
|
||||||
|
|
||||||
|
After a comic-style A/B round in Apr 2026, FLUX.1 schnell via Runware was chosen as the default image backend. Cost is ~$0.0006/image vs ~$0.039/image for Gemini 2.5 Flash Image via OpenRouter (≈65× cheaper), and the looser schnell fidelity reads as editorial-cartoon style — a feature, not a bug, for our single-panel comic aesthetic. FLUX.1 dev (~$0.02/image, 28 steps) remains available as an opt-in for higher-fidelity runs. The Runware layer mirrors the OpenRouter split (interface + provider + support + pricing + batch), all under the 300-line cap. The batch class dispatches `imageInference` POSTs via `curl_multi_init/exec/select` — wall-clock time for the A/B pair drops to ≈ the slowest single image. A v0.9.0 one-time migration (`prautoblogger_migrated_default_image_v090`) flips sites still on the legacy default; explicit user selections are preserved. Cloudflare Workers AI was removed as an image provider in v0.10.0 (see ADR #16 note below).
|
||||||
|
|
||||||
|
### #16: Image generation via Cloudflare Workers AI (FLUX.1) — SUPERSEDED v0.10.0
|
||||||
|
Kept here for history. Until v0.8.2 the default image backend was FLUX.1 schnell on Cloudflare Workers AI, routed via the Cloudflare AI Gateway for caching + unified telemetry. In v0.9.0 (2026-04-21) Runware FLUX.1 schnell became the default (65× cheaper, see ADR #17). In v0.10.0 (2026-04-21) the Cloudflare Workers AI provider was removed entirely — classes, settings, tests, and registry entries are gone; any CF user is migrated to `runware:100@1` on upgrade via `PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run()`. The Cloudflare AI Gateway in front of OpenRouter (ADR #15) is unaffected — it is a distinct integration.
|
||||||
|
|
||||||
### #16: Image generation via Cloudflare Workers AI (FLUX.1), direct (not via AI Gateway)
|
|
||||||
The image-generation layer (started 2026-04-15, tracked in `convo/prautoblogger/threads/2026-04-image-pipeline/`) uses FLUX.1 [schnell] on Cloudflare Workers AI as its default model, chosen for its ~$0.0011/MP cost and 2–3 sec latency. FLUX.1 [dev] is a ~4× cost upgrade exposed as a dropdown for specific posts that warrant it. Calls go directly to `https://api.cloudflare.com/client/v4/accounts/{id}/ai/run/@cf/black-forest-labs/...`, *not* through the Cloudflare AI Gateway that we use for OpenRouter — the gateway route to Workers AI currently 403s (pre-existing open issue). The provider lives behind `PRAutoBlogger_Image_Provider_Interface`, so the decision is trivially reversible: switching to a different image API (DALL-E, Replicate, a future AI Gateway route) is a new class implementation plus a one-line swap in the pipeline wiring. Trade-off: we run two different Cloudflare integration paths (gateway for OpenRouter LLMs, direct for Workers AI images) until the gateway route stabilizes — minor cognitive overhead, zero functional downside.
|
|
||||||
|
|
||||||
### #15: Optional Cloudflare AI Gateway in front of OpenRouter
|
### #15: Optional Cloudflare AI Gateway in front of OpenRouter
|
||||||
We already use Cloudflare for DNS/CDN on peptiderepo.com, so layering AI Gateway in front of OpenRouter is zero marginal infrastructure. It gives us response caching (meaningful for repeated classification/scoring calls), a unified cost/latency dashboard, rate limiting, and provider fallback — all of which we would otherwise have to build ourselves to satisfy the CTO cost-tracking rules. Kept as an opt-in URL setting (`prautoblogger_ai_gateway_base_url`) so the plugin still works unchanged out of the box and can be bypassed instantly if the gateway misbehaves. The gateway is a transparent OpenRouter-compatible proxy; no new provider class is needed, and the response parsing path (`usage`, `choices[0].message.content`) is unchanged.
|
We already use Cloudflare for DNS/CDN on peptiderepo.com, so layering AI Gateway in front of OpenRouter is zero marginal infrastructure. It gives us response caching (meaningful for repeated classification/scoring calls), a unified cost/latency dashboard, rate limiting, and provider fallback — all of which we would otherwise have to build ourselves to satisfy the CTO cost-tracking rules. Kept as an opt-in URL setting (`prautoblogger_ai_gateway_base_url`) so the plugin still works unchanged out of the box and can be bypassed instantly if the gateway misbehaves. The gateway is a transparent OpenRouter-compatible proxy; no new provider class is needed, and the response parsing path (`usage`, `choices[0].message.content`) is unchanged.
|
||||||
|
|||||||
504
CHANGELOG.md
504
CHANGELOG.md
@@ -5,6 +5,506 @@ All notable changes to PRAutoBlogger will be documented in this file.
|
|||||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project uses [Semantic Versioning](https://semver.org/).
|
and this project uses [Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
|
## [0.10.0] — 2026-04-21
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- **Cloudflare Workers AI as image provider.** The four `class-cloudflare-image-*`
|
||||||
|
provider files are gone along with their unit tests and the three
|
||||||
|
CF-specific options (`prautoblogger_cloudflare_ai_token`,
|
||||||
|
`prautoblogger_cloudflare_account_id`, `prautoblogger_cf_image_via_gateway`).
|
||||||
|
Runware FLUX.1 schnell produces equivalent-quality output at ~65×
|
||||||
|
less cost and eliminates a duplicated settings surface. The
|
||||||
|
`flux-1-schnell` registry entry that mapped to the CF provider is
|
||||||
|
removed; the legacy slug now resolves to an empty provider (caught
|
||||||
|
by the migration).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Sites on any `cloudflare` image provider auto-migrate to
|
||||||
|
`runware:100@1` on upgrade.** New migration
|
||||||
|
`PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run()` runs from
|
||||||
|
`admin_init` once per site. Detects three legacy shapes: registry-
|
||||||
|
derived provider `cloudflare`, `prautoblogger_image_provider` option
|
||||||
|
set to `cloudflare`, or the legacy slug `flux-1-schnell`. Sets
|
||||||
|
`prautoblogger_image_provider` + `prautoblogger_image_model` to the
|
||||||
|
Runware schnell pair and deletes the three dead CF options. Idempotent
|
||||||
|
via `prautoblogger_migrated_remove_cloudflare_v0100` flag — running
|
||||||
|
twice is a no-op and will not clobber a post-migration manual choice.
|
||||||
|
- **Image provider dispatch fallback is now Runware, not Cloudflare.**
|
||||||
|
`PRAutoBlogger_Image_Pipeline::create_default_provider()` returns
|
||||||
|
`PRAutoBlogger_Runware_Image_Provider` for any unrecognised
|
||||||
|
`prautoblogger_image_provider` value. An unmigrated install still
|
||||||
|
gets a working pipeline instead of a fatal.
|
||||||
|
|
||||||
|
### Note
|
||||||
|
|
||||||
|
- **Cloudflare AI Gateway fronting OpenRouter is unaffected.** The
|
||||||
|
`prautoblogger_ai_gateway_base_url` + `prautoblogger_ai_gateway_cache_ttl`
|
||||||
|
options, their settings fields, `class-open-router-config.php`
|
||||||
|
gateway resolution, and `class-open-router-request-builder.php`
|
||||||
|
`cf-aig-cache-ttl` headers are all intentionally untouched. The AI
|
||||||
|
Gateway is a proxy/cache layer in front of OpenRouter and is
|
||||||
|
orthogonal to the CF Workers AI image provider removal. The
|
||||||
|
migration test asserts this invariant explicitly.
|
||||||
|
|
||||||
|
### Cost impact
|
||||||
|
|
||||||
|
Zero recurring spend from CF Workers AI image generation (the
|
||||||
|
peptiderepo.com install was still on CF as recently as v0.8.2 — see
|
||||||
|
commit `80a7e11`). No new prompt tokens, no change in model mix for
|
||||||
|
sites already on Runware or OpenRouter.
|
||||||
|
|
||||||
|
### Migration checklist (for anyone else running this plugin)
|
||||||
|
|
||||||
|
1. On first admin_init after upgrade, the migration will flip
|
||||||
|
`prautoblogger_image_provider` to `runware` and
|
||||||
|
`prautoblogger_image_model` to `runware:100@1` if you were on CF.
|
||||||
|
2. **Set `prautoblogger_runware_api_key`** in Settings → Images if you
|
||||||
|
hadn't already — image generation will fail until it is set.
|
||||||
|
3. The CF API token + account ID options are deleted by the migration;
|
||||||
|
you don't need to do anything.
|
||||||
|
4. If you want to switch back to OpenRouter (Gemini) instead of
|
||||||
|
Runware, just change the Image Model picker after the migration
|
||||||
|
has run.
|
||||||
|
|
||||||
|
## [0.9.0] — 2026-04-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Runware (FLUX.1 schnell) as the default image provider.** New
|
||||||
|
provider at `includes/providers/class-runware-image-*` generates
|
||||||
|
~$0.0006/image on FLUX.1 schnell (`runware:100@1`) — roughly 65× cheaper
|
||||||
|
than the previous default (Gemini 2.5 Flash Image via OpenRouter at
|
||||||
|
~$0.039/image). FLUX.1 dev (`runware:101@1`, ~$0.02/image) is also
|
||||||
|
available for higher-fidelity runs. The provider mirrors the
|
||||||
|
OpenRouter split: interface + provider + support + pricing + batch,
|
||||||
|
all under the 300-line cap.
|
||||||
|
- **True parallel image generation.** `PRAutoBlogger_Runware_Image_Batch`
|
||||||
|
uses `curl_multi_init/exec/select` to fire all imageInference POSTs
|
||||||
|
concurrently, then downloads the signed URLs concurrently. Wall-clock
|
||||||
|
for the A/B pair drops to ≈ the slowest single image instead of the
|
||||||
|
sum of both.
|
||||||
|
- **v0.9.0 one-time migration.** Sites where the image model is empty
|
||||||
|
or still pinned to `google/gemini-2.5-flash-image` are auto-flipped
|
||||||
|
to `runware:100@1` on upgrade, with the provider re-derived from the
|
||||||
|
registry. Flag: `prautoblogger_migrated_default_image_v090`. The
|
||||||
|
Runware API key option (`prautoblogger_runware_api_key`) was added
|
||||||
|
to the `enc:`-prefix migration list so Settings writes go through
|
||||||
|
the same encryption-at-rest path as the other provider keys.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Image prompt builder steered away from abstract/chemical subjects.**
|
||||||
|
The rewriter system prompt now explicitly refuses to personify
|
||||||
|
molecules, peptides, hormones, or proteins — FLUX (and most diffusion
|
||||||
|
image models) render "anthropomorphic peptide" as an incoherent blob.
|
||||||
|
Default scene guidance is now "concrete, human-scale scenes: people
|
||||||
|
reacting to things, doctors and patients, gym bros with vials, etc."
|
||||||
|
Style suffix unchanged — single-panel comic aesthetic preserved. See
|
||||||
|
`includes/core/class-image-prompt-builder.php::REWRITER_SYSTEM_PROMPT`.
|
||||||
|
- **Default image model flipped to `runware:100@1` (FLUX.1 schnell).**
|
||||||
|
`PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER` and
|
||||||
|
`PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL` constants in `prautoblogger.php`
|
||||||
|
updated. Cloudflare flux-1-schnell remains in the registry as a legacy
|
||||||
|
fallback for any operators who set it explicitly. Rationale:
|
||||||
|
`image-style` memory (2026-04-21) — schnell passed the comic A/B
|
||||||
|
round and the cost delta funds more generations for the same monthly
|
||||||
|
budget.
|
||||||
|
|
||||||
|
### Cost impact
|
||||||
|
|
||||||
|
At 50 articles/day × 2 image A/B = 100 images/day:
|
||||||
|
|
||||||
|
- Before (Gemini 2.5 Flash Image via OpenRouter): ≈ $3.90/day, ≈ $117/mo
|
||||||
|
- After (FLUX.1 schnell via Runware): ≈ $0.06/day, ≈ $1.80/mo
|
||||||
|
|
||||||
|
Net saving: ~$115/mo at current traffic, funded by a single provider
|
||||||
|
swap with no user-visible change in article quality.
|
||||||
|
|
||||||
|
## [0.8.2] — 2026-04-21
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Daily generation schedule now honors site timezone.** Previously
|
||||||
|
the time configured in Settings → Schedule was interpreted as UTC
|
||||||
|
(WordPress core forces `date_default_timezone_set('UTC')`), causing
|
||||||
|
runs to fire N hours off from user intent where N is the site's UTC
|
||||||
|
offset. On peptiderepo.com (Asia/Singapore, UTC+8), a `schedule_time`
|
||||||
|
of `06:00` fired at 14:00 local. An on-upgrade one-shot migration
|
||||||
|
(`prautoblogger_migrated_schedule_tz_v082`) reschedules the existing
|
||||||
|
daily cron in the site's configured timezone — no user action
|
||||||
|
required.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Cloudflare Workers AI image calls now route through the AI Gateway
|
||||||
|
by default.** The 2026-04-15 regression that caused the gateway route
|
||||||
|
to 403 on Workers AI has been resolved upstream. Routing via the
|
||||||
|
gateway gives us response caching, unified cost/latency dashboard,
|
||||||
|
and rate limiting for free, mirroring the OpenRouter LLM path. A new
|
||||||
|
admin toggle `prautoblogger_cf_image_via_gateway` (default on) lets
|
||||||
|
operators force direct-API if the gateway route regresses again,
|
||||||
|
without clearing the gateway URL setting the LLM provider depends on.
|
||||||
|
- **"Generation Time" settings description** now names the site's
|
||||||
|
configured timezone explicitly (from `wp_timezone_string()`) instead
|
||||||
|
of the ambiguous "server timezone" label — operators can see at a
|
||||||
|
glance which zone their input is being interpreted in.
|
||||||
|
|
||||||
|
## [0.8.1] — 2026-04-21
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Orphaned LLM research costs now reaped daily.** When a pipeline died
|
||||||
|
between post creation and the final cost-amortization step (e.g.
|
||||||
|
execution-time kill on shared hosting), the `llm_research` row stayed
|
||||||
|
unlinked and the affected articles' cost popovers understated true spend.
|
||||||
|
A new daily cron (`prautoblogger_reap_orphan_research_rows`, 03:15
|
||||||
|
server time) retroactively amortizes orphan rows against posts from
|
||||||
|
the same run. Includes a one-time activator migration to back-populate
|
||||||
|
`_prautoblogger_run_id` post_meta for existing posts.
|
||||||
|
- **GA4 Property ID admin description corrected.** The field description
|
||||||
|
previously instructed users to enter `properties/XXXXXXXXX`, but the
|
||||||
|
GA4 client already prepends the `properties/` URL segment internally —
|
||||||
|
a double-prefixed value would 404. Description now asks for the digits
|
||||||
|
only, matching actual code behavior.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- New WP-CLI command `wp prautoblogger reap-research` for manual reaping
|
||||||
|
and ops debugging.
|
||||||
|
- `Publisher::build_meta()` now writes `_prautoblogger_run_id` on every
|
||||||
|
generated post, so the reaper has a direct meta path from run_id to
|
||||||
|
sibling posts without walking `wp_prautoblogger_generation_log`.
|
||||||
|
|
||||||
|
## [0.8.0] — 2026-04-21
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Single Image Model dropdown in admin → Images.** The separate Image
|
||||||
|
Provider select has been removed. The provider (OpenRouter or
|
||||||
|
Cloudflare Workers AI) is now derived from the chosen model's entry
|
||||||
|
in `PRAutoBlogger_Image_Model_Registry` on save, so mismatched
|
||||||
|
provider/model pairs — the root cause of posts 650 and 657 silently
|
||||||
|
missing their featured image on 2026-04-20 — are no longer possible.
|
||||||
|
A one-shot migration runs on first admin_init after upgrade and
|
||||||
|
auto-heals any existing site where the saved provider and model
|
||||||
|
disagreed. The `prautoblogger_image_provider` option key is
|
||||||
|
preserved; all existing reads in the pipeline continue to work.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Image Prompt Instructions setting.** The system prompt used to
|
||||||
|
rewrite articles into SCENE + CAPTION is now editable from admin →
|
||||||
|
Images. Default preserves prior behavior; edit to reshape the
|
||||||
|
creative direction without a code change.
|
||||||
|
- **Retry NSFW-Blocked Images setting** (default on). When the image
|
||||||
|
provider rejects a prompt as NSFW — Cloudflare Workers AI returns
|
||||||
|
HTTP 400 with `errors[].code === 3030` on FLUX.1 schnell —
|
||||||
|
PRAutoBlogger now retries the slot once with a rule-based fallback
|
||||||
|
prompt (article title + style suffix, no LLM rewrite). On a second
|
||||||
|
block it logs a WARNING and publishes without that image, matching
|
||||||
|
existing behaviour for any unrecoverable image failure.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Silent per-image failures in the event log.** The OpenRouter batch
|
||||||
|
provider now emits `Logger::error` with the request key for every
|
||||||
|
per-slot HTTP/cURL/parse failure (previously logged the error but
|
||||||
|
not which slot it was), and `Image_Pipeline::process_image_a/_b`
|
||||||
|
emit `Logger::warning` on `['error' => …]` results whether or not
|
||||||
|
the outer catch fires. Operators can now see NSFW blocks, 4xx
|
||||||
|
bodies, and timeouts in the admin Event Log without cross-
|
||||||
|
referencing PHP error_log timestamps.
|
||||||
|
|
||||||
|
## [0.7.3] — 2026-04-20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Table colors broken in dark mode.** Replaced hardcoded hex colors
|
||||||
|
with theme CSS custom properties (`--color-border-default`,
|
||||||
|
`--color-bg-secondary`, `--color-text-primary`) so table borders,
|
||||||
|
header backgrounds, and text adapt to light/dark mode automatically.
|
||||||
|
|
||||||
|
## [0.7.2] — 2026-04-20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Bullet points missing in articles.** Theme's global CSS reset strips
|
||||||
|
`list-style` from all `ul`/`ol`. Typography CSS now restores disc/decimal
|
||||||
|
bullets with proper padding inside `.entry-content`.
|
||||||
|
- **Tables break on mobile.** Tables now wrapped in a horizontally
|
||||||
|
scrollable container (`prab-table-wrap`) via `the_content` filter.
|
||||||
|
Added responsive media query for tighter padding and smaller font at
|
||||||
|
narrow viewports.
|
||||||
|
|
||||||
|
## [0.7.1] — 2026-04-20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Typography CSS not applying.** Two bugs: CSS targeted `.post-content`
|
||||||
|
but the theme template uses `.entry-content`, and the
|
||||||
|
`_prautoblogger_generated` meta gate excluded posts that predate the
|
||||||
|
meta flag. Display settings now apply to all single post pages and
|
||||||
|
target the correct `.entry-content` selector.
|
||||||
|
|
||||||
|
## [0.7.0] — 2026-04-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Display settings section.** New "Display" tab in admin with three
|
||||||
|
settings that control how generated articles render on the frontend:
|
||||||
|
- **Article Font** — choose from 7 font families (Inter, Georgia,
|
||||||
|
Merriweather, Lora, Open Sans, Roboto, System) or keep theme default.
|
||||||
|
Google Fonts are loaded only on PRAutoBlogger posts that need them.
|
||||||
|
- **Article Font Size** — set body text size in pixels (0 = theme
|
||||||
|
default). Recommended 16–18px for comfortable reading.
|
||||||
|
- **Table Borders** — adds visible borders, cell padding, header
|
||||||
|
background, and alternating row colors to tables. Enabled by default.
|
||||||
|
- **Article_Typography frontend class.** Injects inline CSS only on
|
||||||
|
singular post pages with `_prautoblogger_generated` meta — zero
|
||||||
|
impact on non-PRAutoBlogger pages.
|
||||||
|
|
||||||
|
## [0.6.3] — 2026-04-20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Unchecking all sources still used Reddit.** Browser omits checkbox fields
|
||||||
|
from POST when none are checked, so the `prautoblogger_enabled_sources`
|
||||||
|
option never updated. Added a hidden input so an empty value is always
|
||||||
|
submitted — unchecking all sources now correctly saves `[]`.
|
||||||
|
- **Pipeline status hardcoded "Reddit".** The "Collecting sources…" stage
|
||||||
|
message now reflects the actually-enabled sources instead of always
|
||||||
|
saying "Reddit".
|
||||||
|
|
||||||
|
## [0.6.2] — 2026-04-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Deterministic peptide auto-linker.** New `Peptide_Linker` class scans
|
||||||
|
generated HTML and wraps every mention of a known peptide in a hyperlink
|
||||||
|
to its `/peptides/{slug}/` database page. Runs as a post-processing step
|
||||||
|
in Publisher before `wp_insert_post` — no LLM involvement, 100% reliable.
|
||||||
|
Handles case-insensitive matching, hyphen/space variants (e.g., "BPC-157"
|
||||||
|
and "BPC 157"), and skips text already inside `<a>` tags. Gracefully
|
||||||
|
no-ops if PR Core is not active.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Removed prompt-based peptide linking.** The system prompt no longer asks
|
||||||
|
the LLM to link peptides (unreliable). Peptide links are now injected
|
||||||
|
deterministically by `Peptide_Linker` after content generation. The prompt
|
||||||
|
still provides article links and the "never fabricate URLs" rule.
|
||||||
|
|
||||||
|
## [0.6.1] — 2026-04-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Peptide auto-linking.** When any peptide name from the PR Core database is
|
||||||
|
mentioned in a generated article, the first mention is automatically linked to
|
||||||
|
the corresponding `/peptides/{slug}/` page. The system prompt now includes a
|
||||||
|
complete peptide database reference list alongside the existing article links.
|
||||||
|
Gracefully degrades if PR Core is not active (`post_type_exists()` guard).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Extracted prompt builders into Content_Prompts class.** Moved all prompt
|
||||||
|
construction (system prompt, stage prompts, linking rules) from
|
||||||
|
`Content_Generator` into a new `Content_Prompts` static helper. Generator
|
||||||
|
dropped from 324 → 162 lines; prompts class is 252 lines. Both under 300.
|
||||||
|
- **Internal link rules strengthened.** Linking rules section now explicitly
|
||||||
|
instructs the model to link every peptide's first mention and never fabricate
|
||||||
|
URLs, with real published article and peptide page URLs provided.
|
||||||
|
|
||||||
|
## [0.6.0] — 2026-04-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Generate article from idea.** New "Generate" column in the Ideas browser.
|
||||||
|
Click any idea's Generate button to produce an article directly, bypassing
|
||||||
|
the scheduled pipeline's collect → analyze → score steps. The button shows
|
||||||
|
real-time stage updates (drafting, editing, publishing) via per-idea status
|
||||||
|
polling, then swaps to Edit/View links with cost on completion.
|
||||||
|
- `Ideas_Browser::on_ajax_generate_from_idea()` — AJAX trigger that stores
|
||||||
|
the idea and schedules a one-shot WP-Cron event.
|
||||||
|
- `Ideas_Browser::on_cron_generate_from_idea()` — Background handler that
|
||||||
|
runs Article_Worker for the selected idea.
|
||||||
|
- `Ideas_Browser::on_ajax_idea_gen_status()` — Per-idea status polling.
|
||||||
|
- `assets/js/ideas-browser.js` — Button click, AJAX, polling, and UI state.
|
||||||
|
- Per-idea transients (`prab_idea_gen_{id}`) for independent status tracking.
|
||||||
|
- Page-load polling resume for ideas that were mid-generation.
|
||||||
|
- Generation lock respected — only one generation at a time.
|
||||||
|
|
||||||
|
## [0.5.3] — 2026-04-20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Writing instructions not obeyed by LLM.** The user-configured writing
|
||||||
|
instructions (bullet points, hyperlinks, style rules) were appended to the
|
||||||
|
system prompt as a weak "Additional instructions:" afterthought. Models
|
||||||
|
prioritized competing format directives in the user prompts and ignored
|
||||||
|
the style guide.
|
||||||
|
- System prompt now frames writing instructions as a "MANDATORY STYLE GUIDE"
|
||||||
|
with explicit override language.
|
||||||
|
- Every stage's user prompt (outline, draft, single-pass) now includes a
|
||||||
|
reinforcement line: "Follow EVERY requirement from your system prompt
|
||||||
|
style guide."
|
||||||
|
- Polish stage explicitly preserves bullet points, numbered lists, and
|
||||||
|
hyperlinks instead of flattening them.
|
||||||
|
- Chief editor's revision rules now preserve structural formatting (lists,
|
||||||
|
links) from the original draft.
|
||||||
|
|
||||||
|
## [0.5.2] — 2026-04-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **LLM research cost amortization.** The research LLM call (which runs once
|
||||||
|
per pipeline execution) is now divided evenly across all articles produced
|
||||||
|
in that run. Each article's cost breakdown popover shows its amortized share
|
||||||
|
of the research overhead. Previously, research costs were orphaned with no
|
||||||
|
post attribution.
|
||||||
|
- `Post_Assembler::amortize_research_costs()` — Queries the unlinked
|
||||||
|
research log entry, divides cost and tokens by article count, inserts one
|
||||||
|
row per article, and removes the original.
|
||||||
|
- `link_generation_logs()` now excludes `llm_research` stage from per-article
|
||||||
|
linking so the cost isn't grabbed wholesale by the first article.
|
||||||
|
- `Source_Collector::set_cost_tracker()` — Accepts the pipeline's cost
|
||||||
|
tracker so research costs are tagged with the pipeline's `run_id`.
|
||||||
|
- `LLM_Research_Provider` now uses the pipeline's cost tracker when available,
|
||||||
|
ensuring research log entries share the same `run_id` as article entries.
|
||||||
|
- Amortization runs after all articles complete (both single-article and
|
||||||
|
chained-job paths in `Pipeline_Runner`).
|
||||||
|
|
||||||
|
## [0.5.1] — 2026-04-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Ideas browser admin page.** New "Ideas" submenu under PRAutoBlogger showing
|
||||||
|
all article ideas collected by the content analyzer. Displays suggested title,
|
||||||
|
topic, type (question/complaint/comparison/news/guide), relevance score bar,
|
||||||
|
frequency, key points, and target keywords. Filterable by type with count
|
||||||
|
badges. Paginated at 30 per page, newest first.
|
||||||
|
|
||||||
|
## [0.5.0] — 2026-04-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **LLM Deep Research source provider.** New data source that sends a configurable
|
||||||
|
research prompt to a reasoning-capable model (e.g. Grok 4.1 Fast, DeepSeek-R1)
|
||||||
|
to identify trending topics, emerging questions, misconceptions, and content gaps.
|
||||||
|
Findings feed into the content analyzer alongside Reddit data, giving the pipeline
|
||||||
|
both real-time community signals AND deep knowledge-base research.
|
||||||
|
- `includes/providers/class-llm-research-provider.php` — Source provider implementation.
|
||||||
|
- New admin settings in Sources tab:
|
||||||
|
- "LLM Deep Research" checkbox in Enabled Sources.
|
||||||
|
- "Research Model" model picker — pick a reasoning-capable model.
|
||||||
|
- "Research Prompt" textarea — customizable research brief with `{niche}` placeholder.
|
||||||
|
- Each research run produces 8-12 findings stored as `llm_research` source data.
|
||||||
|
- Deduplication via date-based source IDs (one fresh run per day, refreshes on re-run).
|
||||||
|
- Cost tracked as `llm_research` stage in generation logs.
|
||||||
|
- Content analyzer now formats LLM research findings with a cleaner label
|
||||||
|
(`[LLM Research] Relevance: N`) instead of the Reddit-specific `r/subreddit` format.
|
||||||
|
|
||||||
|
## [0.4.9] — 2026-04-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **OpenRouter reasoning mode support.** New "Enable Reasoning" toggle and
|
||||||
|
"Reasoning Effort" selector in AI Models settings. When enabled, sends
|
||||||
|
`reasoning: {enabled: true, effort: "<level>"}` to models that support it
|
||||||
|
(Grok 4.1 Fast, DeepSeek-R1, etc.). Effort levels: Extra High, High,
|
||||||
|
Medium, Low, Minimal. Reasoning tokens are billed as output tokens — cost
|
||||||
|
is automatically tracked. Models that don't support reasoning ignore the
|
||||||
|
parameter. Per-call override available via the `reasoning` key in the
|
||||||
|
provider options array.
|
||||||
|
- Response parser now captures `reasoning_tokens` and `reasoning_content`
|
||||||
|
from the OpenRouter response for downstream logging and debugging.
|
||||||
|
|
||||||
|
## [0.4.8] — 2026-04-19
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **GLM 4.7 Flash timeout during analysis.** The model was timing out at 120s on
|
||||||
|
large analysis prompts (especially with the new custom instructions). Bumped
|
||||||
|
API timeout from 120s to 180s.
|
||||||
|
- **Empty raw response in error logs.** Parse failure errors now include the raw
|
||||||
|
LLM response inline (first 500 chars) at error level, not debug level, so
|
||||||
|
the actual failure reason is always visible in the Activity Log.
|
||||||
|
|
||||||
|
## [0.4.7] — 2026-04-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Cost breakdown popover.** The Cost column in the post list is now clickable.
|
||||||
|
Hovering or clicking shows a per-step breakdown: stage name, model used, token
|
||||||
|
count, and cost for every LLM call that produced the article.
|
||||||
|
|
||||||
|
## [0.4.6] — 2026-04-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Image B toggle.** New "Generate Second Image (B)" toggle in Images settings.
|
||||||
|
Disabling saves one image generation call + one LLM prompt rewrite per article.
|
||||||
|
Defaults to enabled for backwards compatibility.
|
||||||
|
|
||||||
|
## [0.4.5] — 2026-04-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Analysis Instructions setting.** Custom instructions appended to the analysis
|
||||||
|
LLM's system prompt. Steers how source data is evaluated and which topic ideas
|
||||||
|
get surfaced.
|
||||||
|
- **Editor Instructions setting.** Custom instructions appended to the chief
|
||||||
|
editor's system prompt. Controls what the editorial review looks for and how
|
||||||
|
it decides to approve, revise, or reject articles.
|
||||||
|
|
||||||
|
## [0.4.4] — 2026-04-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Writing Instructions setting.** New textarea in Content settings lets you
|
||||||
|
provide custom instructions appended to the LLM's system prompt when writing
|
||||||
|
articles. Use it to steer style, structure, voice, and formatting without
|
||||||
|
editing code.
|
||||||
|
|
||||||
|
## [0.4.3] — 2026-04-19
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **"Analysis response was not valid JSON" with some models.** Models like
|
||||||
|
GPT 5.1 Nano ignore `response_format: json_object` and wrap JSON in markdown
|
||||||
|
fences or preamble text. New `PRAutoBlogger_Json_Extractor` utility strips
|
||||||
|
fences and extracts the outermost JSON object. Applied to both the content
|
||||||
|
analyzer and chief editor parsers. Raw response now logged on failure for
|
||||||
|
debugging.
|
||||||
|
|
||||||
|
## [0.4.2] — 2026-04-19
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Posts list Title column unreadable.** Title was compressed to ~30px by the
|
||||||
|
three custom columns. Title now gets 35% minimum width with word-wrap enabled.
|
||||||
|
Model columns trimmed to 100px with text-overflow ellipsis for long model names.
|
||||||
|
|
||||||
|
## [0.4.1] — 2026-04-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Posts list columns: Writing Model, Image Model, Cost.** Three new columns
|
||||||
|
on the WordPress Posts admin page. Writing Model shows the LLM that wrote the
|
||||||
|
article, Image Model shows the model that generated the featured image, and
|
||||||
|
Cost shows the total API spend for generating that article (summed from the
|
||||||
|
generation log). All columns show "—" for non-generated posts. Writing Model
|
||||||
|
is sortable. Model names are shortened (provider prefix stripped) with full
|
||||||
|
ID in a tooltip.
|
||||||
|
|
||||||
|
## [0.4.0] — 2026-04-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Semantic dedup via embedding cosine similarity.** Replaces keyword-overlap
|
||||||
|
dedup (60% word match) with MiniLM-L12-v2 embeddings via OpenRouter. Catches
|
||||||
|
rephrasings like "BPC-157 dosing" vs "how much BPC-157 to take" that keyword
|
||||||
|
matching misses. Automatic fallback to keywords if embedding API unavailable.
|
||||||
|
Cost: ~$0.00001 per generation run.
|
||||||
|
- `includes/core/class-semantic-dedup.php` — Dedup engine with embedding +
|
||||||
|
keyword fallback.
|
||||||
|
- `includes/providers/class-open-router-embedding-provider.php` — OpenRouter
|
||||||
|
`/embeddings` client with batch support and cosine similarity helper.
|
||||||
|
|
||||||
|
- **LLM-aware topic avoidance.** Analysis prompt now includes the last 30 days
|
||||||
|
of published article titles with explicit "do not suggest similar topics"
|
||||||
|
instruction. Zero additional cost — appended to the existing analysis call.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Dedup window widened from 7 days to 30 days (semantic similarity is precise
|
||||||
|
enough to avoid over-blocking).
|
||||||
|
- `class-idea-scorer.php` refactored to delegate dedup to `Semantic_Dedup`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Featured images displayed at 300×300 (WordPress "medium" size) instead of full
|
||||||
|
width. Changed theme to use `the_post_thumbnail('full')` with responsive CSS.
|
||||||
|
- Race condition causing duplicate article generation when the AJAX status poller
|
||||||
|
re-scheduled a cron event for an article already being generated.
|
||||||
|
- False stall detection on multi-article runs (measured elapsed time from start
|
||||||
|
instead of idle time since last progress update).
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -40,8 +540,8 @@ and this project uses [Semantic Versioning](https://semver.org/).
|
|||||||
list (`/api/v1/models` — free, unauthenticated). Daily refresh via the
|
list (`/api/v1/models` — free, unauthenticated). Daily refresh via the
|
||||||
existing `prautoblogger_daily_generation` cron hook with 12h idempotency.
|
existing `prautoblogger_daily_generation` cron hook with 12h idempotency.
|
||||||
- `includes/services/interface-model-registry.php` — Phase 3-aware contract.
|
- `includes/services/interface-model-registry.php` — Phase 3-aware contract.
|
||||||
- `includes/services/class-openrouter-model-registry.php` — fetch + cache + query.
|
- `includes/services/class-open-router-model-registry.php` — fetch + cache + query.
|
||||||
- `includes/services/class-openrouter-model-normalizer.php` — raw → standardized shape.
|
- `includes/services/class-open-router-model-normalizer.php` — raw → standardized shape.
|
||||||
- Capability vocabulary: `text→text`, `text+image→text`, `text→embedding`, etc.
|
- Capability vocabulary: `text→text`, `text+image→text`, `text→embedding`, etc.
|
||||||
- Zero-coupling: no PRAUTOBLOGGER_* constants inside the class — Phase 2 lift
|
- Zero-coupling: no PRAUTOBLOGGER_* constants inside the class — Phase 2 lift
|
||||||
into a shared Composer package requires only a namespace rename.
|
into a shared Composer package requires only a namespace rename.
|
||||||
|
|||||||
206
assets/css/model-picker.css
Normal file
206
assets/css/model-picker.css
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* Model picker popup styles for PRAutoBlogger admin.
|
||||||
|
*
|
||||||
|
* Renders a modal overlay with a searchable list of AI models.
|
||||||
|
* Uses the same design tokens as admin.css (--ab-* CSS variables).
|
||||||
|
*
|
||||||
|
* @see assets/js/model-picker.js — JS that builds and manages the popup DOM.
|
||||||
|
* @see assets/css/admin.css — Parent design system variables.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Overlay ────────────────────────────────────────────────────────── */
|
||||||
|
.ab-mp-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 100100;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
animation: ab-mp-fade-in 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ab-mp-fade-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Popup container ────────────────────────────────────────────────── */
|
||||||
|
.ab-mp-popup {
|
||||||
|
background: var(--ab-card-bg, #fff);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
|
||||||
|
width: 640px; max-width: 90vw;
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
animation: ab-mp-slide-up 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ab-mp-slide-up {
|
||||||
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ─────────────────────────────────────────────────────────── */
|
||||||
|
.ab-mp-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 16px 20px; border-bottom: 1px solid var(--ab-border, #e2e8f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-mp-title {
|
||||||
|
font-size: 16px; font-weight: 700;
|
||||||
|
color: var(--ab-text, #1e293b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-mp-close {
|
||||||
|
background: none; border: none; cursor: pointer;
|
||||||
|
font-size: 22px; line-height: 1; color: var(--ab-text-muted, #64748b);
|
||||||
|
padding: 4px 8px; border-radius: 4px;
|
||||||
|
}
|
||||||
|
.ab-mp-close:hover {
|
||||||
|
background: var(--ab-bg, #f8fafc);
|
||||||
|
color: var(--ab-text, #1e293b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Search ─────────────────────────────────────────────────────────── */
|
||||||
|
.ab-mp-search-wrap {
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-bottom: 1px solid var(--ab-border, #e2e8f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-mp-search {
|
||||||
|
width: 100%; box-sizing: border-box;
|
||||||
|
padding: 10px 14px; font-size: 14px;
|
||||||
|
border: 1px solid var(--ab-border, #e2e8f0);
|
||||||
|
border-radius: 8px; outline: none;
|
||||||
|
color: var(--ab-text, #1e293b);
|
||||||
|
background: var(--ab-bg, #f8fafc);
|
||||||
|
}
|
||||||
|
.ab-mp-search:focus {
|
||||||
|
border-color: var(--ab-primary, #4f46e5);
|
||||||
|
box-shadow: 0 0 0 3px var(--ab-primary-light, #eef2ff);
|
||||||
|
background: var(--ab-card-bg, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Model list ─────────────────────────────────────────────────────── */
|
||||||
|
.ab-mp-list {
|
||||||
|
flex: 1; overflow-y: auto;
|
||||||
|
padding: 4px 0;
|
||||||
|
min-height: 200px; max-height: 50vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-mp-loading, .ab-mp-empty {
|
||||||
|
padding: 40px 20px; text-align: center;
|
||||||
|
color: var(--ab-text-muted, #64748b); font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Model row ──────────────────────────────────────────────────────── */
|
||||||
|
.ab-mp-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 10px 20px; cursor: pointer;
|
||||||
|
transition: background 0.1s ease;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.ab-mp-row:hover {
|
||||||
|
background: var(--ab-primary-light, #eef2ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-mp-row-main {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
flex: 1; min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-mp-name {
|
||||||
|
font-size: 13px; font-weight: 600;
|
||||||
|
color: var(--ab-text, #1e293b);
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-mp-provider {
|
||||||
|
font-size: 11px; font-weight: 500;
|
||||||
|
color: var(--ab-primary, #4f46e5);
|
||||||
|
background: var(--ab-primary-light, #eef2ff);
|
||||||
|
padding: 1px 6px; border-radius: 4px;
|
||||||
|
white-space: nowrap; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-mp-dep-badge {
|
||||||
|
font-size: 10px; font-weight: 600;
|
||||||
|
color: var(--ab-warning, #d97706);
|
||||||
|
background: #fffbeb;
|
||||||
|
padding: 1px 6px; border-radius: 4px;
|
||||||
|
white-space: nowrap; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-mp-deprecated {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-mp-row-meta {
|
||||||
|
display: flex; align-items: center; gap: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-mp-price {
|
||||||
|
font-size: 12px; font-weight: 500;
|
||||||
|
color: var(--ab-text-muted, #64748b);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-mp-ctx {
|
||||||
|
font-size: 11px; font-weight: 600;
|
||||||
|
color: var(--ab-text-muted, #64748b);
|
||||||
|
background: var(--ab-bg, #f8fafc);
|
||||||
|
padding: 2px 6px; border-radius: 4px;
|
||||||
|
white-space: nowrap; min-width: 36px; text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Footer ─────────────────────────────────────────────────────────── */
|
||||||
|
.ab-mp-footer {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-top: 1px solid var(--ab-border, #e2e8f0);
|
||||||
|
font-size: 12px; color: var(--ab-text-muted, #64748b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Trigger button (replaces text input) ───────────────────────────── */
|
||||||
|
.ab-mp-trigger {
|
||||||
|
display: inline-flex; align-items: center; gap: 10px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: var(--ab-card-bg, #fff);
|
||||||
|
border: 1px solid var(--ab-border, #e2e8f0);
|
||||||
|
border-radius: 6px; cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
max-width: 420px; width: 100%; box-sizing: border-box;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.ab-mp-trigger:hover {
|
||||||
|
border-color: var(--ab-primary, #4f46e5);
|
||||||
|
}
|
||||||
|
.ab-mp-trigger:focus {
|
||||||
|
border-color: var(--ab-primary, #4f46e5);
|
||||||
|
box-shadow: 0 0 0 3px var(--ab-primary-light, #eef2ff);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-mp-display-name {
|
||||||
|
font-size: 13px; font-weight: 600;
|
||||||
|
color: var(--ab-text, #1e293b);
|
||||||
|
flex: 1; min-width: 0;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-mp-display-price {
|
||||||
|
font-size: 11px; font-weight: 500;
|
||||||
|
color: var(--ab-text-muted, #64748b);
|
||||||
|
white-space: nowrap; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ab-mp-display-arrow {
|
||||||
|
color: var(--ab-text-muted, #64748b);
|
||||||
|
font-size: 14px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ─────────────────────────────────────────────────────── */
|
||||||
|
@media screen and (max-width: 680px) {
|
||||||
|
.ab-mp-popup { width: 95vw; max-height: 85vh; }
|
||||||
|
.ab-mp-row { padding: 10px 14px; }
|
||||||
|
.ab-mp-row-meta { gap: 8px; }
|
||||||
|
}
|
||||||
@@ -36,6 +36,14 @@
|
|||||||
url.searchParams.set('tab', tab);
|
url.searchParams.set('tab', tab);
|
||||||
window.history.replaceState({}, '', url);
|
window.history.replaceState({}, '', url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update the _wp_http_referer so WordPress redirects back to this tab after save.
|
||||||
|
var $referer = $('#ab-settings-form input[name="_wp_http_referer"]');
|
||||||
|
if ($referer.length) {
|
||||||
|
var refUrl = new URL($referer.val(), window.location.origin);
|
||||||
|
refUrl.searchParams.set('tab', tab);
|
||||||
|
$referer.val(refUrl.pathname + refUrl.search);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -55,6 +63,37 @@
|
|||||||
$notice.fadeOut(400, function () { $(this).remove(); });
|
$notice.fadeOut(400, function () { $(this).remove(); });
|
||||||
}, 6000);
|
}, 6000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On load, sync _wp_http_referer with the current tab so the first
|
||||||
|
// save without switching tabs still returns to the right place.
|
||||||
|
var currentTab = new URL(window.location).searchParams.get('tab');
|
||||||
|
if (currentTab) {
|
||||||
|
var $referer = $('#ab-settings-form input[name="_wp_http_referer"]');
|
||||||
|
if ($referer.length) {
|
||||||
|
var refUrl = new URL($referer.val(), window.location.origin);
|
||||||
|
refUrl.searchParams.set('tab', currentTab);
|
||||||
|
$referer.val(refUrl.pathname + refUrl.search);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resume polling if a generation is already running (e.g., user
|
||||||
|
// navigated away and came back, or opened a new tab mid-generation).
|
||||||
|
var $genBtn = $('#prautoblogger-generate-now');
|
||||||
|
if ($genBtn.length && config.ajaxUrl) {
|
||||||
|
$.ajax({
|
||||||
|
url: config.ajaxUrl,
|
||||||
|
method: 'POST',
|
||||||
|
data: { action: 'prautoblogger_generation_status', nonce: config.generateNonce },
|
||||||
|
timeout: 10000,
|
||||||
|
success: function (response) {
|
||||||
|
if (response.success && response.data && response.data.status === 'running') {
|
||||||
|
setButtonLoading($genBtn, config.generatingText || 'Generating…');
|
||||||
|
showProgress(response.data.stage || 'Generation in progress…');
|
||||||
|
pollGenerationStatus($genBtn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -126,29 +165,76 @@
|
|||||||
* Shows pipeline stages while the request is in-flight so the user
|
* Shows pipeline stages while the request is in-flight so the user
|
||||||
* knows it hasn't hung.
|
* knows it hasn't hung.
|
||||||
*/
|
*/
|
||||||
var generateStages = [
|
/*
|
||||||
{ text: 'Collecting sources from Reddit…', delay: 0 },
|
* Pipeline stages are now reported by the server via the status polling
|
||||||
{ text: 'Analyzing topics and scoring…', delay: 8000 },
|
* endpoint. The old client-side fake timers have been removed.
|
||||||
{ text: 'Selecting best topic…', delay: 16000 },
|
*/
|
||||||
{ text: 'Generating article draft via AI…', delay: 24000 },
|
|
||||||
{ text: 'Running editorial pass…', delay: 50000 },
|
/** Poll interval handle for generation status checks. */
|
||||||
{ text: 'Saving and publishing…', delay: 75000 },
|
var statusPollTimer = null;
|
||||||
{ text: 'Almost done — finalizing…', delay: 100000 }
|
|
||||||
];
|
/**
|
||||||
var stageTimers = [];
|
* Poll the generation status endpoint until the run finishes.
|
||||||
|
* Updates the progress UI and resolves the button state on completion.
|
||||||
|
*
|
||||||
|
* @param {jQuery} $btn The Generate Now button.
|
||||||
|
*/
|
||||||
|
function pollGenerationStatus($btn) {
|
||||||
|
statusPollTimer = setInterval(function () {
|
||||||
|
$.ajax({
|
||||||
|
url: config.ajaxUrl,
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
action: 'prautoblogger_generation_status',
|
||||||
|
nonce: config.generateNonce
|
||||||
|
},
|
||||||
|
timeout: 15000,
|
||||||
|
success: function (response) {
|
||||||
|
if (!response.success) return;
|
||||||
|
var d = response.data;
|
||||||
|
|
||||||
|
if (d.status === 'running') {
|
||||||
|
showProgress(d.stage || 'Generating…');
|
||||||
|
return; // Keep polling.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminal state — stop polling.
|
||||||
|
clearInterval(statusPollTimer);
|
||||||
|
statusPollTimer = null;
|
||||||
|
hideProgress();
|
||||||
|
resetButton($btn, config.generateText || 'Generate Now');
|
||||||
|
|
||||||
|
if (d.status === 'complete') {
|
||||||
|
showStatus(
|
||||||
|
'Generation complete: ' + d.generated + ' generated, ' +
|
||||||
|
d.published + ' published, ' + d.rejected + ' rejected. ' +
|
||||||
|
'Cost: $' + parseFloat(d.cost).toFixed(4),
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
} else if (d.status === 'error') {
|
||||||
|
showStatus(
|
||||||
|
'Generation failed: ' + (d.message || 'Unknown error'),
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// idle or unknown — generation may have finished before
|
||||||
|
// polling started. Reset the button cleanly.
|
||||||
|
hideProgress();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function () {
|
||||||
|
// Network blip — keep polling, don't bail on transient errors.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 5000); // Poll every 5 seconds.
|
||||||
|
}
|
||||||
|
|
||||||
$(document).on('click', '#prautoblogger-generate-now', function () {
|
$(document).on('click', '#prautoblogger-generate-now', function () {
|
||||||
var $btn = $(this);
|
var $btn = $(this);
|
||||||
if ($btn.prop('disabled')) return;
|
if ($btn.prop('disabled')) return;
|
||||||
|
|
||||||
setButtonLoading($btn, config.generatingText || 'Generating…');
|
setButtonLoading($btn, config.generatingText || 'Generating…');
|
||||||
|
showProgress('Starting generation…');
|
||||||
// Start pipeline stage messages.
|
|
||||||
stageTimers = [];
|
|
||||||
$.each(generateStages, function (i, stage) {
|
|
||||||
var t = setTimeout(function () { showProgress(stage.text); }, stage.delay);
|
|
||||||
stageTimers.push(t);
|
|
||||||
});
|
|
||||||
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: config.ajaxUrl,
|
url: config.ajaxUrl,
|
||||||
@@ -158,21 +244,19 @@
|
|||||||
nonce: config.generateNonce,
|
nonce: config.generateNonce,
|
||||||
force: '1' // Always clear stale locks on manual runs.
|
force: '1' // Always clear stale locks on manual runs.
|
||||||
},
|
},
|
||||||
timeout: 300000, // 5 minutes — generation can be slow.
|
timeout: 30000, // 30s is plenty — this just schedules the cron.
|
||||||
success: function (response) {
|
success: function (response) {
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
var d = response.data;
|
// Generation kicked off in background — start polling.
|
||||||
showStatus(
|
showProgress(response.data.message || 'Generation started…');
|
||||||
'Generation complete: ' + d.generated + ' generated, ' +
|
pollGenerationStatus($btn);
|
||||||
d.published + ' published, ' + d.rejected + ' rejected. ' +
|
|
||||||
'Cost: $' + parseFloat(d.cost).toFixed(4),
|
|
||||||
'success'
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
showStatus(
|
showStatus(
|
||||||
'Generation failed: ' + (response.data && response.data.message || 'Unknown error'),
|
'Generation failed: ' + (response.data && response.data.message || 'Unknown error'),
|
||||||
'error'
|
'error'
|
||||||
);
|
);
|
||||||
|
hideProgress();
|
||||||
|
resetButton($btn, config.generateText || 'Generate Now');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: function (xhr, status) {
|
error: function (xhr, status) {
|
||||||
@@ -180,11 +264,6 @@
|
|||||||
'Request failed: ' + status + '. Check server error logs.',
|
'Request failed: ' + status + '. Check server error logs.',
|
||||||
'error'
|
'error'
|
||||||
);
|
);
|
||||||
},
|
|
||||||
complete: function () {
|
|
||||||
// Clear all stage timers.
|
|
||||||
$.each(stageTimers, function (_, t) { clearTimeout(t); });
|
|
||||||
stageTimers = [];
|
|
||||||
hideProgress();
|
hideProgress();
|
||||||
resetButton($btn, config.generateText || 'Generate Now');
|
resetButton($btn, config.generateText || 'Generate Now');
|
||||||
}
|
}
|
||||||
|
|||||||
157
assets/js/ideas-browser.js
Normal file
157
assets/js/ideas-browser.js
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* PRAutoBlogger Ideas Browser — "Generate Article" button handler.
|
||||||
|
*
|
||||||
|
* Each idea row has a Generate button that triggers single-article generation
|
||||||
|
* via AJAX → WP-Cron background job. Polls for status and shows stage updates
|
||||||
|
* in the button cell, then swaps to a "View" link on completion.
|
||||||
|
*
|
||||||
|
* @see admin/class-ideas-browser.php — AJAX handlers.
|
||||||
|
* @see templates/admin/ideas-browser.php — Button markup.
|
||||||
|
*/
|
||||||
|
(function ($) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var config = window.prautobloggerAdmin || {};
|
||||||
|
var pollTimers = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kick off generation for an idea and start polling.
|
||||||
|
*
|
||||||
|
* @param {jQuery} $cell The table cell containing the button.
|
||||||
|
* @param {number} ideaId Analysis result row ID.
|
||||||
|
*/
|
||||||
|
function generateFromIdea($cell, ideaId) {
|
||||||
|
$cell.html(
|
||||||
|
'<span class="dashicons dashicons-update ab-spin"></span> ' +
|
||||||
|
'<span class="prab-idea-stage">Starting…</span>'
|
||||||
|
);
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: config.ajaxUrl,
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
action: 'prautoblogger_generate_from_idea',
|
||||||
|
nonce: config.ideaGenNonce,
|
||||||
|
idea_id: ideaId
|
||||||
|
},
|
||||||
|
timeout: 15000,
|
||||||
|
success: function (response) {
|
||||||
|
if (response.success) {
|
||||||
|
pollIdeaStatus($cell, ideaId);
|
||||||
|
} else {
|
||||||
|
showIdeaError($cell, ideaId, response.data && response.data.message || 'Failed to start.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function () {
|
||||||
|
showIdeaError($cell, ideaId, 'Request failed.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll the per-idea status endpoint until generation completes.
|
||||||
|
*
|
||||||
|
* @param {jQuery} $cell The table cell to update.
|
||||||
|
* @param {number} ideaId Analysis result row ID.
|
||||||
|
*/
|
||||||
|
function pollIdeaStatus($cell, ideaId) {
|
||||||
|
pollTimers[ideaId] = setInterval(function () {
|
||||||
|
$.ajax({
|
||||||
|
url: config.ajaxUrl,
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
action: 'prautoblogger_idea_gen_status',
|
||||||
|
nonce: config.ideaGenNonce,
|
||||||
|
idea_id: ideaId
|
||||||
|
},
|
||||||
|
timeout: 10000,
|
||||||
|
success: function (response) {
|
||||||
|
if (!response.success) return;
|
||||||
|
var d = response.data;
|
||||||
|
|
||||||
|
if (d.status === 'running') {
|
||||||
|
$cell.find('.prab-idea-stage').text(d.stage || 'Generating…');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminal — stop polling.
|
||||||
|
clearInterval(pollTimers[ideaId]);
|
||||||
|
delete pollTimers[ideaId];
|
||||||
|
|
||||||
|
if (d.status === 'complete') {
|
||||||
|
showIdeaComplete($cell, ideaId, d);
|
||||||
|
} else if (d.status === 'error') {
|
||||||
|
showIdeaError($cell, ideaId, d.message || 'Generation failed.');
|
||||||
|
} else {
|
||||||
|
showIdeaError($cell, ideaId, 'Unknown status.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the completion state with a View link.
|
||||||
|
*
|
||||||
|
* @param {jQuery} $cell The table cell.
|
||||||
|
* @param {number} ideaId Analysis result row ID.
|
||||||
|
* @param {Object} data Server response data.
|
||||||
|
*/
|
||||||
|
function showIdeaComplete($cell, ideaId, data) {
|
||||||
|
var html = '<span class="dashicons dashicons-yes-alt" style="color:#00a32a;"></span> ';
|
||||||
|
html += '<span style="color:#00a32a;">Done</span>';
|
||||||
|
|
||||||
|
if (data.post_id) {
|
||||||
|
var editUrl = config.adminUrl + 'post.php?post=' + data.post_id + '&action=edit';
|
||||||
|
var viewUrl = config.siteUrl + '?p=' + data.post_id;
|
||||||
|
html += ' — <a href="' + editUrl + '">Edit</a>';
|
||||||
|
html += ' | <a href="' + viewUrl + '" target="_blank">View</a>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.cost > 0) {
|
||||||
|
html += '<div style="font-size:11px;color:#666;margin-top:2px;">$' + parseFloat(data.cost).toFixed(4) + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$cell.html(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show an error state with a retry button.
|
||||||
|
*
|
||||||
|
* @param {jQuery} $cell The table cell.
|
||||||
|
* @param {number} ideaId Analysis result row ID.
|
||||||
|
* @param {string} message Error message.
|
||||||
|
*/
|
||||||
|
function showIdeaError($cell, ideaId, message) {
|
||||||
|
$cell.html(
|
||||||
|
'<span class="dashicons dashicons-warning" style="color:#d63638;"></span> ' +
|
||||||
|
'<span style="color:#d63638; font-size:12px;">' + $('<span>').text(message).html() + '</span>' +
|
||||||
|
'<br><button type="button" class="button button-small prab-gen-idea-btn" data-idea-id="' + ideaId + '" style="margin-top:4px;">Retry</button>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click handler for Generate / Retry buttons (event delegation).
|
||||||
|
$(document).on('click', '.prab-gen-idea-btn', function () {
|
||||||
|
var $btn = $(this);
|
||||||
|
if ($btn.prop('disabled')) return;
|
||||||
|
|
||||||
|
var ideaId = $btn.data('idea-id');
|
||||||
|
var $cell = $btn.closest('td');
|
||||||
|
|
||||||
|
generateFromIdea($cell, ideaId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// On page load, resume polling for any ideas that are mid-generation.
|
||||||
|
$(function () {
|
||||||
|
$('.prab-idea-gen-cell[data-status="running"]').each(function () {
|
||||||
|
var $cell = $(this);
|
||||||
|
var ideaId = $cell.data('idea-id');
|
||||||
|
$cell.html(
|
||||||
|
'<span class="dashicons dashicons-update ab-spin"></span> ' +
|
||||||
|
'<span class="prab-idea-stage">Resuming…</span>'
|
||||||
|
);
|
||||||
|
pollIdeaStatus($cell, ideaId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
})(jQuery);
|
||||||
285
assets/js/model-picker.js
Normal file
285
assets/js/model-picker.js
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* @see admin/class-admin-page.php — Renders model_select fields and localizes config.
|
||||||
|
* @see services/class-open-router-model-registry.php — Server-side registry source.
|
||||||
|
*/
|
||||||
|
(function ($) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var config = window.prautobloggerAdmin || {};
|
||||||
|
var cachedModels = null;
|
||||||
|
var fetchInProgress = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch models from the server (cached after first call).
|
||||||
|
*
|
||||||
|
* @param {string} capability Filter capability (e.g. 'text→text').
|
||||||
|
* @return {jQuery.Deferred} Resolves with model array.
|
||||||
|
*/
|
||||||
|
function fetchModels(capability) {
|
||||||
|
// Image models are embedded in the page config — no AJAX needed.
|
||||||
|
if (capability === 'image_generation') {
|
||||||
|
var imgModels = config.imageModels || [];
|
||||||
|
return $.Deferred().resolve(imgModels);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cachedModels) {
|
||||||
|
return $.Deferred().resolve(filterModels(cachedModels, capability));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fetchInProgress) {
|
||||||
|
return fetchInProgress.then(function () {
|
||||||
|
return filterModels(cachedModels, capability);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchInProgress = $.ajax({
|
||||||
|
url: config.ajaxUrl,
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
action: 'prautoblogger_get_models',
|
||||||
|
nonce: config.modelsNonce
|
||||||
|
},
|
||||||
|
timeout: 15000
|
||||||
|
}).then(function (response) {
|
||||||
|
fetchInProgress = null;
|
||||||
|
if (response.success && Array.isArray(response.data)) {
|
||||||
|
cachedModels = response.data;
|
||||||
|
return filterModels(cachedModels, capability);
|
||||||
|
}
|
||||||
|
cachedModels = [];
|
||||||
|
return [];
|
||||||
|
}, function () {
|
||||||
|
fetchInProgress = null;
|
||||||
|
cachedModels = [];
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
return fetchInProgress;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter model array by capability.
|
||||||
|
*
|
||||||
|
* @param {Array} models Full model list.
|
||||||
|
* @param {string} capability Capability string to match.
|
||||||
|
* @return {Array} Filtered models.
|
||||||
|
*/
|
||||||
|
function filterModels(models, capability) {
|
||||||
|
if (!capability) return models;
|
||||||
|
return models.filter(function (m) {
|
||||||
|
return m.capabilities && m.capabilities.indexOf(capability) !== -1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a price-per-million-tokens value for display.
|
||||||
|
*
|
||||||
|
* @param {number} price Price per 1M tokens.
|
||||||
|
* @return {string} Formatted string like "$0.25" or "Free".
|
||||||
|
*/
|
||||||
|
function formatPrice(price) {
|
||||||
|
if (!price || price <= 0) return 'Free';
|
||||||
|
if (price < 0.01) return '$' + price.toFixed(4);
|
||||||
|
if (price < 1) return '$' + price.toFixed(2);
|
||||||
|
return '$' + price.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format context length for display.
|
||||||
|
*
|
||||||
|
* @param {number} ctx Context length in tokens.
|
||||||
|
* @return {string} Formatted string like "128K" or "1M".
|
||||||
|
*/
|
||||||
|
function formatContext(ctx) {
|
||||||
|
if (!ctx) return '—';
|
||||||
|
if (ctx >= 1000000) return (ctx / 1000000).toFixed(0) + 'M';
|
||||||
|
if (ctx >= 1000) return Math.round(ctx / 1000) + 'K';
|
||||||
|
return ctx.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build HTML for a single model row in the picker list.
|
||||||
|
*
|
||||||
|
* @param {Object} model Normalized model record.
|
||||||
|
* @return {string} HTML string.
|
||||||
|
*/
|
||||||
|
function renderModelRow(model) {
|
||||||
|
var deprecated = model.deprecated ? ' ab-mp-deprecated' : '';
|
||||||
|
var isImageModel = model.cost_per_image !== undefined && model.cost_per_image > 0;
|
||||||
|
var priceHtml, priceTitle, metaRight;
|
||||||
|
|
||||||
|
if (isImageModel) {
|
||||||
|
priceHtml = '$' + model.cost_per_image.toFixed(4) + '/image';
|
||||||
|
priceTitle = 'Cost per generated image';
|
||||||
|
metaRight = model.description ? '<span class="ab-mp-ctx" title="Details">' + escHtml(model.description) + '</span>' : '';
|
||||||
|
} else {
|
||||||
|
priceHtml = formatPrice(model.input_price_per_m) + ' / ' + formatPrice(model.output_price_per_m);
|
||||||
|
priceTitle = 'Input / Output per 1M tokens';
|
||||||
|
metaRight = '<span class="ab-mp-ctx" title="Context window">' + formatContext(model.context_length) + '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
'<div class="ab-mp-row' + deprecated + '" data-model-id="' + escAttr(model.id) + '">' +
|
||||||
|
'<div class="ab-mp-row-main">' +
|
||||||
|
'<span class="ab-mp-name">' + escHtml(model.name) + '</span>' +
|
||||||
|
'<span class="ab-mp-provider">' + escHtml(model.provider) + '</span>' +
|
||||||
|
(model.deprecated ? '<span class="ab-mp-dep-badge">deprecated</span>' : '') +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="ab-mp-row-meta">' +
|
||||||
|
'<span class="ab-mp-price" title="' + priceTitle + '">' + priceHtml + '</span>' +
|
||||||
|
metaRight +
|
||||||
|
'</div>' +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal HTML escaping for display. */
|
||||||
|
function escHtml(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escape a string for use in an HTML attribute. */
|
||||||
|
function escAttr(str) {
|
||||||
|
return escHtml(str).replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the model picker popup for a given trigger button.
|
||||||
|
*
|
||||||
|
* @param {jQuery} $trigger The .ab-mp-trigger button that was clicked.
|
||||||
|
*/
|
||||||
|
function openPicker($trigger) {
|
||||||
|
var fieldId = $trigger.data('field-id');
|
||||||
|
var capability = $trigger.data('capability') || '';
|
||||||
|
|
||||||
|
// 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 = $(
|
||||||
|
'<div class="ab-mp-popup">' +
|
||||||
|
'<div class="ab-mp-header">' +
|
||||||
|
'<span class="ab-mp-title">Select Model</span>' +
|
||||||
|
'<button type="button" class="ab-mp-close">×</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="ab-mp-search-wrap">' +
|
||||||
|
'<input type="text" class="ab-mp-search" placeholder="Search models…" autocomplete="off" />' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="ab-mp-list"><div class="ab-mp-loading">Loading models…</div></div>' +
|
||||||
|
'<div class="ab-mp-footer">' +
|
||||||
|
'<span class="ab-mp-count"></span>' +
|
||||||
|
'<span class="ab-mp-price-label">' + priceLabel + '</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
|
||||||
|
$overlay.append($popup);
|
||||||
|
$('body').append($overlay);
|
||||||
|
|
||||||
|
var $search = $popup.find('.ab-mp-search');
|
||||||
|
var $list = $popup.find('.ab-mp-list');
|
||||||
|
var $count = $popup.find('.ab-mp-count');
|
||||||
|
var allModels = [];
|
||||||
|
|
||||||
|
// Focus search after render.
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
$list.html(html);
|
||||||
|
}
|
||||||
|
$count.text(filtered.length + ' model' + (filtered.length !== 1 ? 's' : ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search input handler (debounced).
|
||||||
|
var searchTimer = null;
|
||||||
|
$search.on('input', function () {
|
||||||
|
var q = $(this).val();
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
searchTimer = setTimeout(function () {
|
||||||
|
renderList(allModels, q);
|
||||||
|
}, 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;
|
||||||
|
|
||||||
|
// Update hidden input + display.
|
||||||
|
$('#' + fieldId).val(model.id);
|
||||||
|
$trigger.find('.ab-mp-display-name').text(model.name || model.id);
|
||||||
|
|
||||||
|
var isImageModel = model.cost_per_image !== undefined && model.cost_per_image > 0;
|
||||||
|
var priceText = isImageModel
|
||||||
|
? '$' + model.cost_per_image.toFixed(4) + '/image'
|
||||||
|
: formatPrice(model.input_price_per_m) + ' / ' + formatPrice(model.output_price_per_m);
|
||||||
|
$trigger.find('.ab-mp-display-price').text(priceText);
|
||||||
|
closePicker();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close handlers.
|
||||||
|
function closePicker() {
|
||||||
|
$overlay.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
$popup.find('.ab-mp-close').on('click', closePicker);
|
||||||
|
$overlay.on('click', function (e) {
|
||||||
|
if ($(e.target).is('.ab-mp-overlay')) closePicker();
|
||||||
|
});
|
||||||
|
$(document).on('keydown.abModelPicker', function (e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closePicker();
|
||||||
|
$(document).off('keydown.abModelPicker');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind click on all model picker triggers.
|
||||||
|
$(document).on('click', '.ab-mp-trigger', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
openPicker($(this));
|
||||||
|
});
|
||||||
|
|
||||||
|
})(jQuery);
|
||||||
@@ -8,7 +8,6 @@
|
|||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"phpunit/phpunit": "^9.5",
|
"phpunit/phpunit": "^9.5",
|
||||||
"wp-phpunit/wp-phpunit": "^6.0",
|
|
||||||
"brain/monkey": "^2.6"
|
"brain/monkey": "^2.6"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ class PRAutoBlogger_Admin_Page {
|
|||||||
'toplevel_page_' . self::PAGE_SLUG,
|
'toplevel_page_' . self::PAGE_SLUG,
|
||||||
'prautoblogger_page_prautoblogger-metrics',
|
'prautoblogger_page_prautoblogger-metrics',
|
||||||
'prautoblogger_page_prautoblogger-review-queue',
|
'prautoblogger_page_prautoblogger-review-queue',
|
||||||
|
'prautoblogger_page_prautoblogger-ideas',
|
||||||
'prautoblogger_page_prautoblogger-logs',
|
'prautoblogger_page_prautoblogger-logs',
|
||||||
];
|
];
|
||||||
if ( ! in_array( $hook_suffix, $pages, true ) ) {
|
if ( ! in_array( $hook_suffix, $pages, true ) ) {
|
||||||
@@ -65,18 +66,32 @@ class PRAutoBlogger_Admin_Page {
|
|||||||
}
|
}
|
||||||
|
|
||||||
wp_enqueue_style( 'prautoblogger-admin', PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/admin.css', [], PRAUTOBLOGGER_VERSION );
|
wp_enqueue_style( 'prautoblogger-admin', PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/admin.css', [], 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-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_localize_script( 'prautoblogger-admin', 'prautobloggerAdmin', [
|
wp_localize_script( 'prautoblogger-admin', 'prautobloggerAdmin', [
|
||||||
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||||
|
'adminUrl' => admin_url(),
|
||||||
|
'siteUrl' => home_url( '/' ),
|
||||||
'generateNonce' => wp_create_nonce( 'prautoblogger_generate_now' ),
|
'generateNonce' => wp_create_nonce( 'prautoblogger_generate_now' ),
|
||||||
|
'imageNonce' => wp_create_nonce( 'prautoblogger_generate_image' ),
|
||||||
'testNonce' => wp_create_nonce( 'prautoblogger_test_connection' ),
|
'testNonce' => wp_create_nonce( 'prautoblogger_test_connection' ),
|
||||||
|
'modelsNonce' => wp_create_nonce( 'prautoblogger_get_models' ),
|
||||||
'reviewNonce' => wp_create_nonce( 'prautoblogger_review_queue' ),
|
'reviewNonce' => wp_create_nonce( 'prautoblogger_review_queue' ),
|
||||||
|
'ideaGenNonce' => wp_create_nonce( 'prautoblogger_idea_gen' ),
|
||||||
'generatingText' => __( 'Generating...', 'prautoblogger' ),
|
'generatingText' => __( 'Generating...', 'prautoblogger' ),
|
||||||
'generateText' => __( 'Generate Now', 'prautoblogger' ),
|
'generateText' => __( 'Generate Now', 'prautoblogger' ),
|
||||||
'testingText' => __( 'Testing...', 'prautoblogger' ),
|
'testingText' => __( 'Testing...', 'prautoblogger' ),
|
||||||
'testText' => __( 'Test Connections', 'prautoblogger' ),
|
'testText' => __( 'Test Connections', 'prautoblogger' ),
|
||||||
|
// Image models for the picker — defined in the settings class to keep this file short.
|
||||||
|
'imageModels' => PRAutoBlogger_Settings_Fields_Extended::get_image_models(),
|
||||||
] );
|
] );
|
||||||
|
|
||||||
|
// Ideas browser: generate-from-idea JS (only on the Ideas page).
|
||||||
|
if ( 'prautoblogger_page_prautoblogger-ideas' === $hook_suffix ) {
|
||||||
|
wp_enqueue_script( 'prautoblogger-ideas-browser', PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/ideas-browser.js', [ 'jquery', 'prautoblogger-admin' ], PRAUTOBLOGGER_VERSION, true );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Render the settings page (delegates to template). */
|
/** Render the settings page (delegates to template). */
|
||||||
@@ -143,6 +158,11 @@ class PRAutoBlogger_Admin_Page {
|
|||||||
|
|
||||||
case 'checkboxes':
|
case 'checkboxes':
|
||||||
$current = json_decode( (string) $value, true ) ?: [];
|
$current = json_decode( (string) $value, true ) ?: [];
|
||||||
|
// 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'] ?? [] ) as $v => $label ) {
|
||||||
$is_checked = in_array( $v, $current, true );
|
$is_checked = in_array( $v, $current, true );
|
||||||
$disabled = strpos( $label, 'coming soon' ) !== false ? ' disabled' : '';
|
$disabled = strpos( $label, 'coming soon' ) !== false ? ' disabled' : '';
|
||||||
@@ -151,6 +171,10 @@ class PRAutoBlogger_Admin_Page {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'model_select':
|
||||||
|
$this->render_model_select_field( $id, (string) $value, $args );
|
||||||
|
break;
|
||||||
|
|
||||||
case 'source_status':
|
case 'source_status':
|
||||||
$this->render_source_status_field();
|
$this->render_source_status_field();
|
||||||
break;
|
break;
|
||||||
@@ -173,58 +197,25 @@ class PRAutoBlogger_Admin_Page {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the Reddit source status indicator.
|
* Delegate source status rendering to the dedicated field class.
|
||||||
*
|
*
|
||||||
* Shows Reddit RSS (primary) and .json (fallback) availability
|
* @see admin/fields/class-source-status-field.php
|
||||||
* with live status checks via transient cache and rate limit info.
|
|
||||||
*
|
|
||||||
* Side effects: Reads transients for cached availability status.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
private function render_source_status_field(): void {
|
private function render_source_status_field(): void {
|
||||||
$last_collection = get_option( 'prautoblogger_last_collection_time', '' );
|
PRAutoBlogger_Source_Status_Field::render();
|
||||||
$last_source_used = get_option( 'prautoblogger_last_source_used', '' );
|
}
|
||||||
|
|
||||||
echo '<div class="ab-source-status">';
|
/**
|
||||||
|
* Delegate model picker rendering to the dedicated field class.
|
||||||
// Reddit RSS — primary source.
|
*
|
||||||
printf(
|
* @param string $id Field HTML id/name.
|
||||||
'<div class="ab-source-row">'
|
* @param string $value Current model id.
|
||||||
. '<span class="ab-source-dot ab-status-ok"></span>'
|
* @param array<string, mixed> $args Field definition.
|
||||||
. '<strong>%s</strong> <span class="ab-source-badge">%s</span>'
|
*
|
||||||
. '<span class="ab-source-label">%s</span>'
|
* @see admin/fields/class-openrouter-model-field.php
|
||||||
. '</div>',
|
*/
|
||||||
esc_html__( 'Reddit RSS', 'prautoblogger' ),
|
private function render_model_select_field( string $id, string $value, array $args ): void {
|
||||||
esc_html__( 'Primary', 'prautoblogger' ),
|
PRAutoBlogger_OpenRouter_Model_Field::render( $id, $value, $args );
|
||||||
esc_html__( 'Reliable from all IPs', 'prautoblogger' )
|
|
||||||
);
|
|
||||||
|
|
||||||
// Reddit .json — fallback source.
|
|
||||||
printf(
|
|
||||||
'<div class="ab-source-row">'
|
|
||||||
. '<span class="ab-source-dot ab-status-ok"></span>'
|
|
||||||
. '<strong>%s</strong> <span class="ab-source-badge ab-badge-secondary">%s</span>'
|
|
||||||
. '<span class="ab-source-label">%s</span>'
|
|
||||||
. '</div>',
|
|
||||||
esc_html__( 'Reddit .json', 'prautoblogger' ),
|
|
||||||
esc_html__( 'Fallback + Comments', 'prautoblogger' ),
|
|
||||||
esc_html__( 'May be IP-blocked on some hosts', 'prautoblogger' )
|
|
||||||
);
|
|
||||||
|
|
||||||
// Last collection info.
|
|
||||||
if ( '' !== $last_collection ) {
|
|
||||||
$time_ago = human_time_diff( (int) $last_collection, time() );
|
|
||||||
printf(
|
|
||||||
'<div class="ab-source-meta">%s <strong>%s</strong> %s</div>',
|
|
||||||
esc_html__( 'Last collection:', 'prautoblogger' ),
|
|
||||||
/* translators: %s is a human-readable time difference like "2 hours" */
|
|
||||||
esc_html( sprintf( __( '%s ago', 'prautoblogger' ), $time_ago ) ),
|
|
||||||
'' !== $last_source_used ? esc_html( sprintf( __( 'via %s', 'prautoblogger' ), $last_source_used ) ) : ''
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
echo '</div>';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -240,7 +231,7 @@ class PRAutoBlogger_Admin_Page {
|
|||||||
$option_name = substr( $filter, strlen( 'sanitize_option_' ) );
|
$option_name = substr( $filter, strlen( 'sanitize_option_' ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
$encrypted = [ 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json' ];
|
$encrypted = [ 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json', 'prautoblogger_runware_api_key' ];
|
||||||
if ( in_array( $option_name, $encrypted, true ) ) {
|
if ( in_array( $option_name, $encrypted, true ) ) {
|
||||||
// Empty value means password field wasn't touched — keep existing.
|
// Empty value means password field wasn't touched — keep existing.
|
||||||
if ( '' === $value ) {
|
if ( '' === $value ) {
|
||||||
@@ -262,19 +253,47 @@ class PRAutoBlogger_Admin_Page {
|
|||||||
|
|
||||||
$json_fields = [ 'prautoblogger_target_subreddits', 'prautoblogger_topic_exclusions', 'prautoblogger_enabled_sources' ];
|
$json_fields = [ 'prautoblogger_target_subreddits', 'prautoblogger_topic_exclusions', 'prautoblogger_enabled_sources' ];
|
||||||
if ( in_array( $option_name, $json_fields, true ) ) {
|
if ( in_array( $option_name, $json_fields, true ) ) {
|
||||||
if ( is_array( $value ) ) { return wp_json_encode( array_values( array_map( 'sanitize_text_field', $value ) ) ); }
|
// PHP array from checkboxes — sanitize each item and re-encode.
|
||||||
if ( is_string( $value ) && '[' !== substr( trim( $value ), 0, 1 ) ) {
|
if ( is_array( $value ) ) {
|
||||||
$items = array_filter( array_map( 'trim', explode( ',', $value ) ) );
|
return wp_json_encode( array_values( array_map( 'sanitize_text_field', $value ) ) );
|
||||||
return wp_json_encode( array_values( $items ) );
|
|
||||||
}
|
}
|
||||||
return sanitize_text_field( $value );
|
|
||||||
|
$trimmed = trim( (string) $value );
|
||||||
|
|
||||||
|
// Already JSON-encoded array — decode, sanitize each item, re-encode.
|
||||||
|
// This prevents sanitize_text_field() from mangling the JSON string,
|
||||||
|
// which caused json_decode() on read to return null → empty subreddits.
|
||||||
|
if ( '[' === substr( $trimmed, 0, 1 ) ) {
|
||||||
|
$decoded = json_decode( $trimmed, true );
|
||||||
|
if ( is_array( $decoded ) ) {
|
||||||
|
return wp_json_encode( array_values( array_map( 'sanitize_text_field', $decoded ) ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comma-separated plain text — split, sanitize, encode as JSON array.
|
||||||
|
$items = array_filter( array_map( 'trim', explode( ',', $trimmed ) ) );
|
||||||
|
return wp_json_encode( array_values( array_map( 'sanitize_text_field', $items ) ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
$numeric = [ '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 = [ 'prautoblogger_daily_article_target', 'prautoblogger_monthly_budget_usd', 'prautoblogger_min_word_count', 'prautoblogger_max_word_count', 'prautoblogger_default_author', 'prautoblogger_default_category', 'prautoblogger_pullpush_cache_ttl', 'prautoblogger_reddit_posts_per_subreddit' ];
|
||||||
if ( in_array( $option_name, $numeric, true ) ) {
|
if ( in_array( $option_name, $numeric, true ) ) {
|
||||||
return is_numeric( $value ) ? $value : 0;
|
return is_numeric( $value ) ? $value : 0;
|
||||||
}
|
}
|
||||||
|
if ( 'prautoblogger_image_model' === $option_name ) {
|
||||||
|
return $this->sanitize_image_model( (string) $value );
|
||||||
|
}
|
||||||
return sanitize_text_field( (string) $value );
|
return sanitize_text_field( (string) $value );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Validate model id against the registry and persist the derived provider. */
|
||||||
|
private function sanitize_image_model( string $value ): string {
|
||||||
|
$candidate = sanitize_text_field( $value );
|
||||||
|
$provider = PRAutoBlogger_Image_Model_Registry::provider_for( $candidate );
|
||||||
|
if ( '' !== $provider ) {
|
||||||
|
update_option( 'prautoblogger_image_provider', $provider );
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
add_settings_error( 'prautoblogger_image_model', 'prautoblogger_image_model_unknown', sprintf( esc_html__( 'Image model "%s" is not in the registry. Keeping the previous selection.', 'prautoblogger' ), esc_html( $candidate ) ) );
|
||||||
|
return (string) get_option( 'prautoblogger_image_model', PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
299
includes/admin/class-ideas-browser.php
Normal file
299
includes/admin/class-ideas-browser.php
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin page listing analysis results (article ideas) with per-idea generation.
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger::register_admin_hooks() on `admin_menu`.
|
||||||
|
* Dependencies: WordPress $wpdb, Article_Worker, Cost_Tracker, Generation_Lock.
|
||||||
|
*
|
||||||
|
* @see core/class-article-worker.php — Generates articles from ideas.
|
||||||
|
* @see templates/admin/ideas-browser.php — Renders the HTML.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Ideas_Browser {
|
||||||
|
|
||||||
|
private const PER_PAGE = 30;
|
||||||
|
|
||||||
|
/** Transient prefix for per-idea generation status. */
|
||||||
|
private const STATUS_PREFIX = 'prab_idea_gen_';
|
||||||
|
|
||||||
|
/** How long to keep per-idea status (seconds). */
|
||||||
|
private const STATUS_TTL = 600;
|
||||||
|
|
||||||
|
/** Register the Ideas submenu page under PRAutoBlogger. */
|
||||||
|
public function on_register_menu(): void {
|
||||||
|
add_submenu_page(
|
||||||
|
'prautoblogger-settings',
|
||||||
|
__( 'Ideas', 'prautoblogger' ),
|
||||||
|
__( 'Ideas', 'prautoblogger' ),
|
||||||
|
'manage_options',
|
||||||
|
'prautoblogger-ideas',
|
||||||
|
[ $this, 'render_page' ]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render the ideas browser page. */
|
||||||
|
public function render_page(): void {
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$paged = isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1;
|
||||||
|
$type_filter = isset( $_GET['type'] ) ? sanitize_key( $_GET['type'] ) : '';
|
||||||
|
|
||||||
|
$result = $this->query_ideas( $paged, $type_filter );
|
||||||
|
$rows = $result['rows'];
|
||||||
|
$total = $result['total'];
|
||||||
|
$total_pages = (int) ceil( $total / self::PER_PAGE );
|
||||||
|
$type_counts = $this->get_type_counts();
|
||||||
|
|
||||||
|
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/ideas-browser.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX: trigger article generation from a specific idea.
|
||||||
|
*
|
||||||
|
* Loads the analysis result, stores it as a transient, sets a per-idea
|
||||||
|
* status transient, and schedules a one-shot cron event so the actual
|
||||||
|
* generation runs in a separate PHP process (Hostinger 120s limit).
|
||||||
|
*
|
||||||
|
* Side effects: transient writes, cron scheduling.
|
||||||
|
*/
|
||||||
|
public function on_ajax_generate_from_idea(): void {
|
||||||
|
check_ajax_referer( 'prautoblogger_idea_gen', 'nonce' );
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
wp_send_json_error( [ 'message' => 'Insufficient permissions.' ], 403 );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$idea_id = absint( $_POST['idea_id'] ?? 0 );
|
||||||
|
if ( $idea_id < 1 ) {
|
||||||
|
wp_send_json_error( [ '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.' ] );
|
||||||
|
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 );
|
||||||
|
|
||||||
|
// 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 ] );
|
||||||
|
}
|
||||||
|
spawn_cron();
|
||||||
|
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( [ 'message' => 'Generation started.' ] );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX: return per-idea generation status for frontend polling.
|
||||||
|
*
|
||||||
|
* Side effects: none (reads transient only).
|
||||||
|
*/
|
||||||
|
public function on_ajax_idea_gen_status(): void {
|
||||||
|
check_ajax_referer( 'prautoblogger_idea_gen', 'nonce' );
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
wp_send_json_error( [ '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' ] );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wp_send_json_success( $status );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron handler: generate a single article from a stored idea.
|
||||||
|
*
|
||||||
|
* Runs in a background PHP process. Acquires the generation lock,
|
||||||
|
* runs Article_Worker, and writes the result to the per-idea status transient.
|
||||||
|
*
|
||||||
|
* Side effects: LLM API calls, DB writes, post creation, cost logging.
|
||||||
|
*
|
||||||
|
* @param int $idea_id Analysis result row ID.
|
||||||
|
*/
|
||||||
|
public static function on_cron_generate_from_idea( int $idea_id ): void {
|
||||||
|
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||||
|
@ignore_user_abort( true );
|
||||||
|
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||||
|
@set_time_limit( 300 );
|
||||||
|
|
||||||
|
$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 );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! PRAutoBlogger_Generation_Lock::acquire() ) {
|
||||||
|
set_transient( $key, [ 'status' => 'error', 'message' => 'Another generation is running.' ], self::STATUS_TTL );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$idea = new PRAutoBlogger_Article_Idea( $idea_data );
|
||||||
|
$cost_tracker = new PRAutoBlogger_Cost_Tracker();
|
||||||
|
$cost_tracker->set_run_id( wp_generate_uuid4() );
|
||||||
|
|
||||||
|
self::update_idea_stage( $idea_id, __( 'Generating article draft…', 'prautoblogger' ) );
|
||||||
|
$worker = new PRAutoBlogger_Article_Worker( $cost_tracker );
|
||||||
|
$result = $worker->generate( $idea );
|
||||||
|
|
||||||
|
// Amortize any research costs for this single-article run.
|
||||||
|
PRAutoBlogger_Post_Assembler::amortize_research_costs( $cost_tracker->get_run_id() );
|
||||||
|
|
||||||
|
// Find the generated post ID for the "View" link.
|
||||||
|
$post_id = 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 );
|
||||||
|
} 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 );
|
||||||
|
}
|
||||||
|
|
||||||
|
PRAutoBlogger_Generation_Lock::release();
|
||||||
|
delete_transient( self::STATUS_PREFIX . 'data_' . $idea_id );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Update the per-idea status transient with a stage message. */
|
||||||
|
private static function update_idea_stage( int $idea_id, string $stage ): void {
|
||||||
|
$key = self::STATUS_PREFIX . $idea_id;
|
||||||
|
$current = get_transient( $key );
|
||||||
|
if ( is_array( $current ) ) {
|
||||||
|
$current['stage'] = $stage;
|
||||||
|
set_transient( $key, $current, self::STATUS_TTL );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find the most recent post created by a specific run_id. */
|
||||||
|
private static function find_post_by_run_id( string $run_id ): ?int {
|
||||||
|
global $wpdb;
|
||||||
|
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
$post_id = $wpdb->get_var(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"SELECT DISTINCT post_id FROM {$table} WHERE run_id = %s AND post_id IS NOT NULL LIMIT 1",
|
||||||
|
$run_id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return null !== $post_id ? (int) $post_id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load an analysis result row and map it to Article_Idea constructor data.
|
||||||
|
*
|
||||||
|
* @param int $idea_id Analysis result row ID.
|
||||||
|
* @return array<string, mixed>|null Article_Idea-compatible array, or null.
|
||||||
|
*/
|
||||||
|
private static function load_idea_data( int $idea_id ): ?array {
|
||||||
|
global $wpdb;
|
||||||
|
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
$row = $wpdb->get_row(
|
||||||
|
$wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $idea_id ),
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
if ( ! $row ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$meta = json_decode( $row['metadata_json'] ?? '{}', true ) ?: [];
|
||||||
|
return [
|
||||||
|
'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'] ?? [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 = [];
|
||||||
|
|
||||||
|
if ( '' !== $type ) {
|
||||||
|
$where[] = 'analysis_type = %s';
|
||||||
|
$params[] = $type;
|
||||||
|
}
|
||||||
|
|
||||||
|
$where_sql = empty( $where ) ? '' : 'WHERE ' . implode( ' AND ', $where );
|
||||||
|
$offset = ( $paged - 1 ) * self::PER_PAGE;
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
$total = (int) $wpdb->get_var(
|
||||||
|
empty( $params )
|
||||||
|
? "SELECT COUNT(*) FROM {$table} {$where_sql}"
|
||||||
|
: $wpdb->prepare( "SELECT COUNT(*) FROM {$table} {$where_sql}", ...$params )
|
||||||
|
);
|
||||||
|
|
||||||
|
$order_sql = 'ORDER BY analyzed_at DESC, relevance_score DESC';
|
||||||
|
$limit_sql = sprintf( 'LIMIT %d OFFSET %d', self::PER_PAGE, $offset );
|
||||||
|
$full_sql = "SELECT * FROM {$table} {$where_sql} {$order_sql} {$limit_sql}";
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
$rows = empty( $params )
|
||||||
|
? $wpdb->get_results( $full_sql, ARRAY_A )
|
||||||
|
: $wpdb->get_results( $wpdb->prepare( $full_sql, ...$params ), ARRAY_A );
|
||||||
|
|
||||||
|
return [ 'rows' => $rows ?: [], 'total' => $total ];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get counts per analysis_type for the filter badges. */
|
||||||
|
private function get_type_counts(): array {
|
||||||
|
global $wpdb;
|
||||||
|
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
$rows = $wpdb->get_results(
|
||||||
|
"SELECT analysis_type, COUNT(*) AS cnt FROM {$table} GROUP BY analysis_type ORDER BY cnt DESC",
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
|
||||||
|
$counts = [];
|
||||||
|
foreach ( $rows ?: [] as $row ) {
|
||||||
|
$counts[ $row['analysis_type'] ] = (int) $row['cnt'];
|
||||||
|
}
|
||||||
|
return $counts;
|
||||||
|
}
|
||||||
|
}
|
||||||
111
includes/admin/class-image-model-registry.php
Normal file
111
includes/admin/class-image-model-registry.php
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
* Who calls it: PRAutoBlogger_Admin_Page (model picker UI + save-time provider
|
||||||
|
* derivation) and PRAutoBlogger_Image_Pipeline (provider lookup).
|
||||||
|
* Dependencies: None.
|
||||||
|
*
|
||||||
|
* @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.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Image_Model_Registry {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all available image generation models.
|
||||||
|
*
|
||||||
|
* Each entry contains:
|
||||||
|
* - id: Model identifier used in API calls.
|
||||||
|
* - name: Human-readable display name.
|
||||||
|
* - provider: 'runware' | 'openrouter'.
|
||||||
|
* - cost_per_image: Estimated USD cost per generation.
|
||||||
|
* - capabilities: Array of capability tags.
|
||||||
|
* - description: Short description for the admin UI.
|
||||||
|
*
|
||||||
|
* Ordered cheapest-to-most-expensive within each provider, with the
|
||||||
|
* recommended default (Runware schnell) at the top.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public static function get_models(): array {
|
||||||
|
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' ),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the provider id for a known model, or empty string if the
|
||||||
|
* model id is not in the registry.
|
||||||
|
*
|
||||||
|
* @param string $model_id Model slug from the admin UI.
|
||||||
|
* @return string Provider id ('runware' | 'openrouter' | '').
|
||||||
|
*/
|
||||||
|
public static function provider_for( string $model_id ): string {
|
||||||
|
foreach ( self::get_models() as $model ) {
|
||||||
|
if ( ( $model['id'] ?? '' ) === $model_id ) {
|
||||||
|
return (string) ( $model['provider'] ?? '' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
261
includes/admin/class-post-list-columns.php
Normal file
261
includes/admin/class-post-list-columns.php
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds "Writing Model", "Image Model", and "Cost" columns to the Posts list.
|
||||||
|
*
|
||||||
|
* Shows which LLM wrote each article, which model generated its featured image,
|
||||||
|
* and the total API cost of generating the article. Non-generated posts show "—".
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger::register_admin_hooks().
|
||||||
|
* Dependencies: Post meta, attachment meta, prab_generation_log table.
|
||||||
|
*
|
||||||
|
* @see core/class-publisher.php — Sets _prautoblogger_model_used.
|
||||||
|
* @see core/class-image-media-sideloader.php — Sets _prautoblogger_image_model on attachment.
|
||||||
|
* @see core/class-cost-tracker.php — Writes generation_log with estimated_cost.
|
||||||
|
* @see class-prautoblogger.php — Registers hooks for this class.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Post_List_Columns {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register column and render hooks.
|
||||||
|
*
|
||||||
|
* Side effects: adds WordPress filters/actions for post list customization.
|
||||||
|
*/
|
||||||
|
public function register(): void {
|
||||||
|
add_filter( 'manage_posts_columns', [ $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' ] );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert three columns after the title column.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $columns Existing columns.
|
||||||
|
* @return array<string, string> Modified columns.
|
||||||
|
*/
|
||||||
|
public function filter_add_columns( array $columns ): array {
|
||||||
|
$new = [];
|
||||||
|
foreach ( $columns as $key => $label ) {
|
||||||
|
$new[ $key ] = $label;
|
||||||
|
if ( 'title' === $key ) {
|
||||||
|
$new['prab_writing_model'] = __( 'Writing Model', 'prautoblogger' );
|
||||||
|
$new['prab_image_model'] = __( 'Image Model', 'prautoblogger' );
|
||||||
|
$new['prab_cost'] = __( 'Cost', 'prautoblogger' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $new;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render cell content for our custom columns.
|
||||||
|
*
|
||||||
|
* @param string $column Column identifier.
|
||||||
|
* @param int $post_id Current post ID.
|
||||||
|
*/
|
||||||
|
public function on_render_column( string $column, int $post_id ): void {
|
||||||
|
switch ( $column ) {
|
||||||
|
case 'prab_writing_model':
|
||||||
|
$this->render_model_cell(
|
||||||
|
(string) get_post_meta( $post_id, '_prautoblogger_model_used', true )
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'prab_image_model':
|
||||||
|
$this->render_image_model_cell( $post_id );
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'prab_cost':
|
||||||
|
$this->render_cost_cell( $post_id );
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make the writing model column sortable.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $columns Sortable column map.
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function filter_sortable_columns( array $columns ): array {
|
||||||
|
$columns['prab_writing_model'] = 'prab_writing_model';
|
||||||
|
return $columns;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle sorting by writing model meta value.
|
||||||
|
*
|
||||||
|
* @param \WP_Query $query The admin posts query.
|
||||||
|
*/
|
||||||
|
public function on_sort_by_model( \WP_Query $query ): void {
|
||||||
|
if ( ! is_admin() || ! $query->is_main_query() ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( 'prab_writing_model' !== $query->get( 'orderby' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$query->set( 'meta_key', '_prautoblogger_model_used' );
|
||||||
|
$query->set( 'orderby', 'meta_value' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Output column width styles on the Posts list screen only.
|
||||||
|
*/
|
||||||
|
public function on_column_styles(): void {
|
||||||
|
$screen = get_current_screen();
|
||||||
|
if ( ! $screen || 'edit-post' !== $screen->id ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
echo '<style>'
|
||||||
|
. '.wp-list-table .column-title{width: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}'
|
||||||
|
. '.prab-cost-wrap{position:relative;display:inline-block}'
|
||||||
|
. '.prab-cost-trigger{font-size:12px;color:#2271b1;text-decoration:underline;cursor:pointer}'
|
||||||
|
. '.prab-cost-popover{display:none;position:absolute;right:0;top:100%;z-index:9999;'
|
||||||
|
. 'background:#fff;border:1px solid #c3c4c7;border-radius:4px;box-shadow:0 2px 8px rgba(0,0,0,.15);'
|
||||||
|
. 'padding:8px;min-width:340px;white-space:normal}'
|
||||||
|
. '.prab-cost-wrap:hover .prab-cost-popover,'
|
||||||
|
. '.prab-cost-trigger:focus+.prab-cost-popover{display:block}'
|
||||||
|
. '.prab-cost-table{width:100%;border-collapse:collapse;font-size:12px}'
|
||||||
|
. '.prab-cost-table th{text-align:left;border-bottom:2px solid #dcdcde;padding:3px 6px;color:#50575e;font-weight:600}'
|
||||||
|
. '.prab-cost-table td{padding:3px 6px;border-bottom:1px solid #f0f0f1;color:#3c434a}'
|
||||||
|
. '.prab-cost-table tr:last-child td{border-bottom:none}'
|
||||||
|
. '.prab-cost-total td{border-top:2px solid #dcdcde;border-bottom:none}'
|
||||||
|
. '</style>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a model name cell with shortened display and full tooltip.
|
||||||
|
*
|
||||||
|
* @param string $model Full model identifier (may be empty).
|
||||||
|
*/
|
||||||
|
private function render_model_cell( string $model ): void {
|
||||||
|
if ( '' === $model ) {
|
||||||
|
echo '—';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
printf(
|
||||||
|
'<span title="%s" style="font-size:12px;color:#666;">%s</span>',
|
||||||
|
esc_attr( $model ),
|
||||||
|
esc_html( $this->shorten( $model ) )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up the featured image's model and render it.
|
||||||
|
*
|
||||||
|
* @param int $post_id The post ID.
|
||||||
|
*/
|
||||||
|
private function render_image_model_cell( int $post_id ): void {
|
||||||
|
$thumbnail_id = (int) get_post_thumbnail_id( $post_id );
|
||||||
|
if ( 0 === $thumbnail_id ) {
|
||||||
|
echo '—';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$model = (string) get_post_meta( $thumbnail_id, '_prautoblogger_image_model', true );
|
||||||
|
$this->render_model_cell( $model );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch generation_log rows for a post, render total with clickable breakdown.
|
||||||
|
*
|
||||||
|
* @param int $post_id The post ID.
|
||||||
|
*/
|
||||||
|
private function render_cost_cell( int $post_id ): void {
|
||||||
|
$is_generated = get_post_meta( $post_id, '_prautoblogger_generated', true );
|
||||||
|
if ( '1' !== $is_generated ) {
|
||||||
|
echo '—';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||||
|
$rows = $wpdb->get_results(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"SELECT stage, model, prompt_tokens, completion_tokens, estimated_cost
|
||||||
|
FROM {$table} WHERE post_id = %d ORDER BY id ASC",
|
||||||
|
$post_id
|
||||||
|
),
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( empty( $rows ) ) {
|
||||||
|
echo '<span style="font-size:12px;color:#999;">$0.00</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = array_sum( array_column( $rows, 'estimated_cost' ) );
|
||||||
|
$formatted = $total < 0.01
|
||||||
|
? sprintf( '$%.4f', $total )
|
||||||
|
: sprintf( '$%.2f', $total );
|
||||||
|
|
||||||
|
$breakdown = $this->build_breakdown_html( $rows, $total );
|
||||||
|
|
||||||
|
printf(
|
||||||
|
'<span class="prab-cost-wrap">'
|
||||||
|
. '<a href="#" class="prab-cost-trigger" onclick="return false;">%s</a>'
|
||||||
|
. '<span class="prab-cost-popover">%s</span>'
|
||||||
|
. '</span>',
|
||||||
|
esc_html( $formatted ),
|
||||||
|
$breakdown
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the HTML breakdown table for the cost popover.
|
||||||
|
*
|
||||||
|
* @param array[] $rows Generation log rows.
|
||||||
|
* @param float $total Total cost.
|
||||||
|
* @return string HTML table (already escaped).
|
||||||
|
*/
|
||||||
|
private function build_breakdown_html( array $rows, float $total ): string {
|
||||||
|
$html = '<table class="prab-cost-table">';
|
||||||
|
$html .= '<tr><th>Stage</th><th>Model</th><th>Tokens</th><th>Cost</th></tr>';
|
||||||
|
|
||||||
|
foreach ( $rows as $row ) {
|
||||||
|
$stage = esc_html( ucfirst( str_replace( '_', ' ', $row['stage'] ) ) );
|
||||||
|
$model = esc_html( $this->shorten( $row['model'] ) );
|
||||||
|
$tokens = (int) $row['prompt_tokens'] + (int) $row['completion_tokens'];
|
||||||
|
$cost = (float) $row['estimated_cost'];
|
||||||
|
$cost_f = $cost < 0.01 ? sprintf( '$%.4f', $cost ) : sprintf( '$%.2f', $cost );
|
||||||
|
|
||||||
|
$html .= sprintf(
|
||||||
|
'<tr><td>%s</td><td title="%s">%s</td><td>%s</td><td>%s</td></tr>',
|
||||||
|
$stage,
|
||||||
|
esc_attr( $row['model'] ),
|
||||||
|
$model,
|
||||||
|
esc_html( number_format( $tokens ) ),
|
||||||
|
esc_html( $cost_f )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$total_f = $total < 0.01 ? sprintf( '$%.4f', $total ) : sprintf( '$%.2f', $total );
|
||||||
|
$html .= sprintf(
|
||||||
|
'<tr class="prab-cost-total"><td colspan="3"><strong>Total</strong></td>'
|
||||||
|
. '<td><strong>%s</strong></td></tr>',
|
||||||
|
esc_html( $total_f )
|
||||||
|
);
|
||||||
|
$html .= '</table>';
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip provider prefix for compact display.
|
||||||
|
*
|
||||||
|
* @param string $model Full model identifier.
|
||||||
|
* @return string Shortened name.
|
||||||
|
*/
|
||||||
|
private function shorten( string $model ): string {
|
||||||
|
$pos = strpos( $model, '/' );
|
||||||
|
return false !== $pos ? substr( $model, $pos + 1 ) : $model;
|
||||||
|
}
|
||||||
|
}
|
||||||
261
includes/admin/class-settings-fields-extended.php
Normal file
261
includes/admin/class-settings-fields-extended.php
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
* PRAutoBlogger_Article_Typography (font choices), PRAutoBlogger_Image_Model_Registry.
|
||||||
|
*
|
||||||
|
* @see admin/class-settings-fields.php — Core fields and sections; calls get_extended_fields().
|
||||||
|
* @see admin/class-admin-page.php — Renders fields registered from these definitions.
|
||||||
|
* @see admin/class-image-model-registry.php — Static image model list extracted for 300-line compliance.
|
||||||
|
* @see frontend/class-article-typography.php — Reads Display settings on the frontend.
|
||||||
|
* @see CONVENTIONS.md — "How To: Add a New Admin Setting".
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Settings_Fields_Extended {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static image model list for the admin model picker.
|
||||||
|
* Delegates to Image_Model_Registry (extracted for 300-line file limit).
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public static function get_image_models(): array {
|
||||||
|
return PRAutoBlogger_Image_Model_Registry::get_models();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get schedule, publishing, analytics, and image settings fields.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public static function get_fields(): array {
|
||||||
|
return [
|
||||||
|
// ── Sources (extended) ──────────────────────────────────────
|
||||||
|
[
|
||||||
|
'id' => 'prautoblogger_research_model',
|
||||||
|
'label' => __( 'Research Model', 'prautoblogger' ),
|
||||||
|
'type' => 'model_select',
|
||||||
|
'section' => 'prautoblogger_sources',
|
||||||
|
'default' => PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL,
|
||||||
|
'capability' => 'text→text',
|
||||||
|
'description' => __( 'Model for LLM Deep Research. Pick a reasoning-capable model (e.g. Grok 4.1 Fast, DeepSeek-R1) for best results. Only used when LLM Deep Research is enabled above.', 'prautoblogger' ),
|
||||||
|
'badge' => __( 'Reasoning', 'prautoblogger' ),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'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) ────────────────────────────────────
|
||||||
|
[
|
||||||
|
'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' => [
|
||||||
|
'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' ),
|
||||||
|
],
|
||||||
|
|
||||||
|
// ── Schedule & Budget ───────────────────────────────────────
|
||||||
|
[
|
||||||
|
'id' => 'prautoblogger_daily_article_target',
|
||||||
|
'label' => __( 'Articles per Day', 'prautoblogger' ),
|
||||||
|
'type' => 'number',
|
||||||
|
'section' => 'prautoblogger_schedule',
|
||||||
|
'default' => 1,
|
||||||
|
'min' => 1,
|
||||||
|
'max' => 10,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'prautoblogger_schedule_time',
|
||||||
|
'label' => __( 'Generation Time', 'prautoblogger' ),
|
||||||
|
'type' => 'time',
|
||||||
|
'section' => 'prautoblogger_schedule',
|
||||||
|
'default' => '03:00',
|
||||||
|
'description' => sprintf(
|
||||||
|
/* translators: %s: site timezone string, e.g. "Asia/Singapore". */
|
||||||
|
esc_html__( "Daily generation runs at this time in your site's configured timezone (Settings → General → Timezone). Current site timezone: %s.", 'prautoblogger' ),
|
||||||
|
esc_html( function_exists( 'wp_timezone_string' ) ? wp_timezone_string() : 'UTC' )
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'prautoblogger_monthly_budget_usd',
|
||||||
|
'label' => __( 'Monthly Budget (USD)', 'prautoblogger' ),
|
||||||
|
'type' => 'number',
|
||||||
|
'section' => 'prautoblogger_schedule',
|
||||||
|
'default' => 50,
|
||||||
|
'min' => 0,
|
||||||
|
'step' => '0.01',
|
||||||
|
'description' => __( 'Hard stop when reached. 0 = unlimited.', 'prautoblogger' ),
|
||||||
|
],
|
||||||
|
|
||||||
|
// ── Publishing ──────────────────────────────────────────────
|
||||||
|
[
|
||||||
|
'id' => 'prautoblogger_auto_publish',
|
||||||
|
'label' => __( 'Auto-Publish', 'prautoblogger' ),
|
||||||
|
'type' => 'toggle',
|
||||||
|
'section' => 'prautoblogger_publishing',
|
||||||
|
'default' => '0',
|
||||||
|
'description' => __( 'Automatically publish editor-approved posts. When off, all posts go to the Review Queue as drafts.', 'prautoblogger' ),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'prautoblogger_default_author',
|
||||||
|
'label' => __( 'Default Author', 'prautoblogger' ),
|
||||||
|
'type' => 'author_select',
|
||||||
|
'section' => 'prautoblogger_publishing',
|
||||||
|
'default' => 0,
|
||||||
|
'description' => __( 'WordPress user assigned as author of generated posts.', 'prautoblogger' ),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'prautoblogger_default_category',
|
||||||
|
'label' => __( 'Default Category', 'prautoblogger' ),
|
||||||
|
'type' => 'category_select',
|
||||||
|
'section' => 'prautoblogger_publishing',
|
||||||
|
'default' => 0,
|
||||||
|
'description' => __( 'Fallback category for generated posts.', 'prautoblogger' ),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'prautoblogger_log_level',
|
||||||
|
'label' => __( 'Log Level', 'prautoblogger' ),
|
||||||
|
'type' => 'select',
|
||||||
|
'section' => 'prautoblogger_publishing',
|
||||||
|
'default' => 'info',
|
||||||
|
'options' => [
|
||||||
|
'error' => __( 'Error — only failures', 'prautoblogger' ),
|
||||||
|
'warning' => __( 'Warning — errors + warnings', 'prautoblogger' ),
|
||||||
|
'info' => __( 'Info — key events (recommended)', 'prautoblogger' ),
|
||||||
|
'debug' => __( 'Debug — everything (verbose)', 'prautoblogger' ),
|
||||||
|
],
|
||||||
|
'description' => __( 'Controls detail level in the Activity Log.', 'prautoblogger' ),
|
||||||
|
],
|
||||||
|
|
||||||
|
// ── Analytics ───────────────────────────────────────────────
|
||||||
|
[
|
||||||
|
'id' => 'prautoblogger_ga4_property_id',
|
||||||
|
'label' => __( 'GA4 Property ID', 'prautoblogger' ),
|
||||||
|
'type' => 'text',
|
||||||
|
'section' => 'prautoblogger_analytics',
|
||||||
|
'description' => __( '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' ),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'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 ────────────────────────────────────────────────
|
||||||
|
[
|
||||||
|
'id' => 'prautoblogger_article_font_family',
|
||||||
|
'label' => __( 'Article Font', 'prautoblogger' ),
|
||||||
|
'type' => 'select',
|
||||||
|
'section' => 'prautoblogger_display',
|
||||||
|
'default' => 'default',
|
||||||
|
'options' => PRAutoBlogger_Article_Typography::get_font_choices(),
|
||||||
|
'description' => __( 'Font family used for generated article body text. Serif fonts improve long-form readability.', 'prautoblogger' ),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'prautoblogger_article_font_size',
|
||||||
|
'label' => __( 'Article Font Size (px)', 'prautoblogger' ),
|
||||||
|
'type' => 'number',
|
||||||
|
'section' => 'prautoblogger_display',
|
||||||
|
'default' => 0,
|
||||||
|
'min' => 0,
|
||||||
|
'max' => 32,
|
||||||
|
'description' => __( 'Base font size for article body text. 0 uses the theme default (13px). Recommended: 16–18px for comfortable reading.', 'prautoblogger' ),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'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 ─────────────────────────────────────────────────
|
||||||
|
[
|
||||||
|
'id' => 'prautoblogger_image_enabled',
|
||||||
|
'label' => __( 'Enable Image Generation', 'prautoblogger' ),
|
||||||
|
'type' => 'toggle',
|
||||||
|
'section' => 'prautoblogger_images',
|
||||||
|
'default' => '0',
|
||||||
|
'description' => __( 'Generate images for each published article.', 'prautoblogger' ),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'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' ),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'prautoblogger_image_model',
|
||||||
|
'label' => __( 'Image Model', 'prautoblogger' ),
|
||||||
|
'type' => 'model_select',
|
||||||
|
'section' => 'prautoblogger_images',
|
||||||
|
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL,
|
||||||
|
'capability' => 'image_generation',
|
||||||
|
'description' => __( 'Pick an image model. The provider (Runware or OpenRouter) is derived from the model registry on save, so mismatched pairs are no longer possible.', 'prautoblogger' ),
|
||||||
|
'badge' => __( 'Quality', 'prautoblogger' ),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'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' ),
|
||||||
|
'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' ),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'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' ),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'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' ),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,12 +6,15 @@ declare(strict_types=1);
|
|||||||
*
|
*
|
||||||
* Centralizes all field and section definitions in one place, making it trivial
|
* Centralizes all field and section definitions in one place, making it trivial
|
||||||
* to add new settings (just add one array entry). Decoupled from page rendering logic.
|
* to add new settings (just add one array entry). Decoupled from page rendering logic.
|
||||||
|
* Core fields (API, Models, Content, Sources) live here; operational fields
|
||||||
|
* (Schedule, Publishing, Analytics, Images) are in Settings_Fields_Extended.
|
||||||
*
|
*
|
||||||
* Triggered by: PRAutoBlogger_Admin_Page::on_register_settings() calls static methods here.
|
* Triggered by: PRAutoBlogger_Admin_Page::on_register_settings() calls static methods here.
|
||||||
* Dependencies: None — contains only data definitions and static methods.
|
* Dependencies: PRAutoBlogger_Settings_Fields_Extended for operational field definitions.
|
||||||
*
|
*
|
||||||
* @see admin/class-admin-page.php — Calls get_sections() and get_fields() to register settings.
|
* @see admin/class-settings-fields-extended.php — Schedule, publishing, analytics, images fields.
|
||||||
* @see CONVENTIONS.md — "How To: Add a New Admin Setting".
|
* @see admin/class-admin-page.php — Calls get_sections() and get_fields() to register settings.
|
||||||
|
* @see CONVENTIONS.md — "How To: Add a New Admin Setting".
|
||||||
*/
|
*/
|
||||||
class PRAutoBlogger_Settings_Fields {
|
class PRAutoBlogger_Settings_Fields {
|
||||||
|
|
||||||
@@ -57,20 +60,34 @@ class PRAutoBlogger_Settings_Fields {
|
|||||||
'icon' => 'dashicons-chart-area',
|
'icon' => 'dashicons-chart-area',
|
||||||
'description' => __( 'Connect Google Analytics 4 for post performance scoring.', 'prautoblogger' ),
|
'description' => __( 'Connect Google Analytics 4 for post performance scoring.', 'prautoblogger' ),
|
||||||
],
|
],
|
||||||
|
'prautoblogger_display' => [
|
||||||
|
'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' => [
|
||||||
'title' => __( 'Images', 'prautoblogger' ),
|
'title' => __( 'Images', 'prautoblogger' ),
|
||||||
'icon' => 'dashicons-format-image',
|
'icon' => 'dashicons-format-image',
|
||||||
'description' => __( 'Cloudflare Workers AI credentials and image style controls.', 'prautoblogger' ),
|
'description' => __( 'Image provider, model, and style controls. OpenRouter reuses your existing API key.', 'prautoblogger' ),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all settings fields. Adding a new setting = adding one entry to this array.
|
* Get all settings fields. Core fields here, operational fields merged from Extended.
|
||||||
*
|
*
|
||||||
* @return array<int, array<string, mixed>>
|
* @return array<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
public static function get_fields(): array {
|
public static function get_fields(): array {
|
||||||
|
return array_merge( self::get_core_fields(), PRAutoBlogger_Settings_Fields_Extended::get_fields() );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core fields: API Keys, AI Models, Content, and Sources.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private static function get_core_fields(): array {
|
||||||
return [
|
return [
|
||||||
// ── API Keys ────────────────────────────────────────────────
|
// ── API Keys ────────────────────────────────────────────────
|
||||||
[
|
[
|
||||||
@@ -113,27 +130,30 @@ class PRAutoBlogger_Settings_Fields {
|
|||||||
[
|
[
|
||||||
'id' => 'prautoblogger_analysis_model',
|
'id' => 'prautoblogger_analysis_model',
|
||||||
'label' => __( 'Analysis Model', 'prautoblogger' ),
|
'label' => __( 'Analysis Model', 'prautoblogger' ),
|
||||||
'type' => 'text',
|
'type' => 'model_select',
|
||||||
'section' => 'prautoblogger_models',
|
'section' => 'prautoblogger_models',
|
||||||
'default' => PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL,
|
'default' => PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL,
|
||||||
|
'capability' => 'text→text',
|
||||||
'description' => __( 'Used for source analysis. Pick a cheap, fast model.', 'prautoblogger' ),
|
'description' => __( 'Used for source analysis. Pick a cheap, fast model.', 'prautoblogger' ),
|
||||||
'badge' => __( 'Low cost', 'prautoblogger' ),
|
'badge' => __( 'Low cost', 'prautoblogger' ),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'id' => 'prautoblogger_writing_model',
|
'id' => 'prautoblogger_writing_model',
|
||||||
'label' => __( 'Writing Model', 'prautoblogger' ),
|
'label' => __( 'Writing Model', 'prautoblogger' ),
|
||||||
'type' => 'text',
|
'type' => 'model_select',
|
||||||
'section' => 'prautoblogger_models',
|
'section' => 'prautoblogger_models',
|
||||||
'default' => PRAUTOBLOGGER_DEFAULT_WRITING_MODEL,
|
'default' => PRAUTOBLOGGER_DEFAULT_WRITING_MODEL,
|
||||||
|
'capability' => 'text→text',
|
||||||
'description' => __( 'Used for article generation. Quality matters here.', 'prautoblogger' ),
|
'description' => __( 'Used for article generation. Quality matters here.', 'prautoblogger' ),
|
||||||
'badge' => __( 'Quality', 'prautoblogger' ),
|
'badge' => __( 'Quality', 'prautoblogger' ),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'id' => 'prautoblogger_editor_model',
|
'id' => 'prautoblogger_editor_model',
|
||||||
'label' => __( 'Editor Model', 'prautoblogger' ),
|
'label' => __( 'Editor Model', 'prautoblogger' ),
|
||||||
'type' => 'text',
|
'type' => 'model_select',
|
||||||
'section' => 'prautoblogger_models',
|
'section' => 'prautoblogger_models',
|
||||||
'default' => PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL,
|
'default' => PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL,
|
||||||
|
'capability' => 'text→text',
|
||||||
'description' => __( 'Used for the chief editor review pass.', 'prautoblogger' ),
|
'description' => __( 'Used for the chief editor review pass.', 'prautoblogger' ),
|
||||||
'badge' => __( 'Quality', 'prautoblogger' ),
|
'badge' => __( 'Quality', 'prautoblogger' ),
|
||||||
],
|
],
|
||||||
@@ -146,6 +166,14 @@ class PRAutoBlogger_Settings_Fields {
|
|||||||
'section' => 'prautoblogger_content',
|
'section' => 'prautoblogger_content',
|
||||||
'description' => __( 'Describe your site\'s niche. Guides content analysis and generation.', 'prautoblogger' ),
|
'description' => __( 'Describe your site\'s niche. Guides content analysis and generation.', 'prautoblogger' ),
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'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' ),
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'id' => 'prautoblogger_tone',
|
'id' => 'prautoblogger_tone',
|
||||||
'label' => __( 'Content Tone', 'prautoblogger' ),
|
'label' => __( 'Content Tone', 'prautoblogger' ),
|
||||||
@@ -187,6 +215,22 @@ class PRAutoBlogger_Settings_Fields {
|
|||||||
'default' => 2000,
|
'default' => 2000,
|
||||||
'min' => 500,
|
'min' => 500,
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'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' ),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'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' ),
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'id' => 'prautoblogger_topic_exclusions',
|
'id' => 'prautoblogger_topic_exclusions',
|
||||||
'label' => __( 'Topic Exclusions', 'prautoblogger' ),
|
'label' => __( 'Topic Exclusions', 'prautoblogger' ),
|
||||||
@@ -209,7 +253,8 @@ class PRAutoBlogger_Settings_Fields {
|
|||||||
'type' => 'checkboxes',
|
'type' => 'checkboxes',
|
||||||
'section' => 'prautoblogger_sources',
|
'section' => 'prautoblogger_sources',
|
||||||
'options' => [
|
'options' => [
|
||||||
'reddit' => __( 'Reddit (RSS + .json)', 'prautoblogger' ),
|
'reddit' => __( 'Reddit (RSS + .json)', 'prautoblogger' ),
|
||||||
|
'llm_research' => __( 'LLM Deep Research (reasoning model)', 'prautoblogger' ),
|
||||||
],
|
],
|
||||||
'default' => '["reddit"]',
|
'default' => '["reddit"]',
|
||||||
'description' => __( 'Select which platforms to monitor for topics.', 'prautoblogger' ),
|
'description' => __( 'Select which platforms to monitor for topics.', 'prautoblogger' ),
|
||||||
@@ -247,140 +292,6 @@ class PRAutoBlogger_Settings_Fields {
|
|||||||
'max' => 100,
|
'max' => 100,
|
||||||
'description' => __( 'Maximum posts to fetch per subreddit per collection run.', 'prautoblogger' ),
|
'description' => __( 'Maximum posts to fetch per subreddit per collection run.', 'prautoblogger' ),
|
||||||
],
|
],
|
||||||
|
|
||||||
// ── Schedule & Budget ───────────────────────────────────────
|
|
||||||
[
|
|
||||||
'id' => 'prautoblogger_daily_article_target',
|
|
||||||
'label' => __( 'Articles per Day', 'prautoblogger' ),
|
|
||||||
'type' => 'number',
|
|
||||||
'section' => 'prautoblogger_schedule',
|
|
||||||
'default' => 1,
|
|
||||||
'min' => 1,
|
|
||||||
'max' => 10,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'prautoblogger_schedule_time',
|
|
||||||
'label' => __( 'Generation Time', 'prautoblogger' ),
|
|
||||||
'type' => 'time',
|
|
||||||
'section' => 'prautoblogger_schedule',
|
|
||||||
'default' => '03:00',
|
|
||||||
'description' => __( 'Daily generation runs at this time (server timezone).', 'prautoblogger' ),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'prautoblogger_monthly_budget_usd',
|
|
||||||
'label' => __( 'Monthly Budget (USD)', 'prautoblogger' ),
|
|
||||||
'type' => 'number',
|
|
||||||
'section' => 'prautoblogger_schedule',
|
|
||||||
'default' => 50,
|
|
||||||
'min' => 0,
|
|
||||||
'step' => '0.01',
|
|
||||||
'description' => __( 'Hard stop when reached. 0 = unlimited.', 'prautoblogger' ),
|
|
||||||
],
|
|
||||||
|
|
||||||
// ── Publishing ──────────────────────────────────────────────
|
|
||||||
[
|
|
||||||
'id' => 'prautoblogger_auto_publish',
|
|
||||||
'label' => __( 'Auto-Publish', 'prautoblogger' ),
|
|
||||||
'type' => 'toggle',
|
|
||||||
'section' => 'prautoblogger_publishing',
|
|
||||||
'default' => '0',
|
|
||||||
'description' => __( 'Automatically publish editor-approved posts. When off, all posts go to the Review Queue as drafts.', 'prautoblogger' ),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'prautoblogger_default_author',
|
|
||||||
'label' => __( 'Default Author', 'prautoblogger' ),
|
|
||||||
'type' => 'author_select',
|
|
||||||
'section' => 'prautoblogger_publishing',
|
|
||||||
'default' => 0,
|
|
||||||
'description' => __( 'WordPress user assigned as author of generated posts.', 'prautoblogger' ),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'prautoblogger_default_category',
|
|
||||||
'label' => __( 'Default Category', 'prautoblogger' ),
|
|
||||||
'type' => 'category_select',
|
|
||||||
'section' => 'prautoblogger_publishing',
|
|
||||||
'default' => 0,
|
|
||||||
'description' => __( 'Fallback category for generated posts.', 'prautoblogger' ),
|
|
||||||
],
|
|
||||||
|
|
||||||
// ── Logging ─────────────────────────────────────────────────
|
|
||||||
[
|
|
||||||
'id' => 'prautoblogger_log_level',
|
|
||||||
'label' => __( 'Log Level', 'prautoblogger' ),
|
|
||||||
'type' => 'select',
|
|
||||||
'section' => 'prautoblogger_publishing',
|
|
||||||
'default' => 'info',
|
|
||||||
'options' => [
|
|
||||||
'error' => __( 'Error — only failures', 'prautoblogger' ),
|
|
||||||
'warning' => __( 'Warning — errors + warnings', 'prautoblogger' ),
|
|
||||||
'info' => __( 'Info — key events (recommended)', 'prautoblogger' ),
|
|
||||||
'debug' => __( 'Debug — everything (verbose)', 'prautoblogger' ),
|
|
||||||
],
|
|
||||||
'description' => __( 'Controls detail level in the Activity Log.', 'prautoblogger' ),
|
|
||||||
],
|
|
||||||
|
|
||||||
// ── Analytics ───────────────────────────────────────────────
|
|
||||||
[
|
|
||||||
'id' => 'prautoblogger_ga4_property_id',
|
|
||||||
'label' => __( 'GA4 Property ID', 'prautoblogger' ),
|
|
||||||
'type' => 'text',
|
|
||||||
'section' => 'prautoblogger_analytics',
|
|
||||||
'description' => __( 'Format: properties/XXXXXXXXX. Leave blank to skip GA4.', 'prautoblogger' ),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'prautoblogger_ga4_credentials_json',
|
|
||||||
'label' => __( 'Service Account JSON', 'prautoblogger' ),
|
|
||||||
'type' => 'password',
|
|
||||||
'section' => 'prautoblogger_analytics',
|
|
||||||
'description' => __( 'Paste the full JSON key file for a service account with Analytics read access.', 'prautoblogger' ),
|
|
||||||
],
|
|
||||||
|
|
||||||
// ── Images ─────────────────────────────────────────────────
|
|
||||||
[
|
|
||||||
'id' => 'prautoblogger_image_enabled',
|
|
||||||
'label' => __( 'Enable Image Generation', 'prautoblogger' ),
|
|
||||||
'type' => 'toggle',
|
|
||||||
'section' => 'prautoblogger_images',
|
|
||||||
'default' => '0',
|
|
||||||
'description' => __( 'When enabled, generate two images (A/B) for each published article. Requires Cloudflare credentials.', 'prautoblogger' ),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'prautoblogger_cloudflare_ai_token',
|
|
||||||
'label' => __( 'Cloudflare API Token', 'prautoblogger' ),
|
|
||||||
'type' => 'password',
|
|
||||||
'section' => 'prautoblogger_images',
|
|
||||||
'description' => __( 'Workers AI token with Read + Edit scope. Create at dash.cloudflare.com → AI → API Tokens.', 'prautoblogger' ),
|
|
||||||
'icon' => '🔑',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'prautoblogger_cloudflare_account_id',
|
|
||||||
'label' => __( 'Cloudflare Account ID', 'prautoblogger' ),
|
|
||||||
'type' => 'text',
|
|
||||||
'section' => 'prautoblogger_images',
|
|
||||||
'default' => '',
|
|
||||||
'description' => __( 'The Account ID shown on your Cloudflare dashboard sidebar. Used to build the Workers AI URL.', 'prautoblogger' ),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'prautoblogger_image_model',
|
|
||||||
'label' => __( 'Image Model', 'prautoblogger' ),
|
|
||||||
'type' => 'select',
|
|
||||||
'section' => 'prautoblogger_images',
|
|
||||||
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL,
|
|
||||||
'options' => [
|
|
||||||
'flux-1-schnell' => __( 'FLUX.1 [schnell] — fast + cheap (default)', 'prautoblogger' ),
|
|
||||||
'flux-1-dev' => __( 'FLUX.1 [dev] — higher quality, ~4× cost', 'prautoblogger' ),
|
|
||||||
],
|
|
||||||
'description' => __( 'Schnell is the normal choice. Only switch to [dev] when a specific post needs higher quality.', 'prautoblogger' ),
|
|
||||||
'badge' => __( 'Low cost', 'prautoblogger' ),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'prautoblogger_image_style_suffix',
|
|
||||||
'label' => __( 'Style Suffix', 'prautoblogger' ),
|
|
||||||
'type' => 'textarea',
|
|
||||||
'section' => 'prautoblogger_images',
|
|
||||||
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX,
|
|
||||||
'description' => __( 'Appended to every image prompt. Controls the visual look across all placements. Changing this mid-run will cause visible style drift — prefer changing during quiet periods.', 'prautoblogger' ),
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
90
includes/admin/fields/class-open-router-model-field.php
Normal file
90
includes/admin/fields/class-open-router-model-field.php
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
* Who calls it: PRAutoBlogger_Admin_Page::render_field() delegates here
|
||||||
|
* for fields with type 'model_select'.
|
||||||
|
* Dependencies: PRAutoBlogger_Model_Registry_Interface (cached registry lookup).
|
||||||
|
*
|
||||||
|
* @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().
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_OpenRouter_Model_Field {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the model picker trigger button and hidden input.
|
||||||
|
*
|
||||||
|
* @param string $id Field HTML id/name attribute.
|
||||||
|
* @param string $value Current model id (e.g. 'google/gemini-2.5-flash-lite').
|
||||||
|
* @param array<string, mixed> $args Field definition from settings fields.
|
||||||
|
*/
|
||||||
|
public static function render( string $id, string $value, array $args ): void {
|
||||||
|
$capability = $args['capability'] ?? 'text→text';
|
||||||
|
$display_name = $value ?: __( '— Select a model —', 'prautoblogger' );
|
||||||
|
$display_price = '';
|
||||||
|
|
||||||
|
if ( 'image_generation' === $capability ) {
|
||||||
|
// Resolve from the static image model list defined in settings.
|
||||||
|
$image_models = PRAutoBlogger_Settings_Fields_Extended::get_image_models();
|
||||||
|
foreach ( $image_models as $im ) {
|
||||||
|
if ( ( $im['id'] ?? '' ) === $value ) {
|
||||||
|
$display_name = $im['name'] ?? $value;
|
||||||
|
$display_price = '$' . number_format( (float) ( $im['cost_per_image'] ?? 0 ), 4 ) . '/image';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Resolve from the OpenRouter model registry cache.
|
||||||
|
$registry = prautoblogger()->get_executor()->get_model_registry();
|
||||||
|
$model = $registry->find_model( $value );
|
||||||
|
if ( null !== $model ) {
|
||||||
|
$display_name = $model['name'] ?? $value;
|
||||||
|
$in = (float) ( $model['input_price_per_m'] ?? 0 );
|
||||||
|
$out = (float) ( $model['output_price_per_m'] ?? 0 );
|
||||||
|
$display_price = self::format_price( $in ) . ' / ' . self::format_price( $out );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hidden input carries the actual value for the form submission.
|
||||||
|
printf(
|
||||||
|
'<input type="hidden" id="%s" name="%s" value="%s" />',
|
||||||
|
esc_attr( $id ),
|
||||||
|
esc_attr( $id ),
|
||||||
|
esc_attr( $value )
|
||||||
|
);
|
||||||
|
|
||||||
|
// Trigger button — opens the model picker popup via JS.
|
||||||
|
printf(
|
||||||
|
'<button type="button" class="ab-mp-trigger" data-field-id="%s" data-capability="%s">'
|
||||||
|
. '<span class="ab-mp-display-name">%s</span>'
|
||||||
|
. '<span class="ab-mp-display-price">%s</span>'
|
||||||
|
. '<span class="ab-mp-display-arrow">▾</span>'
|
||||||
|
. '</button>',
|
||||||
|
esc_attr( $id ),
|
||||||
|
esc_attr( $capability ),
|
||||||
|
esc_html( $display_name ),
|
||||||
|
esc_html( $display_price )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a price-per-million-tokens value for display.
|
||||||
|
*
|
||||||
|
* @param float $price Price per 1M tokens.
|
||||||
|
* @return string Formatted price string like "$0.25" or "Free".
|
||||||
|
*/
|
||||||
|
private static function format_price( float $price ): string {
|
||||||
|
if ( $price <= 0 ) {
|
||||||
|
return __( 'Free', 'prautoblogger' );
|
||||||
|
}
|
||||||
|
if ( $price < 0.01 ) {
|
||||||
|
return '$' . number_format( $price, 4 );
|
||||||
|
}
|
||||||
|
return '$' . number_format( $price, 2 );
|
||||||
|
}
|
||||||
|
}
|
||||||
66
includes/admin/fields/class-source-status-field.php
Normal file
66
includes/admin/fields/class-source-status-field.php
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the Reddit source status indicator in the admin settings.
|
||||||
|
*
|
||||||
|
* What: shows RSS (primary) and .json (fallback) availability with
|
||||||
|
* last-collection timestamp.
|
||||||
|
* Who calls it: PRAutoBlogger_Admin_Page::render_field() delegates here
|
||||||
|
* for fields with type 'source_status'.
|
||||||
|
* Dependencies: None — reads wp_options only.
|
||||||
|
*
|
||||||
|
* @see admin/class-admin-page.php — Calls render() from the main field switch.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Source_Status_Field {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the Reddit source status indicator.
|
||||||
|
*
|
||||||
|
* Side effects: reads wp_options for last collection time.
|
||||||
|
*/
|
||||||
|
public static function render(): void {
|
||||||
|
$last_collection = get_option( 'prautoblogger_last_collection_time', '' );
|
||||||
|
$last_source_used = get_option( 'prautoblogger_last_source_used', '' );
|
||||||
|
|
||||||
|
echo '<div class="ab-source-status">';
|
||||||
|
|
||||||
|
// Reddit RSS — primary source.
|
||||||
|
printf(
|
||||||
|
'<div class="ab-source-row">'
|
||||||
|
. '<span class="ab-source-dot ab-status-ok"></span>'
|
||||||
|
. '<strong>%s</strong> <span class="ab-source-badge">%s</span>'
|
||||||
|
. '<span class="ab-source-label">%s</span>'
|
||||||
|
. '</div>',
|
||||||
|
esc_html__( 'Reddit RSS', 'prautoblogger' ),
|
||||||
|
esc_html__( 'Primary', 'prautoblogger' ),
|
||||||
|
esc_html__( 'Reliable from all IPs', 'prautoblogger' )
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reddit .json — fallback source.
|
||||||
|
printf(
|
||||||
|
'<div class="ab-source-row">'
|
||||||
|
. '<span class="ab-source-dot ab-status-ok"></span>'
|
||||||
|
. '<strong>%s</strong> <span class="ab-source-badge ab-badge-secondary">%s</span>'
|
||||||
|
. '<span class="ab-source-label">%s</span>'
|
||||||
|
. '</div>',
|
||||||
|
esc_html__( 'Reddit .json', 'prautoblogger' ),
|
||||||
|
esc_html__( 'Fallback + Comments', 'prautoblogger' ),
|
||||||
|
esc_html__( 'May be IP-blocked on some hosts', 'prautoblogger' )
|
||||||
|
);
|
||||||
|
|
||||||
|
// Last collection info.
|
||||||
|
if ( '' !== $last_collection ) {
|
||||||
|
$time_ago = human_time_diff( (int) $last_collection, time() );
|
||||||
|
printf(
|
||||||
|
'<div class="ab-source-meta">%s <strong>%s</strong> %s</div>',
|
||||||
|
esc_html__( 'Last collection:', 'prautoblogger' ),
|
||||||
|
/* translators: %s is a human-readable time difference like "2 hours" */
|
||||||
|
esc_html( sprintf( __( '%s ago', 'prautoblogger' ), $time_ago ) ),
|
||||||
|
'' !== $last_source_used ? esc_html( sprintf( __( 'via %s', 'prautoblogger' ), $last_source_used ) ) : ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,121 +28,15 @@ class PRAutoBlogger_Activator {
|
|||||||
self::create_tables();
|
self::create_tables();
|
||||||
self::set_default_options();
|
self::set_default_options();
|
||||||
self::schedule_cron();
|
self::schedule_cron();
|
||||||
|
self::backpopulate_run_id_meta_v081();
|
||||||
|
|
||||||
// Store the DB version so we can run migrations on future updates.
|
// Store the DB version so we can run migrations on future updates.
|
||||||
update_option( 'prautoblogger_db_version', PRAUTOBLOGGER_DB_VERSION, false );
|
update_option( 'prautoblogger_db_version', PRAUTOBLOGGER_DB_VERSION, false );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Delegate to `PRAutoBlogger_Schema_Installer::install()` — see that class. */
|
||||||
* Create all custom database tables using dbDelta.
|
|
||||||
*
|
|
||||||
* Side effects: creates/updates four database tables.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
private static function create_tables(): void {
|
private static function create_tables(): void {
|
||||||
global $wpdb;
|
PRAutoBlogger_Schema_Installer::install();
|
||||||
|
|
||||||
$charset_collate = $wpdb->get_charset_collate();
|
|
||||||
$prefix = $wpdb->prefix . 'prautoblogger_';
|
|
||||||
|
|
||||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
|
||||||
|
|
||||||
// Source data — raw posts/comments from social platforms.
|
|
||||||
$sql_source_data = "CREATE TABLE {$prefix}source_data (
|
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
source_type VARCHAR(50) NOT NULL,
|
|
||||||
source_id VARCHAR(255) NOT NULL,
|
|
||||||
subreddit VARCHAR(255) DEFAULT NULL,
|
|
||||||
title TEXT DEFAULT NULL,
|
|
||||||
content LONGTEXT DEFAULT NULL,
|
|
||||||
author VARCHAR(255) DEFAULT NULL,
|
|
||||||
score INT DEFAULT 0,
|
|
||||||
comment_count INT DEFAULT 0,
|
|
||||||
permalink VARCHAR(500) DEFAULT NULL,
|
|
||||||
collected_at DATETIME NOT NULL,
|
|
||||||
metadata_json LONGTEXT DEFAULT NULL,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
UNIQUE KEY source_unique (source_type, source_id),
|
|
||||||
KEY source_collected (source_type, collected_at)
|
|
||||||
) {$charset_collate};";
|
|
||||||
|
|
||||||
// Analysis results — detected patterns from source data.
|
|
||||||
$sql_analysis = "CREATE TABLE {$prefix}analysis_results (
|
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
analysis_type VARCHAR(50) NOT NULL,
|
|
||||||
topic VARCHAR(500) NOT NULL,
|
|
||||||
summary TEXT DEFAULT NULL,
|
|
||||||
frequency INT DEFAULT 1,
|
|
||||||
relevance_score FLOAT DEFAULT 0,
|
|
||||||
source_ids_json LONGTEXT DEFAULT NULL,
|
|
||||||
analyzed_at DATETIME NOT NULL,
|
|
||||||
metadata_json LONGTEXT DEFAULT NULL,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
KEY type_relevance (analysis_type, relevance_score),
|
|
||||||
KEY analyzed_at (analyzed_at)
|
|
||||||
) {$charset_collate};";
|
|
||||||
|
|
||||||
// Generation log — every API call with cost tracking.
|
|
||||||
// run_id groups all log entries from a single pipeline execution so
|
|
||||||
// link_generation_logs() can accurately attribute costs to a post.
|
|
||||||
$sql_generation_log = "CREATE TABLE {$prefix}generation_log (
|
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
post_id BIGINT UNSIGNED DEFAULT NULL,
|
|
||||||
run_id VARCHAR(36) DEFAULT NULL,
|
|
||||||
stage VARCHAR(50) NOT NULL,
|
|
||||||
provider VARCHAR(50) NOT NULL,
|
|
||||||
model VARCHAR(100) NOT NULL,
|
|
||||||
prompt_tokens INT DEFAULT 0,
|
|
||||||
completion_tokens INT DEFAULT 0,
|
|
||||||
estimated_cost DECIMAL(10,6) DEFAULT 0,
|
|
||||||
request_json LONGTEXT DEFAULT NULL,
|
|
||||||
response_status VARCHAR(20) NOT NULL DEFAULT 'success',
|
|
||||||
error_message TEXT DEFAULT NULL,
|
|
||||||
created_at DATETIME NOT NULL,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
KEY post_id (post_id),
|
|
||||||
KEY run_id (run_id),
|
|
||||||
KEY created_at (created_at),
|
|
||||||
KEY stage (stage)
|
|
||||||
) {$charset_collate};";
|
|
||||||
|
|
||||||
// Content scores — post performance metrics for self-improvement.
|
|
||||||
$sql_content_scores = "CREATE TABLE {$prefix}content_scores (
|
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
post_id BIGINT UNSIGNED NOT NULL,
|
|
||||||
pageviews INT DEFAULT 0,
|
|
||||||
avg_time_on_page FLOAT DEFAULT 0,
|
|
||||||
bounce_rate FLOAT DEFAULT 0,
|
|
||||||
comment_count INT DEFAULT 0,
|
|
||||||
composite_score FLOAT DEFAULT 0,
|
|
||||||
score_factors_json LONGTEXT DEFAULT NULL,
|
|
||||||
measured_at DATETIME NOT NULL,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
KEY post_id (post_id),
|
|
||||||
KEY measured_at (measured_at),
|
|
||||||
KEY composite_score (composite_score)
|
|
||||||
) {$charset_collate};";
|
|
||||||
|
|
||||||
// Event log — structured application log entries for the admin log viewer.
|
|
||||||
$sql_event_log = "CREATE TABLE {$prefix}event_log (
|
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
level VARCHAR(10) NOT NULL DEFAULT 'info',
|
|
||||||
context VARCHAR(100) DEFAULT NULL,
|
|
||||||
message TEXT NOT NULL,
|
|
||||||
meta_json LONGTEXT DEFAULT NULL,
|
|
||||||
created_at DATETIME NOT NULL,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
KEY level (level),
|
|
||||||
KEY created_at (created_at),
|
|
||||||
KEY level_created (level, created_at)
|
|
||||||
) {$charset_collate};";
|
|
||||||
|
|
||||||
dbDelta( $sql_source_data );
|
|
||||||
dbDelta( $sql_analysis );
|
|
||||||
dbDelta( $sql_generation_log );
|
|
||||||
dbDelta( $sql_content_scores );
|
|
||||||
dbDelta( $sql_event_log );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -169,8 +63,12 @@ class PRAutoBlogger_Activator {
|
|||||||
'prautoblogger_max_word_count' => 2000,
|
'prautoblogger_max_word_count' => 2000,
|
||||||
'prautoblogger_topic_exclusions' => '[]',
|
'prautoblogger_topic_exclusions' => '[]',
|
||||||
'prautoblogger_enabled_sources' => '["reddit"]',
|
'prautoblogger_enabled_sources' => '["reddit"]',
|
||||||
|
'prautoblogger_article_font_family' => 'default',
|
||||||
|
'prautoblogger_article_font_size' => 0,
|
||||||
|
'prautoblogger_table_borders' => '1',
|
||||||
'prautoblogger_schedule_time' => '03:00',
|
'prautoblogger_schedule_time' => '03:00',
|
||||||
'prautoblogger_log_level' => 'info',
|
'prautoblogger_log_level' => 'info',
|
||||||
|
'prautoblogger_image_nsfw_retry' => '1',
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ( $defaults as $key => $value ) {
|
foreach ( $defaults as $key => $value ) {
|
||||||
@@ -192,14 +90,8 @@ class PRAutoBlogger_Activator {
|
|||||||
*/
|
*/
|
||||||
private static function schedule_cron(): void {
|
private static function schedule_cron(): void {
|
||||||
if ( ! wp_next_scheduled( 'prautoblogger_daily_generation' ) ) {
|
if ( ! wp_next_scheduled( 'prautoblogger_daily_generation' ) ) {
|
||||||
// Schedule for the configured time tomorrow.
|
$timestamp = self::next_daily_generation_timestamp();
|
||||||
$time_str = get_option( 'prautoblogger_schedule_time', '03:00' );
|
if ( $timestamp > 0 ) {
|
||||||
$parts = explode( ':', $time_str );
|
|
||||||
$hour = isset( $parts[0] ) ? absint( $parts[0] ) : 3;
|
|
||||||
$minute = isset( $parts[1] ) ? absint( $parts[1] ) : 0;
|
|
||||||
$timestamp = strtotime( "tomorrow {$hour}:{$minute}" );
|
|
||||||
|
|
||||||
if ( false !== $timestamp ) {
|
|
||||||
wp_schedule_event( $timestamp, 'daily', 'prautoblogger_daily_generation' );
|
wp_schedule_event( $timestamp, 'daily', 'prautoblogger_daily_generation' );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,5 +114,156 @@ class PRAutoBlogger_Activator {
|
|||||||
if ( ! wp_next_scheduled( 'prautoblogger_collect_metrics' ) ) {
|
if ( ! wp_next_scheduled( 'prautoblogger_collect_metrics' ) ) {
|
||||||
wp_schedule_event( time() + HOUR_IN_SECONDS, 'prautoblogger_six_hours', 'prautoblogger_collect_metrics' );
|
wp_schedule_event( time() + HOUR_IN_SECONDS, 'prautoblogger_six_hours', 'prautoblogger_collect_metrics' );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.8.1: daily reaper for orphan `llm_research` rows. Pinned to
|
||||||
|
// 03:15 so it runs 15 min after the primary generation cron, giving
|
||||||
|
// any live pipeline time to finish and call amortize_research_costs
|
||||||
|
// cleanly before the reaper scans.
|
||||||
|
if ( ! wp_next_scheduled( 'prautoblogger_reap_orphan_research_rows' ) ) {
|
||||||
|
$tomorrow = strtotime( 'tomorrow 03:15' );
|
||||||
|
if ( false !== $tomorrow ) {
|
||||||
|
wp_schedule_event( $tomorrow, 'daily', 'prautoblogger_reap_orphan_research_rows' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the next daily-generation timestamp in the site's configured
|
||||||
|
* timezone. Fixes the v<0.8.2 bug where `strtotime("tomorrow HH:MM")`
|
||||||
|
* evaluated in UTC (WP core sets `date_default_timezone_set('UTC')`),
|
||||||
|
* firing the cron N hours off from user intent where N is the site's
|
||||||
|
* UTC offset.
|
||||||
|
*
|
||||||
|
* @return int Unix timestamp, or 0 on any failure.
|
||||||
|
*/
|
||||||
|
public static function next_daily_generation_timestamp(): int {
|
||||||
|
$time_str = (string) get_option( 'prautoblogger_schedule_time', '03:00' );
|
||||||
|
$parts = explode( ':', $time_str );
|
||||||
|
$hour = isset( $parts[0] ) ? absint( $parts[0] ) : 3;
|
||||||
|
$minute = isset( $parts[1] ) ? absint( $parts[1] ) : 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$site_tz = function_exists( 'wp_timezone' )
|
||||||
|
? wp_timezone()
|
||||||
|
: new \DateTimeZone( 'UTC' );
|
||||||
|
$local = ( new \DateTimeImmutable( 'tomorrow', $site_tz ) )
|
||||||
|
->setTime( $hour, $minute, 0 );
|
||||||
|
return (int) $local->getTimestamp();
|
||||||
|
} catch ( \Exception $e ) {
|
||||||
|
// Defensive: never leave the site without any schedule because
|
||||||
|
// of a bad tz string. Fall back to the pre-fix behaviour.
|
||||||
|
$timestamp = strtotime( "tomorrow {$hour}:{$minute}" );
|
||||||
|
return false === $timestamp ? 0 : (int) $timestamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.8.2 one-shot migration — reschedule `prautoblogger_daily_generation`
|
||||||
|
* in the site's timezone.
|
||||||
|
*
|
||||||
|
* Existing installs had their daily cron scheduled with the v<0.8.2
|
||||||
|
* UTC-interpretation bug. This clears the stale event and reschedules it
|
||||||
|
* using the timezone-aware helper, exactly once per site (gated by
|
||||||
|
* `prautoblogger_migrated_schedule_tz_v082`). Logs an INFO line with the
|
||||||
|
* before/after next-run times so the outcome is auditable.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function reschedule_daily_in_site_timezone_v082(): void {
|
||||||
|
if ( get_option( 'prautoblogger_migrated_schedule_tz_v082' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$old_timestamp = wp_next_scheduled( 'prautoblogger_daily_generation' );
|
||||||
|
wp_clear_scheduled_hook( 'prautoblogger_daily_generation' );
|
||||||
|
|
||||||
|
$new_timestamp = self::next_daily_generation_timestamp();
|
||||||
|
if ( $new_timestamp > 0 ) {
|
||||||
|
wp_schedule_event( $new_timestamp, 'daily', 'prautoblogger_daily_generation' );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( class_exists( 'PRAutoBlogger_Logger', false ) ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
|
sprintf(
|
||||||
|
'Rescheduled daily generation in site timezone (was UTC). Old next_run: %s, new next_run: %s.',
|
||||||
|
false !== $old_timestamp ? gmdate( 'Y-m-d H:i:s \\U\\T\\C', (int) $old_timestamp ) : 'unscheduled',
|
||||||
|
$new_timestamp > 0 ? gmdate( 'Y-m-d H:i:s \\U\\T\\C', $new_timestamp ) : 'unscheduled'
|
||||||
|
),
|
||||||
|
'activator'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
update_option( 'prautoblogger_migrated_schedule_tz_v082', '1' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot v0.8.1 migration — back-populate `_prautoblogger_run_id`
|
||||||
|
* post_meta for existing posts from their gen-log rows.
|
||||||
|
*
|
||||||
|
* Lets the orphan-research-row reaper (v0.8.1+) attribute historic
|
||||||
|
* orphan rows to sibling posts via post_meta lookup, without having
|
||||||
|
* to walk the generation_log table. Gated by
|
||||||
|
* `prautoblogger_migrated_run_id_meta_v081` so it runs exactly once
|
||||||
|
* per site.
|
||||||
|
*
|
||||||
|
* Side effects: adds post_meta rows for existing PRAutoBlogger posts
|
||||||
|
* that don't already have `_prautoblogger_run_id`.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private static function backpopulate_run_id_meta_v081(): void {
|
||||||
|
if ( get_option( 'prautoblogger_migrated_run_id_meta_v081' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$gen_log = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||||
|
|
||||||
|
// For each (post_id, run_id) pair present in the gen-log, write
|
||||||
|
// the run_id meta if it's not already set. Uses the first run_id
|
||||||
|
// seen per post — in practice each post is linked to exactly one run.
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
$pairs = $wpdb->get_results(
|
||||||
|
"SELECT post_id, MIN(run_id) AS run_id FROM {$gen_log}
|
||||||
|
WHERE post_id IS NOT NULL AND run_id IS NOT NULL AND run_id != ''
|
||||||
|
GROUP BY post_id"
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( is_array( $pairs ) ) {
|
||||||
|
foreach ( $pairs as $row ) {
|
||||||
|
$post_id = (int) ( $row->post_id ?? 0 );
|
||||||
|
$run_id = (string) ( $row->run_id ?? '' );
|
||||||
|
if ( $post_id > 0 && '' !== $run_id && ! metadata_exists( 'post', $post_id, '_prautoblogger_run_id' ) ) {
|
||||||
|
update_post_meta( $post_id, '_prautoblogger_run_id', $run_id );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update_option( 'prautoblogger_migrated_run_id_meta_v081', '1' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.9.0 migration: flip legacy default image model to Runware schnell
|
||||||
|
* and re-derive provider. CEO decision 2026-04-21 — 65× cheaper than
|
||||||
|
* Gemini Nano Banana. Explicit user selections are preserved.
|
||||||
|
*
|
||||||
|
* Side effects: may update `prautoblogger_image_model`,
|
||||||
|
* `prautoblogger_image_provider`, and sets the
|
||||||
|
* `prautoblogger_migrated_default_image_v090` flag.
|
||||||
|
*/
|
||||||
|
public static function migrate_default_image_v090(): void {
|
||||||
|
if ( get_option( 'prautoblogger_migrated_default_image_v090' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$curr = (string) get_option( 'prautoblogger_image_model', '' );
|
||||||
|
if ( in_array( $curr, [ '', 'google/gemini-2.5-flash-image' ], true ) ) {
|
||||||
|
update_option( 'prautoblogger_image_model', 'runware:100@1' );
|
||||||
|
}
|
||||||
|
$final = (string) get_option( 'prautoblogger_image_model', 'runware:100@1' );
|
||||||
|
$prov = PRAutoBlogger_Image_Model_Registry::provider_for( $final );
|
||||||
|
if ( '' !== $prov ) {
|
||||||
|
update_option( 'prautoblogger_image_provider', $prov );
|
||||||
|
}
|
||||||
|
update_option( 'prautoblogger_migrated_default_image_v090', '1' );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
145
includes/class-ajax-handlers.php
Normal file
145
includes/class-ajax-handlers.php
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX endpoint handlers for PRAutoBlogger admin actions.
|
||||||
|
*
|
||||||
|
* What: Handles image generation, model registry, and connection test AJAX requests.
|
||||||
|
* Who calls it: PRAutoBlogger registers wp_ajax hooks that delegate to methods here.
|
||||||
|
* Dependencies: PRAutoBlogger_Image_Pipeline, PRAutoBlogger_Source_Collector,
|
||||||
|
* PRAutoBlogger_OpenRouter_Provider, PRAutoBlogger_Reddit_Provider,
|
||||||
|
* PRAutoBlogger_OpenRouter_Model_Registry, PRAutoBlogger_Logger.
|
||||||
|
*
|
||||||
|
* @see class-prautoblogger.php — Hook registration that wires these handlers.
|
||||||
|
* @see class-executor.php — Generation-related AJAX lives there (generate_now, status).
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Ajax_Handlers {
|
||||||
|
|
||||||
|
/** @var PRAutoBlogger_OpenRouter_Model_Registry Model registry instance (shared with Executor). */
|
||||||
|
private PRAutoBlogger_OpenRouter_Model_Registry $model_registry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param PRAutoBlogger_OpenRouter_Model_Registry $model_registry Shared model registry.
|
||||||
|
*/
|
||||||
|
public function __construct( PRAutoBlogger_OpenRouter_Model_Registry $model_registry ) {
|
||||||
|
$this->model_registry = $model_registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX handler: generate images for existing posts that lack them.
|
||||||
|
*
|
||||||
|
* Accepts a `post_id` parameter. Generates Image A (article-driven) and
|
||||||
|
* sets it as the featured image. Useful for retroactively adding images
|
||||||
|
* to posts published before image generation was enabled/fixed.
|
||||||
|
*
|
||||||
|
* Side effects: image provider API call, media library write, post meta update.
|
||||||
|
*/
|
||||||
|
public function on_ajax_generate_image(): void {
|
||||||
|
check_ajax_referer( 'prautoblogger_generate_image', 'nonce' );
|
||||||
|
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image generation can take 20-30s (Runware FLUX + OpenRouter image models).
|
||||||
|
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||||
|
@set_time_limit( 120 );
|
||||||
|
|
||||||
|
$post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0;
|
||||||
|
if ( 0 === $post_id ) {
|
||||||
|
wp_send_json_error( [ '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 ) ] );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pipeline = new PRAutoBlogger_Image_Pipeline();
|
||||||
|
$article_data = [
|
||||||
|
'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_data = ( new PRAutoBlogger_Source_Collector() )->get_source_data_for_image(
|
||||||
|
is_array( $source_ids ) ? array_map( 'absint', $source_ids ) : []
|
||||||
|
);
|
||||||
|
|
||||||
|
// The pipeline sets featured image and Image B meta internally.
|
||||||
|
$result = $pipeline->generate_and_attach_images( $post_id, $article_data, $source_data );
|
||||||
|
|
||||||
|
wp_send_json_success( $result );
|
||||||
|
} catch ( \Throwable $e ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->error(
|
||||||
|
sprintf( 'Retroactive image gen %s for post %d: %s', get_class( $e ), $post_id, $e->getMessage() ),
|
||||||
|
'image_pipeline'
|
||||||
|
);
|
||||||
|
wp_send_json_error( [ 'message' => $e->getMessage() ] );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX handler: return the model registry for the admin model picker.
|
||||||
|
*
|
||||||
|
* Returns the full cached model list. If the registry is empty, triggers
|
||||||
|
* a refresh first. Called by the model-picker.js popup.
|
||||||
|
*
|
||||||
|
* Side effects: may trigger an OpenRouter API call if registry is empty.
|
||||||
|
*/
|
||||||
|
public function on_ajax_get_models(): void {
|
||||||
|
check_ajax_referer( 'prautoblogger_get_models', 'nonce' );
|
||||||
|
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$models = $this->model_registry->get_models();
|
||||||
|
|
||||||
|
// Auto-refresh if the registry has never been populated.
|
||||||
|
if ( empty( $models ) ) {
|
||||||
|
$this->model_registry->refresh( true );
|
||||||
|
$models = $this->model_registry->get_models();
|
||||||
|
}
|
||||||
|
|
||||||
|
wp_send_json_success( $models );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX handler: test API connections (OpenRouter, Reddit).
|
||||||
|
*
|
||||||
|
* Accepts a `service` parameter: 'openrouter', 'reddit', or 'all'.
|
||||||
|
*
|
||||||
|
* Side effects: API calls to OpenRouter and/or Reddit to validate credentials.
|
||||||
|
*/
|
||||||
|
public function on_ajax_test_connection(): void {
|
||||||
|
check_ajax_referer( 'prautoblogger_test_connection', 'nonce' );
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = isset( $_POST['service'] ) ? sanitize_text_field( wp_unslash( $_POST['service'] ) ) : '';
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
if ( 'openrouter' === $service || 'all' === $service ) {
|
||||||
|
$results['openrouter'] = ( new PRAutoBlogger_OpenRouter_Provider() )->validate_credentials_detailed();
|
||||||
|
}
|
||||||
|
if ( 'reddit' === $service || 'all' === $service ) {
|
||||||
|
$reddit = new PRAutoBlogger_Reddit_Provider();
|
||||||
|
$is_ok = $reddit->validate_credentials();
|
||||||
|
$results['reddit'] = $is_ok
|
||||||
|
? [ 'status' => 'ok', 'message' => __( 'Reddit source available (RSS primary, .json fallback).', 'prautoblogger' ) ]
|
||||||
|
: [ 'status' => 'error', 'message' => __( 'Reddit sources unreachable (both RSS and .json failed).', 'prautoblogger' ) ];
|
||||||
|
}
|
||||||
|
wp_send_json_success( $results );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ class PRAutoBlogger_Autoloader {
|
|||||||
private static array $directories = [
|
private static array $directories = [
|
||||||
'', // includes/ root
|
'', // includes/ root
|
||||||
'admin/',
|
'admin/',
|
||||||
|
'admin/fields/',
|
||||||
'core/',
|
'core/',
|
||||||
'providers/',
|
'providers/',
|
||||||
'models/',
|
'models/',
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class PRAutoBlogger_Deactivator {
|
|||||||
$hooks = [
|
$hooks = [
|
||||||
'prautoblogger_daily_generation',
|
'prautoblogger_daily_generation',
|
||||||
'prautoblogger_collect_metrics',
|
'prautoblogger_collect_metrics',
|
||||||
|
'prautoblogger_reap_orphan_research_rows',
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ( $hooks as $hook ) {
|
foreach ( $hooks as $hook ) {
|
||||||
|
|||||||
298
includes/class-executor.php
Normal file
298
includes/class-executor.php
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron/AJAX handlers for content generation, metrics collection, model registry.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the daily generation cron event.
|
||||||
|
*
|
||||||
|
* Uses an atomic DB mutex. If the pipeline queues additional articles,
|
||||||
|
* they fire as chained cron events and the lock is released by the
|
||||||
|
* last article job — not here.
|
||||||
|
*
|
||||||
|
* Side effects: API calls, database writes, WordPress post creation.
|
||||||
|
*/
|
||||||
|
public function on_daily_generation(): void {
|
||||||
|
do_action( 'prautoblogger_refresh_model_registry', false );
|
||||||
|
|
||||||
|
if ( ! PRAutoBlogger_Generation_Lock::acquire() ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->info( 'Daily generation skipped: already running (lock held).', 'scheduler' );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
( new PRAutoBlogger_Pipeline_Runner() )->run();
|
||||||
|
|
||||||
|
// Release lock only if no articles were queued for chained processing.
|
||||||
|
if ( ! get_option( 'prautoblogger_article_queue' ) ) {
|
||||||
|
PRAutoBlogger_Generation_Lock::release();
|
||||||
|
}
|
||||||
|
} catch ( \Throwable $e ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->error(
|
||||||
|
sprintf( 'Daily generation %s: %s', get_class( $e ), $e->getMessage() ),
|
||||||
|
'scheduler'
|
||||||
|
);
|
||||||
|
PRAutoBlogger_Generation_Lock::release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the metrics collection cron event.
|
||||||
|
* Side effects: API calls to GA4, database writes to ab_content_scores.
|
||||||
|
*/
|
||||||
|
public function on_collect_metrics(): void {
|
||||||
|
try {
|
||||||
|
( new PRAutoBlogger_Metrics_Collector() )->collect_all();
|
||||||
|
} catch ( \Throwable $e ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->error(
|
||||||
|
sprintf( 'Metrics collection %s: %s', get_class( $e ), $e->getMessage() ),
|
||||||
|
'metrics'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX handler: kick off manual generation as a background cron job.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* 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' ),
|
||||||
|
] );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron handler: runs pipeline orchestration + first article.
|
||||||
|
*
|
||||||
|
* If the pipeline queues additional articles (2..N), they will fire as
|
||||||
|
* chained cron events — each in its own PHP process. The lock is released
|
||||||
|
* by the pipeline when the last article completes, not here.
|
||||||
|
*/
|
||||||
|
public function on_manual_generation(): void {
|
||||||
|
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||||
|
@ignore_user_abort( true );
|
||||||
|
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||||
|
@set_time_limit( 300 );
|
||||||
|
|
||||||
|
if ( ! PRAutoBlogger_Generation_Lock::acquire() ) {
|
||||||
|
set_transient( self::STATUS_TRANSIENT, [
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => __( 'Could not acquire generation lock. Another run may be in progress.', 'prautoblogger' ),
|
||||||
|
], self::STATUS_TTL );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->update_generation_stage( __( 'Collecting sources from Reddit...', 'prautoblogger' ) );
|
||||||
|
|
||||||
|
$runner = ( new PRAutoBlogger_Pipeline_Runner() )->set_skip_dedup( true );
|
||||||
|
$results = $runner->run();
|
||||||
|
|
||||||
|
// If no articles were queued (target=1 or 0 ideas), finalize here.
|
||||||
|
// When articles ARE queued, the pipeline handles status + lock release.
|
||||||
|
if ( ! get_option( 'prautoblogger_article_queue' ) ) {
|
||||||
|
set_transient( self::STATUS_TRANSIENT, [
|
||||||
|
'status' => 'complete',
|
||||||
|
'generated' => $results['generated'],
|
||||||
|
'published' => $results['published'],
|
||||||
|
'rejected' => $results['rejected'],
|
||||||
|
'cost' => $results['cost'],
|
||||||
|
], self::STATUS_TTL );
|
||||||
|
PRAutoBlogger_Generation_Lock::release();
|
||||||
|
}
|
||||||
|
} catch ( \Throwable $e ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->error(
|
||||||
|
sprintf( 'Manual generation %s: %s', get_class( $e ), $e->getMessage() ),
|
||||||
|
'pipeline'
|
||||||
|
);
|
||||||
|
set_transient( self::STATUS_TRANSIENT, [
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
], self::STATUS_TTL );
|
||||||
|
PRAutoBlogger_Generation_Lock::release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cron handler: process next queued article. Chained by Pipeline_Runner. */
|
||||||
|
public function on_process_article_queue(): void {
|
||||||
|
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||||
|
@ignore_user_abort( true );
|
||||||
|
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||||
|
@set_time_limit( 300 );
|
||||||
|
|
||||||
|
try {
|
||||||
|
( new PRAutoBlogger_Pipeline_Runner() )->process_next_queued_article();
|
||||||
|
} catch ( \Throwable $e ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->error(
|
||||||
|
sprintf( 'Queued article generation %s: %s', get_class( $e ), $e->getMessage() ),
|
||||||
|
'pipeline'
|
||||||
|
);
|
||||||
|
// Clean up on catastrophic failure.
|
||||||
|
delete_option( 'prautoblogger_article_queue' );
|
||||||
|
PRAutoBlogger_Generation_Lock::release();
|
||||||
|
set_transient( self::STATUS_TRANSIENT, [
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
], self::STATUS_TTL );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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( [ '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 ] );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: update the generation stage for frontend polling.
|
||||||
|
*
|
||||||
|
* @param string $stage Human-readable stage description.
|
||||||
|
*/
|
||||||
|
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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazy-load the OpenRouter model registry singleton.
|
||||||
|
* Config injected here — registry class has no knowledge of PRAUTOBLOGGER_* constants.
|
||||||
|
*
|
||||||
|
* @return PRAutoBlogger_OpenRouter_Model_Registry
|
||||||
|
*/
|
||||||
|
public function get_model_registry(): PRAutoBlogger_OpenRouter_Model_Registry {
|
||||||
|
if ( null === $this->model_registry ) {
|
||||||
|
$this->model_registry = new PRAutoBlogger_OpenRouter_Model_Registry(
|
||||||
|
'prautoblogger_openrouter_model_registry',
|
||||||
|
'prautoblogger_openrouter_model_registry_cache'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return $this->model_registry;
|
||||||
|
}
|
||||||
|
}
|
||||||
95
includes/class-generation-lock.php
Normal file
95
includes/class-generation-lock.php
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database-level atomic mutex for content generation.
|
||||||
|
*
|
||||||
|
* What: Provides acquire/release/check semantics for a single-writer generation lock.
|
||||||
|
* Who calls it: PRAutoBlogger_Executor (daily and manual generation), status polling fallback.
|
||||||
|
* Dependencies: WordPress $wpdb for direct SQL.
|
||||||
|
*
|
||||||
|
* Uses INSERT IGNORE against the wp_options UNIQUE index on option_name to
|
||||||
|
* guarantee only one PHP process can hold the lock at a time. Expired locks
|
||||||
|
* (>1 hour) are cleaned up automatically on acquire to prevent permanent deadlock.
|
||||||
|
*
|
||||||
|
* @see class-executor.php — Acquires/releases the lock around pipeline runs.
|
||||||
|
* @see class-ajax-handlers.php — Releases stale locks in the status fallback path.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Generation_Lock {
|
||||||
|
|
||||||
|
/** @var string Option name used as the lock key in wp_options. */
|
||||||
|
private const LOCK_NAME = 'prautoblogger_generation_lock';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acquire the generation mutex.
|
||||||
|
*
|
||||||
|
* Cleans up expired locks (>1 hour) first, then attempts an atomic INSERT
|
||||||
|
* IGNORE. The UNIQUE constraint on option_name ensures only one caller wins.
|
||||||
|
*
|
||||||
|
* @return bool True if lock acquired, false if already held.
|
||||||
|
*/
|
||||||
|
public static function acquire(): bool {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
// Clean up expired locks to prevent permanent deadlock.
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||||
|
$wpdb->query(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"DELETE FROM {$wpdb->options} WHERE option_name = %s AND CAST(option_value AS UNSIGNED) < %d",
|
||||||
|
self::LOCK_NAME,
|
||||||
|
time() - HOUR_IN_SECONDS
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Atomic insert — UNIQUE constraint guarantees only one process succeeds.
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
$result = $wpdb->query(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, %s)",
|
||||||
|
self::LOCK_NAME,
|
||||||
|
(string) time(),
|
||||||
|
'no'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return $result > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the generation lock is currently held.
|
||||||
|
*
|
||||||
|
* @return bool True if the lock exists (generation in progress or stuck).
|
||||||
|
*/
|
||||||
|
public static function is_locked(): bool {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||||
|
$row = $wpdb->get_var(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
|
||||||
|
self::LOCK_NAME
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return null !== $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release the generation mutex.
|
||||||
|
*
|
||||||
|
* Safe to call even if the lock is not held (no-ops gracefully).
|
||||||
|
*
|
||||||
|
* Side effects: deletes the lock row from wp_options.
|
||||||
|
*/
|
||||||
|
public static function release(): void {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||||
|
$wpdb->query(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"DELETE FROM {$wpdb->options} WHERE option_name = %s",
|
||||||
|
self::LOCK_NAME
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
99
includes/class-migrate-remove-cloudflare-v0100.php
Normal file
99
includes/class-migrate-remove-cloudflare-v0100.php
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot v0.10.0 migration — remove Cloudflare Workers AI as image provider.
|
||||||
|
*
|
||||||
|
* Cloudflare Workers AI was removed as an image provider in v0.10.0.
|
||||||
|
* Runware FLUX.1 schnell is 65× cheaper at equivalent quality, so any user
|
||||||
|
* still on a `cloudflare` provider must be migrated off — otherwise the
|
||||||
|
* image pipeline throws "no provider for id cloudflare" at the first
|
||||||
|
* generation run after upgrade.
|
||||||
|
*
|
||||||
|
* Split out of class-activator.php so that file stays under the 300-line
|
||||||
|
* cap. The activator's maybe_run_db_migrations() delegates here.
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger::maybe_run_db_migrations() once per admin_init
|
||||||
|
* until the `prautoblogger_migrated_remove_cloudflare_v0100`
|
||||||
|
* flag is set.
|
||||||
|
* Dependencies: PRAutoBlogger_Image_Model_Registry::provider_for() for
|
||||||
|
* current-model provider derivation.
|
||||||
|
*
|
||||||
|
* @see class-activator.php — Parallel v0.9.0 migration.
|
||||||
|
* @see class-prautoblogger.php — Calls run() during admin_init.
|
||||||
|
* @see class-image-model-registry.php — Provider derivation source.
|
||||||
|
* @see convo/prautoblogger/threads/2026-04-cloudflare-workers-ai-removal/
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Migrate_Remove_Cloudflare_V0100 {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration flag option key. Presence = already migrated.
|
||||||
|
*/
|
||||||
|
private const FLAG = 'prautoblogger_migrated_remove_cloudflare_v0100';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the migration. Idempotent — second + later calls are no-ops.
|
||||||
|
*
|
||||||
|
* Side effects: may update `prautoblogger_image_model` +
|
||||||
|
* `prautoblogger_image_provider`; always deletes
|
||||||
|
* `prautoblogger_cloudflare_ai_token`,
|
||||||
|
* `prautoblogger_cloudflare_account_id`,
|
||||||
|
* `prautoblogger_cf_image_via_gateway`; sets the
|
||||||
|
* migration flag option.
|
||||||
|
*
|
||||||
|
* Explicitly does NOT touch `prautoblogger_ai_gateway_base_url` —
|
||||||
|
* that option is shared with the OpenRouter AI Gateway route and
|
||||||
|
* must stay intact.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function run(): void {
|
||||||
|
if ( get_option( self::FLAG ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( self::is_user_on_cloudflare() ) {
|
||||||
|
// Closest equivalent — FLUX.1 schnell, Runware-hosted, ~$0.0006/image.
|
||||||
|
update_option( 'prautoblogger_image_model', 'runware:100@1' );
|
||||||
|
update_option( 'prautoblogger_image_provider', 'runware' );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up orphaned CF Workers AI credentials + the dead gateway toggle.
|
||||||
|
delete_option( 'prautoblogger_cloudflare_ai_token' );
|
||||||
|
delete_option( 'prautoblogger_cloudflare_account_id' );
|
||||||
|
delete_option( 'prautoblogger_cf_image_via_gateway' );
|
||||||
|
|
||||||
|
update_option( self::FLAG, '1' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect whether the site is currently configured to use Cloudflare
|
||||||
|
* Workers AI for image generation.
|
||||||
|
*
|
||||||
|
* Because the v0.10.0 image-model registry no longer contains
|
||||||
|
* Cloudflare entries, `provider_for()` returns '' for legacy
|
||||||
|
* `flux-1-schnell` slugs. We therefore look at three signals and
|
||||||
|
* treat any of them as "on Cloudflare":
|
||||||
|
* 1. Current registry-derived provider === 'cloudflare'.
|
||||||
|
* 2. The legacy `prautoblogger_image_provider` option is 'cloudflare'.
|
||||||
|
* 3. The legacy CF-specific slug `flux-1-schnell` is still persisted
|
||||||
|
* as the image model (pre-registry-rename installs).
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private static function is_user_on_cloudflare(): bool {
|
||||||
|
$curr = (string) get_option( 'prautoblogger_image_model', '' );
|
||||||
|
$curr_provider = PRAutoBlogger_Image_Model_Registry::provider_for( $curr );
|
||||||
|
|
||||||
|
if ( 'cloudflare' === $curr_provider ) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$legacy_provider = (string) get_option( 'prautoblogger_image_provider', '' );
|
||||||
|
if ( 'cloudflare' === $legacy_provider ) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'flux-1-schnell' === $curr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,144 +9,160 @@ declare(strict_types=1);
|
|||||||
* business logic, not hook registration.
|
* business logic, not hook registration.
|
||||||
*
|
*
|
||||||
* Triggered by: prautoblogger() singleton in prautoblogger.php, called on `plugins_loaded`.
|
* Triggered by: prautoblogger() singleton in prautoblogger.php, called on `plugins_loaded`.
|
||||||
* Dependencies: All other plugin classes (loaded via autoloader), PRAutoBlogger_Pipeline_Runner.
|
* Dependencies: All other plugin classes (loaded via autoloader), PRAutoBlogger_Executor.
|
||||||
*
|
*
|
||||||
* @see prautoblogger.php — Plugin bootstrap that instantiates this class.
|
* @see prautoblogger.php — Plugin bootstrap that instantiates this class.
|
||||||
* @see core/class-pipeline-runner.php — Executes the generation pipeline.
|
* @see class-executor.php — Cron/AJAX handlers for generation, model registry.
|
||||||
* @see ARCHITECTURE.md — Data flow diagram showing how classes interact.
|
* @see class-ajax-handlers.php — Non-generation AJAX endpoints (images, models, test).
|
||||||
* @see CONVENTIONS.md — Hook naming conventions.
|
* @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.
|
||||||
*/
|
*/
|
||||||
class PRAutoBlogger {
|
class PRAutoBlogger {
|
||||||
|
|
||||||
private bool $initialized = false;
|
private bool $initialized = false;
|
||||||
|
|
||||||
/** @var PRAutoBlogger_OpenRouter_Model_Registry|null Lazy-loaded singleton. */
|
/** @var PRAutoBlogger_Executor Handles generation cron/AJAX, model registry. */
|
||||||
private ?PRAutoBlogger_OpenRouter_Model_Registry $model_registry = null;
|
private PRAutoBlogger_Executor $executor;
|
||||||
|
|
||||||
|
/** @var PRAutoBlogger_Ajax_Handlers Handles non-generation AJAX (images, models, test). */
|
||||||
|
private PRAutoBlogger_Ajax_Handlers $ajax_handlers;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register all hooks and initialize the plugin.
|
* Register all hooks and initialize the plugin.
|
||||||
*
|
* Called once on `plugins_loaded`. Idempotent.
|
||||||
* Called once on `plugins_loaded`. Idempotent — calling it twice is a no-op.
|
|
||||||
*
|
*
|
||||||
* Side effects: registers WordPress actions and filters.
|
* Side effects: registers WordPress actions and filters.
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
public function run(): void {
|
public function run(): void {
|
||||||
if ( $this->initialized ) {
|
if ( $this->initialized ) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$this->initialized = true;
|
$this->initialized = true;
|
||||||
|
$this->executor = new PRAutoBlogger_Executor();
|
||||||
|
$this->ajax_handlers = new PRAutoBlogger_Ajax_Handlers( $this->executor->get_model_registry() );
|
||||||
|
|
||||||
// Check for database migrations on admin_init.
|
|
||||||
add_action( 'admin_init', [ $this, 'on_check_db_version' ] );
|
add_action( 'admin_init', [ $this, 'on_check_db_version' ] );
|
||||||
|
|
||||||
// Register custom cron schedules.
|
|
||||||
add_filter( 'cron_schedules', [ $this, 'filter_add_cron_schedules' ] );
|
add_filter( 'cron_schedules', [ $this, 'filter_add_cron_schedules' ] );
|
||||||
|
|
||||||
// Admin hooks — only load on admin pages.
|
|
||||||
if ( is_admin() ) {
|
if ( is_admin() ) {
|
||||||
$this->register_admin_hooks();
|
$this->register_admin_hooks();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cron hooks — always registered so WP-Cron can fire them.
|
|
||||||
$this->register_cron_hooks();
|
$this->register_cron_hooks();
|
||||||
|
|
||||||
// Frontend hooks — shortcode and REST API for public-facing widget.
|
|
||||||
$this->register_frontend_hooks();
|
$this->register_frontend_hooks();
|
||||||
|
|
||||||
// AJAX hooks — always registered so admin AJAX works.
|
|
||||||
$this->register_ajax_hooks();
|
$this->register_ajax_hooks();
|
||||||
|
|
||||||
/**
|
/** Fires after PRAutoBlogger has finished registering all hooks. */
|
||||||
* Fires after PRAutoBlogger has finished registering all hooks.
|
|
||||||
*
|
|
||||||
* Useful for add-ons that need to hook into PRAutoBlogger's lifecycle.
|
|
||||||
*/
|
|
||||||
do_action( 'prautoblogger_loaded' );
|
do_action( 'prautoblogger_loaded' );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Register admin-only hooks (settings, notices, metabox, dashboard widget). */
|
||||||
* Register admin-only hooks (settings page, notices, metabox, dashboard widget).
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
private function register_admin_hooks(): void {
|
private function register_admin_hooks(): void {
|
||||||
$admin_page = new PRAutoBlogger_Admin_Page();
|
$admin_page = new PRAutoBlogger_Admin_Page();
|
||||||
add_action( 'admin_menu', [ $admin_page, 'on_register_menu' ] );
|
add_action( 'admin_menu', [ $admin_page, 'on_register_menu' ] );
|
||||||
add_action( 'admin_init', [ $admin_page, 'on_register_settings' ] );
|
add_action( 'admin_init', [ $admin_page, 'on_register_settings' ] );
|
||||||
add_action( 'admin_enqueue_scripts', [ $admin_page, 'on_enqueue_assets' ] );
|
add_action( 'admin_enqueue_scripts', [ $admin_page, 'on_enqueue_assets' ] );
|
||||||
|
|
||||||
$notices = new PRAutoBlogger_Admin_Notices();
|
add_action( 'admin_notices', [ new PRAutoBlogger_Admin_Notices(), 'on_display_notices' ] );
|
||||||
add_action( 'admin_notices', [ $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' ] );
|
||||||
$metabox = new PRAutoBlogger_Post_Metabox();
|
add_action( 'wp_dashboard_setup', [ new PRAutoBlogger_Dashboard_Widget(), 'on_register_widget' ] );
|
||||||
add_action( 'add_meta_boxes', [ $metabox, 'on_register_metabox' ] );
|
|
||||||
|
|
||||||
$metrics_page = new PRAutoBlogger_Metrics_Page();
|
|
||||||
add_action( 'admin_menu', [ $metrics_page, 'on_register_menu' ] );
|
|
||||||
|
|
||||||
$dashboard_widget = new PRAutoBlogger_Dashboard_Widget();
|
|
||||||
add_action( 'wp_dashboard_setup', [ $dashboard_widget, 'on_register_widget' ] );
|
|
||||||
|
|
||||||
$review_queue = new PRAutoBlogger_Review_Queue();
|
$review_queue = new PRAutoBlogger_Review_Queue();
|
||||||
add_action( 'admin_menu', [ $review_queue, 'on_register_menu' ] );
|
add_action( 'admin_menu', [ $review_queue, 'on_register_menu' ] );
|
||||||
|
|
||||||
$log_viewer = new PRAutoBlogger_Log_Viewer();
|
$ideas_browser = new PRAutoBlogger_Ideas_Browser();
|
||||||
add_action( 'admin_menu', [ $log_viewer, 'on_register_menu' ] );
|
add_action( 'admin_menu', [ $ideas_browser, 'on_register_menu' ] );
|
||||||
|
add_action( 'admin_menu', [ new PRAutoBlogger_Log_Viewer(), 'on_register_menu' ] );
|
||||||
|
|
||||||
// Block false update notifications from wordpress.org — our plugin name
|
( new PRAutoBlogger_Post_List_Columns() )->register();
|
||||||
// collides with a different "PRAutoBlogger" in the plugin directory.
|
|
||||||
|
// 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', [ $this, 'filter_block_false_updates' ] );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Register frontend hooks (shortcode, REST, typography). */
|
||||||
* Register frontend hooks (shortcode and REST API for posts widget).
|
|
||||||
*
|
|
||||||
* The shortcode renders a mount-point div; the React component hydrates it
|
|
||||||
* on the client side by fetching posts from the REST endpoint.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
private function register_frontend_hooks(): void {
|
private function register_frontend_hooks(): void {
|
||||||
$posts_widget = new PRAutoBlogger_Posts_Widget();
|
$posts_widget = new PRAutoBlogger_Posts_Widget();
|
||||||
add_action( 'init', [ $posts_widget, 'on_register_shortcode' ] );
|
add_action( 'init', [ $posts_widget, 'on_register_shortcode' ] );
|
||||||
add_action( 'rest_api_init', [ $posts_widget, 'on_register_rest_route' ] );
|
add_action( 'rest_api_init', [ $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 );
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Register cron-triggered hooks for scheduled generation and metrics. */
|
/** Register cron-triggered hooks for scheduled generation and metrics. */
|
||||||
private function register_cron_hooks(): void {
|
private function register_cron_hooks(): void {
|
||||||
add_action( 'prautoblogger_daily_generation', [ $this, 'on_daily_generation' ] );
|
add_action( 'prautoblogger_daily_generation', [ $this->executor, 'on_daily_generation' ] );
|
||||||
add_action( 'prautoblogger_collect_metrics', [ $this, 'on_collect_metrics' ] );
|
add_action( 'prautoblogger_collect_metrics', [ $this->executor, 'on_collect_metrics' ] );
|
||||||
|
|
||||||
// Model registry refresh — fires inside on_daily_generation() before the
|
// Manual "Generate Now" runs as a one-shot cron event to avoid
|
||||||
// generation lock, so a stuck lock doesn't freeze the registry too.
|
// Hostinger's 120-second web-server connection timeout.
|
||||||
$registry = $this->get_model_registry();
|
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_Pipeline_Runner::CRON_ACTION,
|
||||||
|
[ $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', [ $registry, 'refresh' ] );
|
||||||
|
|
||||||
|
// Single-idea generation from the Ideas browser page.
|
||||||
|
add_action( 'prautoblogger_generate_from_idea', [ 'PRAutoBlogger_Ideas_Browser', 'on_cron_generate_from_idea' ] );
|
||||||
|
|
||||||
|
// v0.8.1: daily reaper for orphan `llm_research` rows (when a
|
||||||
|
// pipeline dies before amortize_research_costs runs).
|
||||||
|
add_action( 'prautoblogger_reap_orphan_research_rows', [ 'PRAutoBlogger_Research_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']
|
||||||
|
) );
|
||||||
|
} );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Register AJAX handlers for admin actions. */
|
/** Register AJAX handlers for admin actions. */
|
||||||
private function register_ajax_hooks(): void {
|
private function register_ajax_hooks(): void {
|
||||||
add_action( 'wp_ajax_prautoblogger_generate_now', [ $this, 'on_ajax_generate_now' ] );
|
// Generation AJAX (start + status polling) stays on executor.
|
||||||
add_action( 'wp_ajax_prautoblogger_test_connection', [ $this, 'on_ajax_test_connection' ] );
|
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' ] );
|
||||||
|
|
||||||
|
// 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' ] );
|
||||||
|
|
||||||
$review_queue = new PRAutoBlogger_Review_Queue();
|
$review_queue = new PRAutoBlogger_Review_Queue();
|
||||||
add_action( 'wp_ajax_prautoblogger_approve_post', [ $review_queue, 'on_ajax_approve_post' ] );
|
add_action( 'wp_ajax_prautoblogger_approve_post', [ $review_queue, 'on_ajax_approve_post' ] );
|
||||||
add_action( 'wp_ajax_prautoblogger_reject_post', [ $review_queue, 'on_ajax_reject_post' ] );
|
add_action( 'wp_ajax_prautoblogger_reject_post', [ $review_queue, 'on_ajax_reject_post' ] );
|
||||||
|
|
||||||
$log_viewer = new PRAutoBlogger_Log_Viewer();
|
add_action( 'wp_ajax_prautoblogger_clear_logs', [ new PRAutoBlogger_Log_Viewer(), 'on_ajax_clear_logs' ] );
|
||||||
add_action( 'wp_ajax_prautoblogger_clear_logs', [ $log_viewer, 'on_ajax_clear_logs' ] );
|
|
||||||
|
$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' ] );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if database needs migration after plugin update.
|
* Check if database needs migration after plugin update.
|
||||||
*
|
* Compares stored DB version with PRAUTOBLOGGER_DB_VERSION; re-runs activation if different.
|
||||||
* Compares stored DB version with current PRAUTOBLOGGER_DB_VERSION.
|
|
||||||
* If different, re-runs activation to apply schema changes via dbDelta.
|
|
||||||
*
|
*
|
||||||
* Side effects: may update database schema and db_version option.
|
* Side effects: may update database schema and db_version option.
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
public function on_check_db_version(): void {
|
public function on_check_db_version(): void {
|
||||||
$stored_version = get_option( 'prautoblogger_db_version', '0' );
|
$stored_version = get_option( 'prautoblogger_db_version', '0' );
|
||||||
@@ -162,15 +178,68 @@ class PRAutoBlogger {
|
|||||||
update_option( 'prautoblogger_migrated_gemini_flash_lite', '1' );
|
update_option( 'prautoblogger_migrated_gemini_flash_lite', '1' );
|
||||||
}
|
}
|
||||||
|
|
||||||
// One-time migration: re-wrap existing encrypted values with "enc:" prefix
|
// One-time migration (v0.8.0): the admin no longer has an independent
|
||||||
// to prevent the double-encryption bug permanently.
|
// 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' ) ) {
|
if ( ! get_option( 'prautoblogger_migrated_enc_prefix' ) ) {
|
||||||
$enc_options = [ 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json' ];
|
$enc_options = [ 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json', 'prautoblogger_runware_api_key' ];
|
||||||
foreach ( $enc_options as $opt ) {
|
foreach ( $enc_options as $opt ) {
|
||||||
$val = get_option( $opt, '' );
|
$val = get_option( $opt, '' );
|
||||||
// If it has a value but no "enc:" prefix, it's a legacy encrypted value.
|
|
||||||
// Clear it — user must re-enter. We can't reliably tell if it's
|
|
||||||
// single or double encrypted, so a fresh entry is safest.
|
|
||||||
if ( '' !== $val && ! PRAutoBlogger_Encryption::is_encrypted( $val ) ) {
|
if ( '' !== $val && ! PRAutoBlogger_Encryption::is_encrypted( $val ) ) {
|
||||||
delete_option( $opt );
|
delete_option( $opt );
|
||||||
}
|
}
|
||||||
@@ -183,8 +252,7 @@ class PRAutoBlogger {
|
|||||||
* Add custom cron schedules (six-hourly for metrics collection).
|
* Add custom cron schedules (six-hourly for metrics collection).
|
||||||
*
|
*
|
||||||
* @param array<string, array{interval: int, display: string}> $schedules Existing schedules.
|
* @param array<string, array{interval: int, display: string}> $schedules Existing schedules.
|
||||||
*
|
* @return array<string, array{interval: int, display: string}>
|
||||||
* @return array<string, array{interval: int, display: string}> Modified schedules.
|
|
||||||
*/
|
*/
|
||||||
public function filter_add_cron_schedules( array $schedules ): array {
|
public function filter_add_cron_schedules( array $schedules ): array {
|
||||||
$schedules['prautoblogger_six_hours'] = [
|
$schedules['prautoblogger_six_hours'] = [
|
||||||
@@ -196,209 +264,37 @@ class PRAutoBlogger {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Block false update notifications from wordpress.org.
|
* Block false update notifications from wordpress.org.
|
||||||
*
|
|
||||||
* Our plugin slug may collide with a different plugin in the directory.
|
* Our plugin slug may collide with a different plugin in the directory.
|
||||||
* This filter removes any update notice that wordpress.org might push
|
|
||||||
* for our plugin basename, since we manage updates via our own CI/CD.
|
|
||||||
*
|
*
|
||||||
* @param object $transient The update_plugins transient data.
|
* @param object $transient The update_plugins transient data.
|
||||||
*
|
* @return object Modified transient.
|
||||||
* @return object Modified transient with our plugin removed from updates.
|
|
||||||
*/
|
*/
|
||||||
public function filter_block_false_updates( $transient ) {
|
public function filter_block_false_updates( $transient ) {
|
||||||
$plugin_basename = PRAUTOBLOGGER_PLUGIN_BASENAME;
|
if ( isset( $transient->response[ PRAUTOBLOGGER_PLUGIN_BASENAME ] ) ) {
|
||||||
if ( isset( $transient->response[ $plugin_basename ] ) ) {
|
unset( $transient->response[ PRAUTOBLOGGER_PLUGIN_BASENAME ] );
|
||||||
unset( $transient->response[ $plugin_basename ] );
|
|
||||||
}
|
}
|
||||||
return $transient;
|
return $transient;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Expose the executor for external access (e.g., model registry). */
|
||||||
* Handle the daily generation cron event.
|
public function get_executor(): PRAutoBlogger_Executor {
|
||||||
*
|
return $this->executor;
|
||||||
* Uses a database-level atomic mutex to prevent overlapping runs.
|
|
||||||
*
|
|
||||||
* Side effects: API calls, database writes, WordPress post creation.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function on_daily_generation(): void {
|
|
||||||
// Refresh the OpenRouter model registry (idempotent, skips if <12h old).
|
|
||||||
// Fires BEFORE the generation lock so a stuck lock doesn't block the refresh.
|
|
||||||
do_action( 'prautoblogger_refresh_model_registry', false );
|
|
||||||
|
|
||||||
if ( ! $this->acquire_generation_lock() ) {
|
|
||||||
PRAutoBlogger_Logger::instance()->info( 'Daily generation skipped: already running (lock held).', 'scheduler' );
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
( new PRAutoBlogger_Pipeline_Runner() )->run();
|
|
||||||
} catch ( \Exception $e ) {
|
|
||||||
PRAutoBlogger_Logger::instance()->error( 'Daily generation FAILED: ' . $e->getMessage(), 'scheduler' );
|
|
||||||
} finally {
|
|
||||||
$this->release_generation_lock();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ── Backward-compatible proxies ──
|
||||||
* Handle the metrics collection cron event.
|
|
||||||
*
|
|
||||||
* Side effects: API calls to GA4, database writes to ab_content_scores.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function on_collect_metrics(): void {
|
|
||||||
try {
|
|
||||||
$metrics = new PRAutoBlogger_Metrics_Collector();
|
|
||||||
$metrics->collect_all();
|
|
||||||
} catch ( \Exception $e ) {
|
|
||||||
PRAutoBlogger_Logger::instance()->error( 'Metrics collection FAILED: ' . $e->getMessage(), 'metrics' );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/** @see PRAutoBlogger_Executor::on_daily_generation() */
|
||||||
* AJAX handler: trigger manual generation.
|
public function on_daily_generation(): void { $this->executor->on_daily_generation(); }
|
||||||
*
|
|
||||||
* Verifies nonce and capability, then runs the generation pipeline.
|
|
||||||
* Uses the same atomic DB lock as the cron handler.
|
|
||||||
*
|
|
||||||
* Side effects: API calls, database writes, WordPress post creation.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function on_ajax_generate_now(): void {
|
|
||||||
check_ajax_referer( 'prautoblogger_generate_now', 'nonce' );
|
|
||||||
|
|
||||||
if ( ! current_user_can( 'manage_options' ) ) {
|
/** @see PRAutoBlogger_Executor::on_collect_metrics() */
|
||||||
wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 );
|
public function on_collect_metrics(): void { $this->executor->on_collect_metrics(); }
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// LLM API calls can take 30-60+ seconds each. Extend PHP's execution
|
/** @see PRAutoBlogger_Executor::on_ajax_generate_now() */
|
||||||
// limit so the pipeline isn't killed mid-generation. This may be
|
public function on_ajax_generate_now(): void { $this->executor->on_ajax_generate_now(); }
|
||||||
// blocked by Hostinger's hardcoded limits, but it's worth trying.
|
|
||||||
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
|
||||||
@set_time_limit( 300 );
|
|
||||||
|
|
||||||
// Allow admins to force-clear a stale lock.
|
/** @see PRAutoBlogger_Ajax_Handlers::on_ajax_test_connection() */
|
||||||
$force = isset( $_POST['force'] ) && '1' === $_POST['force'];
|
public function on_ajax_test_connection(): void { $this->ajax_handlers->on_ajax_test_connection(); }
|
||||||
if ( $force ) {
|
|
||||||
$this->release_generation_lock();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( ! $this->acquire_generation_lock() ) {
|
|
||||||
wp_send_json_error( [ 'message' => __( 'A generation run is already in progress. Pass force=1 to clear the lock.', 'prautoblogger' ) ] );
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Manual runs skip dedup so "Generate Now" always produces content
|
|
||||||
// even when a related article was recently published.
|
|
||||||
$results = ( new PRAutoBlogger_Pipeline_Runner() )
|
|
||||||
->set_skip_dedup( true )
|
|
||||||
->run();
|
|
||||||
wp_send_json_success( $results );
|
|
||||||
} catch ( \Exception $e ) {
|
|
||||||
PRAutoBlogger_Logger::instance()->error( 'Manual generation FAILED: ' . $e->getMessage(), 'pipeline' );
|
|
||||||
wp_send_json_error( [ 'message' => $e->getMessage() ] );
|
|
||||||
} finally {
|
|
||||||
$this->release_generation_lock();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** AJAX handler: test API connections (OpenRouter, Reddit). */
|
|
||||||
public function on_ajax_test_connection(): void {
|
|
||||||
check_ajax_referer( 'prautoblogger_test_connection', 'nonce' );
|
|
||||||
if ( ! current_user_can( 'manage_options' ) ) {
|
|
||||||
wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 );
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$service = isset( $_POST['service'] ) ? sanitize_text_field( wp_unslash( $_POST['service'] ) ) : '';
|
|
||||||
$results = [];
|
|
||||||
|
|
||||||
if ( 'openrouter' === $service || 'all' === $service ) {
|
|
||||||
$llm = new PRAutoBlogger_OpenRouter_Provider();
|
|
||||||
$results['openrouter'] = $llm->validate_credentials_detailed();
|
|
||||||
}
|
|
||||||
if ( 'reddit' === $service || 'all' === $service ) {
|
|
||||||
$reddit = new PRAutoBlogger_Reddit_Provider();
|
|
||||||
$is_ok = $reddit->validate_credentials();
|
|
||||||
$results['reddit'] = $is_ok
|
|
||||||
? [ 'status' => 'ok', 'message' => __( 'Reddit source available (RSS primary, .json fallback).', 'prautoblogger' ) ]
|
|
||||||
: [ 'status' => 'error', 'message' => __( 'Reddit sources unreachable (both RSS and .json failed).', 'prautoblogger' ) ];
|
|
||||||
}
|
|
||||||
wp_send_json_success( $results );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Acquire a database-level atomic mutex for generation.
|
|
||||||
*
|
|
||||||
* Uses INSERT IGNORE into wp_options — option_name has a UNIQUE index so only
|
|
||||||
* one process can insert the row. Expired locks (>1 hour) are cleaned up first.
|
|
||||||
*
|
|
||||||
* @return bool True if lock acquired, false if another process holds it.
|
|
||||||
*/
|
|
||||||
private function acquire_generation_lock(): bool {
|
|
||||||
global $wpdb;
|
|
||||||
|
|
||||||
$lock_name = 'prautoblogger_generation_lock';
|
|
||||||
$now = (string) time();
|
|
||||||
|
|
||||||
// Clean up expired locks older than 1 hour — prevents permanent deadlock.
|
|
||||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
|
||||||
$wpdb->query(
|
|
||||||
$wpdb->prepare(
|
|
||||||
"DELETE FROM {$wpdb->options} WHERE option_name = %s AND CAST(option_value AS UNSIGNED) < %d",
|
|
||||||
$lock_name,
|
|
||||||
time() - HOUR_IN_SECONDS
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Atomic insert — UNIQUE constraint on option_name guarantees only one process succeeds.
|
|
||||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
|
||||||
$result = $wpdb->query(
|
|
||||||
$wpdb->prepare(
|
|
||||||
"INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, %s)",
|
|
||||||
$lock_name,
|
|
||||||
$now,
|
|
||||||
'no'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
return $result > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lazy-load the OpenRouter model registry singleton.
|
|
||||||
*
|
|
||||||
* Config (option names, transient names) is injected here — the registry
|
|
||||||
* class itself has no knowledge of PRAUTOBLOGGER_* constants.
|
|
||||||
*
|
|
||||||
* @return PRAutoBlogger_OpenRouter_Model_Registry
|
|
||||||
*/
|
|
||||||
public function get_model_registry(): PRAutoBlogger_OpenRouter_Model_Registry {
|
|
||||||
if ( null === $this->model_registry ) {
|
|
||||||
$this->model_registry = new PRAutoBlogger_OpenRouter_Model_Registry(
|
|
||||||
'prautoblogger_openrouter_model_registry',
|
|
||||||
'prautoblogger_openrouter_model_registry_cache'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return $this->model_registry;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Release the generation mutex. */
|
|
||||||
private function release_generation_lock(): void {
|
|
||||||
global $wpdb;
|
|
||||||
|
|
||||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
|
||||||
$wpdb->query(
|
|
||||||
$wpdb->prepare(
|
|
||||||
"DELETE FROM {$wpdb->options} WHERE option_name = %s",
|
|
||||||
'prautoblogger_generation_lock'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/** @see PRAutoBlogger_Executor::get_model_registry() */
|
||||||
|
public function get_model_registry(): PRAutoBlogger_OpenRouter_Model_Registry { return $this->executor->get_model_registry(); }
|
||||||
}
|
}
|
||||||
|
|||||||
136
includes/class-schema-installer.php
Normal file
136
includes/class-schema-installer.php
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates / updates the plugin's custom database tables via `dbDelta`.
|
||||||
|
*
|
||||||
|
* Extracted from `PRAutoBlogger_Activator` in v0.8.2 to keep that class
|
||||||
|
* under the 300-line cap once the timezone-aware cron migration landed.
|
||||||
|
* The Activator delegates to `install()` on activate and on db-version
|
||||||
|
* upgrade; schema definitions live here and only here.
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger_Activator::activate().
|
||||||
|
* Dependencies: WordPress $wpdb, dbDelta() from wp-admin/includes/upgrade.php.
|
||||||
|
*
|
||||||
|
* @see class-activator.php — Sole caller; delegates on activate and db-version bump.
|
||||||
|
* @see ARCHITECTURE.md — Data model section documents each table's purpose.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Schema_Installer {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create (or upgrade) all plugin tables.
|
||||||
|
*
|
||||||
|
* `dbDelta` is idempotent and will create new tables, add missing
|
||||||
|
* columns on existing tables, and add missing indexes — but it will
|
||||||
|
* not drop columns or indexes. That matches the plugin's forward-only
|
||||||
|
* schema-migration policy.
|
||||||
|
*
|
||||||
|
* Side effects: up to five `CREATE TABLE` / `ALTER TABLE` statements.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function install(): void {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$charset_collate = $wpdb->get_charset_collate();
|
||||||
|
$prefix = $wpdb->prefix . 'prautoblogger_';
|
||||||
|
|
||||||
|
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||||
|
|
||||||
|
// Source data — raw posts/comments from social platforms.
|
||||||
|
$sql_source_data = "CREATE TABLE {$prefix}source_data (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
source_type VARCHAR(50) NOT NULL,
|
||||||
|
source_id VARCHAR(255) NOT NULL,
|
||||||
|
subreddit VARCHAR(255) DEFAULT NULL,
|
||||||
|
title TEXT DEFAULT NULL,
|
||||||
|
content LONGTEXT DEFAULT NULL,
|
||||||
|
author VARCHAR(255) DEFAULT NULL,
|
||||||
|
score INT DEFAULT 0,
|
||||||
|
comment_count INT DEFAULT 0,
|
||||||
|
permalink VARCHAR(500) DEFAULT NULL,
|
||||||
|
collected_at DATETIME NOT NULL,
|
||||||
|
metadata_json LONGTEXT DEFAULT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY source_unique (source_type, source_id),
|
||||||
|
KEY source_collected (source_type, collected_at)
|
||||||
|
) {$charset_collate};";
|
||||||
|
|
||||||
|
// Analysis results — detected patterns from source data.
|
||||||
|
$sql_analysis = "CREATE TABLE {$prefix}analysis_results (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
analysis_type VARCHAR(50) NOT NULL,
|
||||||
|
topic VARCHAR(500) NOT NULL,
|
||||||
|
summary TEXT DEFAULT NULL,
|
||||||
|
frequency INT DEFAULT 1,
|
||||||
|
relevance_score FLOAT DEFAULT 0,
|
||||||
|
source_ids_json LONGTEXT DEFAULT NULL,
|
||||||
|
analyzed_at DATETIME NOT NULL,
|
||||||
|
metadata_json LONGTEXT DEFAULT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY type_relevance (analysis_type, relevance_score),
|
||||||
|
KEY analyzed_at (analyzed_at)
|
||||||
|
) {$charset_collate};";
|
||||||
|
|
||||||
|
// Generation log — every API call with cost tracking. `run_id`
|
||||||
|
// groups all entries from a single pipeline execution so
|
||||||
|
// `link_generation_logs()` can accurately attribute costs to a post.
|
||||||
|
$sql_generation_log = "CREATE TABLE {$prefix}generation_log (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
post_id BIGINT UNSIGNED DEFAULT NULL,
|
||||||
|
run_id VARCHAR(36) DEFAULT NULL,
|
||||||
|
stage VARCHAR(50) NOT NULL,
|
||||||
|
provider VARCHAR(50) NOT NULL,
|
||||||
|
model VARCHAR(100) NOT NULL,
|
||||||
|
prompt_tokens INT DEFAULT 0,
|
||||||
|
completion_tokens INT DEFAULT 0,
|
||||||
|
estimated_cost DECIMAL(10,6) DEFAULT 0,
|
||||||
|
request_json LONGTEXT DEFAULT NULL,
|
||||||
|
response_status VARCHAR(20) NOT NULL DEFAULT 'success',
|
||||||
|
error_message TEXT DEFAULT NULL,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY post_id (post_id),
|
||||||
|
KEY run_id (run_id),
|
||||||
|
KEY created_at (created_at),
|
||||||
|
KEY stage (stage)
|
||||||
|
) {$charset_collate};";
|
||||||
|
|
||||||
|
// Content scores — post performance metrics for self-improvement.
|
||||||
|
$sql_content_scores = "CREATE TABLE {$prefix}content_scores (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
post_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
pageviews INT DEFAULT 0,
|
||||||
|
avg_time_on_page FLOAT DEFAULT 0,
|
||||||
|
bounce_rate FLOAT DEFAULT 0,
|
||||||
|
comment_count INT DEFAULT 0,
|
||||||
|
composite_score FLOAT DEFAULT 0,
|
||||||
|
score_factors_json LONGTEXT DEFAULT NULL,
|
||||||
|
measured_at DATETIME NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY post_id (post_id),
|
||||||
|
KEY measured_at (measured_at),
|
||||||
|
KEY composite_score (composite_score)
|
||||||
|
) {$charset_collate};";
|
||||||
|
|
||||||
|
// Event log — structured application log entries for the admin log viewer.
|
||||||
|
$sql_event_log = "CREATE TABLE {$prefix}event_log (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
level VARCHAR(10) NOT NULL DEFAULT 'info',
|
||||||
|
context VARCHAR(100) DEFAULT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
meta_json LONGTEXT DEFAULT NULL,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY level (level),
|
||||||
|
KEY created_at (created_at),
|
||||||
|
KEY level_created (level, created_at)
|
||||||
|
) {$charset_collate};";
|
||||||
|
|
||||||
|
dbDelta( $sql_source_data );
|
||||||
|
dbDelta( $sql_analysis );
|
||||||
|
dbDelta( $sql_generation_log );
|
||||||
|
dbDelta( $sql_content_scores );
|
||||||
|
dbDelta( $sql_event_log );
|
||||||
|
}
|
||||||
|
}
|
||||||
165
includes/core/class-analysis-prompts.php
Normal file
165
includes/core/class-analysis-prompts.php
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* @see core/class-content-analyzer.php — Orchestrates analysis and calls these builders.
|
||||||
|
* @see ARCHITECTURE.md — Data flow step 2.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Analysis_Prompts {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the system prompt for content analysis.
|
||||||
|
*
|
||||||
|
* @param string $niche Niche description from settings.
|
||||||
|
* @param string $performance_context Past performance data summary.
|
||||||
|
* @param int $target_count Number of ideas the LLM should produce.
|
||||||
|
* @return string System prompt text.
|
||||||
|
*/
|
||||||
|
public static function build_system_prompt( string $niche, string $performance_context, int $target_count = 6 ): string {
|
||||||
|
$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";
|
||||||
|
}
|
||||||
|
|
||||||
|
$recent_context = self::get_recent_articles_context();
|
||||||
|
if ( '' !== $recent_context ) {
|
||||||
|
$prompt .= $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";
|
||||||
|
}
|
||||||
|
|
||||||
|
$prompt .= "Respond with valid JSON containing exactly {$target_count} patterns:\n";
|
||||||
|
$prompt .= '{"patterns": [{"type": "...", "topic": "...", "summary": "...", "frequency": N, "relevance_score": 0.X, "suggested_title": "...", "key_points": [...], "target_keywords": [...]}]}';
|
||||||
|
|
||||||
|
return $prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the user prompt containing the actual source data.
|
||||||
|
*
|
||||||
|
* @param string $summary Formatted source data summary.
|
||||||
|
* @param int $target_count Number of ideas requested.
|
||||||
|
* @return string User prompt text.
|
||||||
|
*/
|
||||||
|
public static function build_user_prompt( string $summary, int $target_count = 6 ): string {
|
||||||
|
return "Here are the recent social media posts and comments to analyze. "
|
||||||
|
. "Find {$target_count} distinct, diverse article ideas from this data:\n\n"
|
||||||
|
. $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get past content performance data to feed into analysis for self-improvement.
|
||||||
|
*
|
||||||
|
* Side effects: database read.
|
||||||
|
*
|
||||||
|
* @return string Summary of what topics performed well, or empty string if no data.
|
||||||
|
*/
|
||||||
|
public static function get_performance_context(): string {
|
||||||
|
global $wpdb;
|
||||||
|
$scores_table = $wpdb->prefix . 'prautoblogger_content_scores';
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
$top_posts = $wpdb->get_results(
|
||||||
|
"SELECT cs.post_id, cs.composite_score, p.post_title
|
||||||
|
FROM {$scores_table} cs
|
||||||
|
JOIN {$wpdb->posts} p ON p.ID = cs.post_id
|
||||||
|
WHERE cs.composite_score > 0
|
||||||
|
ORDER BY cs.composite_score DESC
|
||||||
|
LIMIT 5",
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( empty( $top_posts ) ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = [ 'Top performing past articles (learn from these):' ];
|
||||||
|
foreach ( $top_posts as $post ) {
|
||||||
|
$lines[] = sprintf(
|
||||||
|
'- "%s" (score: %.1f)',
|
||||||
|
$post['post_title'],
|
||||||
|
(float) $post['composite_score']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode( "\n", $lines );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get titles of recently published articles so the LLM avoids repeating them.
|
||||||
|
*
|
||||||
|
* Fetches generated posts from the last 30 days. A wider window than the
|
||||||
|
* scorer's 7-day dedup because the LLM is smart enough to find genuinely
|
||||||
|
* new angles even on adjacent topics — we just need it to know what exists.
|
||||||
|
*
|
||||||
|
* Side effects: database read via WP_Query.
|
||||||
|
*
|
||||||
|
* @return string Formatted context block, or empty string if no recent articles.
|
||||||
|
*/
|
||||||
|
public static function get_recent_articles_context(): string {
|
||||||
|
$query = new \WP_Query( [
|
||||||
|
'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,
|
||||||
|
] );
|
||||||
|
|
||||||
|
if ( empty( $query->posts ) ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$titles = [];
|
||||||
|
foreach ( $query->posts as $post_id ) {
|
||||||
|
$titles[] = '- "' . get_the_title( $post_id ) . '"';
|
||||||
|
}
|
||||||
|
|
||||||
|
$block = "IMPORTANT — Topics already covered (last 30 days). Do NOT suggest topics that overlap with these. ";
|
||||||
|
$block .= "Find genuinely NEW angles, questions, or subjects that are NOT already covered:\n";
|
||||||
|
$block .= implode( "\n", $titles );
|
||||||
|
|
||||||
|
return $block;
|
||||||
|
}
|
||||||
|
}
|
||||||
145
includes/core/class-article-worker.php
Normal file
145
includes/core/class-article-worker.php
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* Triggered by: Pipeline_Runner (directly for article 1, via cron for 2..N).
|
||||||
|
* Dependencies: Content_Generator, Chief_Editor, Publisher, Cost_Tracker.
|
||||||
|
*
|
||||||
|
* @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.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Article_Worker {
|
||||||
|
|
||||||
|
private PRAutoBlogger_Cost_Tracker $cost_tracker;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param PRAutoBlogger_Cost_Tracker $cost_tracker Shared cost tracker.
|
||||||
|
*/
|
||||||
|
public function __construct( PRAutoBlogger_Cost_Tracker $cost_tracker ) {
|
||||||
|
$this->cost_tracker = $cost_tracker;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate one article from an idea.
|
||||||
|
*
|
||||||
|
* Runs the full single-article pipeline: LLM content generation →
|
||||||
|
* editorial review → publish or save as draft.
|
||||||
|
*
|
||||||
|
* Side effects: LLM API calls, database writes, WordPress post creation,
|
||||||
|
* image generation, cost logging.
|
||||||
|
*
|
||||||
|
* @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 {
|
||||||
|
$llm = new PRAutoBlogger_OpenRouter_Provider();
|
||||||
|
$generator = new PRAutoBlogger_Content_Generator( $llm, $this->cost_tracker );
|
||||||
|
$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' ],
|
||||||
|
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 );
|
||||||
|
$result['generated'] = 1;
|
||||||
|
|
||||||
|
$this->broadcast_stage( __( 'Running editorial pass…', 'prautoblogger' ) );
|
||||||
|
$review = $editor->review( $content, $idea );
|
||||||
|
|
||||||
|
$this->broadcast_stage( __( 'Saving and publishing…', 'prautoblogger' ) );
|
||||||
|
$this->publish_or_draft(
|
||||||
|
$content, $idea, $review, $publisher, $auto_publish, $result
|
||||||
|
);
|
||||||
|
} catch ( \Throwable $e ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->error(
|
||||||
|
sprintf(
|
||||||
|
'Article generation %s for "%s": %s',
|
||||||
|
get_class( $e ),
|
||||||
|
$idea->get_topic(),
|
||||||
|
$e->getMessage()
|
||||||
|
),
|
||||||
|
'pipeline'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result['cost'] = $this->cost_tracker->get_current_run_cost();
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish approved content or save as draft.
|
||||||
|
*
|
||||||
|
* @param string $content Generated HTML content.
|
||||||
|
* @param PRAutoBlogger_Article_Idea $idea Source idea.
|
||||||
|
* @param PRAutoBlogger_Editorial_Review $review Editor verdict.
|
||||||
|
* @param PRAutoBlogger_Publisher $publisher Publisher instance.
|
||||||
|
* @param bool $auto_publish Whether to auto-publish.
|
||||||
|
* @param array &$result Result counters (by ref).
|
||||||
|
*/
|
||||||
|
private function publish_or_draft(
|
||||||
|
string $content,
|
||||||
|
PRAutoBlogger_Article_Idea $idea,
|
||||||
|
PRAutoBlogger_Editorial_Review $review,
|
||||||
|
PRAutoBlogger_Publisher $publisher,
|
||||||
|
bool $auto_publish,
|
||||||
|
array &$result
|
||||||
|
): void {
|
||||||
|
$verdict = $review->get_verdict();
|
||||||
|
$run_id = $this->cost_tracker->get_run_id();
|
||||||
|
|
||||||
|
if ( 'approved' === $verdict || 'revised' === $verdict ) {
|
||||||
|
$final = 'revised' === $verdict
|
||||||
|
? ( $review->get_revised_content() ?? $content )
|
||||||
|
: $content;
|
||||||
|
|
||||||
|
if ( $auto_publish ) {
|
||||||
|
$publisher->publish( $final, $idea, $review, $run_id );
|
||||||
|
$result['published']++;
|
||||||
|
} else {
|
||||||
|
$publisher->save_as_draft( $final, $idea, $review, $run_id );
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$publisher->save_as_draft( $content, $idea, $review, $run_id );
|
||||||
|
$result['rejected']++;
|
||||||
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
|
'Article rejected by editor: ' . $idea->get_topic(),
|
||||||
|
'pipeline'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the generation status transient with the current stage.
|
||||||
|
*
|
||||||
|
* @param string $stage Human-readable stage description.
|
||||||
|
*/
|
||||||
|
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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ declare(strict_types=1);
|
|||||||
* Dependencies: LLM_Provider_Interface, Cost_Tracker.
|
* Dependencies: LLM_Provider_Interface, Cost_Tracker.
|
||||||
*
|
*
|
||||||
* @see core/class-content-generator.php — Produces the content we review.
|
* @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 core/class-publisher.php — Publishes approved content.
|
||||||
* @see ARCHITECTURE.md — Data flow step 5.
|
* @see ARCHITECTURE.md — Data flow step 5.
|
||||||
*/
|
*/
|
||||||
@@ -108,6 +109,13 @@ class PRAutoBlogger_Chief_Editor {
|
|||||||
$prompt .= "- REVISE if fixable issues exist — you MUST provide the full revised HTML\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 .= "- 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, 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;
|
return $prompt;
|
||||||
}
|
}
|
||||||
@@ -145,10 +153,14 @@ class PRAutoBlogger_Chief_Editor {
|
|||||||
* @return PRAutoBlogger_Editorial_Review
|
* @return PRAutoBlogger_Editorial_Review
|
||||||
*/
|
*/
|
||||||
private function parse_review_response( string $content ): PRAutoBlogger_Editorial_Review {
|
private function parse_review_response( string $content ): PRAutoBlogger_Editorial_Review {
|
||||||
$data = json_decode( $content, true );
|
$data = PRAutoBlogger_Json_Extractor::decode( $content );
|
||||||
|
|
||||||
if ( ! is_array( $data ) || ! isset( $data['verdict'] ) ) {
|
if ( ! is_array( $data ) || ! isset( $data['verdict'] ) ) {
|
||||||
PRAutoBlogger_Logger::instance()->error( 'Chief editor response was not valid JSON. Defaulting to rejected.', 'editor' );
|
PRAutoBlogger_Logger::instance()->error(
|
||||||
|
'Chief editor response was not valid JSON. Defaulting to rejected. Raw (first 500 chars): '
|
||||||
|
. mb_substr( $content, 0, 500 ),
|
||||||
|
'editor'
|
||||||
|
);
|
||||||
return new PRAutoBlogger_Editorial_Review( [
|
return new PRAutoBlogger_Editorial_Review( [
|
||||||
'verdict' => 'rejected',
|
'verdict' => 'rejected',
|
||||||
'notes' => 'Editor response could not be parsed.',
|
'notes' => 'Editor response could not be parsed.',
|
||||||
|
|||||||
@@ -7,14 +7,17 @@ declare(strict_types=1);
|
|||||||
*
|
*
|
||||||
* Uses a cheap model (configurable) to keep analysis costs low.
|
* Uses a cheap model (configurable) to keep analysis costs low.
|
||||||
* Batches source data into manageable chunks for the LLM context window.
|
* Batches source data into manageable chunks for the LLM context window.
|
||||||
|
* Prompt construction is delegated to PRAutoBlogger_Analysis_Prompts.
|
||||||
*
|
*
|
||||||
* Triggered by: PRAutoBlogger::run_generation_pipeline() (step 2).
|
* Triggered by: PRAutoBlogger::run_generation_pipeline() (step 2).
|
||||||
* Dependencies: LLM_Provider_Interface, Cost_Tracker, WordPress $wpdb.
|
* Dependencies: LLM_Provider_Interface, Cost_Tracker, PRAutoBlogger_Analysis_Prompts, WordPress $wpdb.
|
||||||
*
|
*
|
||||||
* @see providers/interface-llm-provider.php — LLM used for analysis.
|
* @see core/class-analysis-prompts.php — System/user prompt builders + performance context.
|
||||||
* @see core/class-cost-tracker.php — Logs API costs.
|
* @see providers/interface-llm-provider.php — LLM used for analysis.
|
||||||
* @see core/class-idea-scorer.php — Consumes analysis results.
|
* @see core/class-cost-tracker.php — Logs API costs.
|
||||||
* @see ARCHITECTURE.md — Data flow step 2.
|
* @see core/class-json-extractor.php — Tolerant JSON parsing for LLM output.
|
||||||
|
* @see core/class-idea-scorer.php — Consumes analysis results.
|
||||||
|
* @see ARCHITECTURE.md — Data flow step 2.
|
||||||
*/
|
*/
|
||||||
class PRAutoBlogger_Content_Analyzer {
|
class PRAutoBlogger_Content_Analyzer {
|
||||||
|
|
||||||
@@ -37,11 +40,9 @@ class PRAutoBlogger_Content_Analyzer {
|
|||||||
*
|
*
|
||||||
* Side effects: LLM API call, database reads/writes.
|
* Side effects: LLM API call, database reads/writes.
|
||||||
*
|
*
|
||||||
* @return PRAutoBlogger_Analysis_Result[] Detected patterns.
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* @param int $target_idea_count How many distinct topic ideas the LLM should produce.
|
* @param int $target_idea_count How many distinct topic ideas the LLM should produce.
|
||||||
* Defaults to 6 so the scorer has enough to pick from.
|
* Defaults to 6 so the scorer has enough to pick from.
|
||||||
|
* @return PRAutoBlogger_Analysis_Result[] Detected patterns.
|
||||||
*/
|
*/
|
||||||
public function analyze_recent_data( int $target_idea_count = 6 ): array {
|
public function analyze_recent_data( int $target_idea_count = 6 ): array {
|
||||||
$source_items = $this->fetch_recent_source_data();
|
$source_items = $this->fetch_recent_source_data();
|
||||||
@@ -54,14 +55,10 @@ class PRAutoBlogger_Content_Analyzer {
|
|||||||
$niche = get_option( 'prautoblogger_niche_description', '' );
|
$niche = get_option( 'prautoblogger_niche_description', '' );
|
||||||
$model = get_option( 'prautoblogger_analysis_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL );
|
$model = get_option( 'prautoblogger_analysis_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL );
|
||||||
|
|
||||||
// Build the source data summary for the LLM (trim to avoid huge contexts).
|
$summary = $this->build_source_summary( $source_items );
|
||||||
$summary = $this->build_source_summary( $source_items );
|
$performance_context = PRAutoBlogger_Analysis_Prompts::get_performance_context();
|
||||||
|
$system_prompt = PRAutoBlogger_Analysis_Prompts::build_system_prompt( $niche, $performance_context, $target_idea_count );
|
||||||
// Include past performance data for self-improvement.
|
$user_prompt = PRAutoBlogger_Analysis_Prompts::build_user_prompt( $summary, $target_idea_count );
|
||||||
$performance_context = $this->get_performance_context();
|
|
||||||
|
|
||||||
$system_prompt = $this->build_system_prompt( $niche, $performance_context, $target_idea_count );
|
|
||||||
$user_prompt = $this->build_user_prompt( $summary, $target_idea_count );
|
|
||||||
|
|
||||||
$response = $this->llm->send_chat_completion(
|
$response = $this->llm->send_chat_completion(
|
||||||
[
|
[
|
||||||
@@ -76,7 +73,6 @@ class PRAutoBlogger_Content_Analyzer {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Log the cost.
|
|
||||||
$this->cost_tracker->log_api_call(
|
$this->cost_tracker->log_api_call(
|
||||||
null,
|
null,
|
||||||
'analysis',
|
'analysis',
|
||||||
@@ -86,15 +82,38 @@ class PRAutoBlogger_Content_Analyzer {
|
|||||||
$response['completion_tokens']
|
$response['completion_tokens']
|
||||||
);
|
);
|
||||||
|
|
||||||
// Parse LLM response into Analysis_Result objects.
|
|
||||||
$results = $this->parse_analysis_response( $response['content'], $source_items );
|
$results = $this->parse_analysis_response( $response['content'], $source_items );
|
||||||
|
|
||||||
// Store in database.
|
|
||||||
$this->store_results( $results );
|
$this->store_results( $results );
|
||||||
|
|
||||||
return $results;
|
return $results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Backward-compatible proxies (prompt building moved to Analysis_Prompts) ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use PRAutoBlogger_Analysis_Prompts::build_system_prompt() directly.
|
||||||
|
* @see core/class-analysis-prompts.php
|
||||||
|
*/
|
||||||
|
private function build_system_prompt( string $niche, string $performance_context, int $target_count = 6 ): string {
|
||||||
|
return PRAutoBlogger_Analysis_Prompts::build_system_prompt( $niche, $performance_context, $target_count );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use PRAutoBlogger_Analysis_Prompts::build_user_prompt() directly.
|
||||||
|
* @see core/class-analysis-prompts.php
|
||||||
|
*/
|
||||||
|
private function build_user_prompt( string $summary, int $target_count = 6 ): string {
|
||||||
|
return PRAutoBlogger_Analysis_Prompts::build_user_prompt( $summary, $target_count );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use PRAutoBlogger_Analysis_Prompts::get_performance_context() directly.
|
||||||
|
* @see core/class-analysis-prompts.php
|
||||||
|
*/
|
||||||
|
private function get_performance_context(): string {
|
||||||
|
return PRAutoBlogger_Analysis_Prompts::get_performance_context();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch source data collected in the last 24 hours.
|
* Fetch source data collected in the last 24 hours.
|
||||||
*
|
*
|
||||||
@@ -120,7 +139,6 @@ class PRAutoBlogger_Content_Analyzer {
|
|||||||
* Prioritizes high-engagement content and keeps total text under ~8000 tokens.
|
* Prioritizes high-engagement content and keeps total text under ~8000 tokens.
|
||||||
*
|
*
|
||||||
* @param array<int, array<string, mixed>> $items Source data rows.
|
* @param array<int, array<string, mixed>> $items Source data rows.
|
||||||
*
|
|
||||||
* @return string Formatted text summary.
|
* @return string Formatted text summary.
|
||||||
*/
|
*/
|
||||||
private function build_source_summary( array $items ): string {
|
private function build_source_summary( array $items ): string {
|
||||||
@@ -129,12 +147,14 @@ class PRAutoBlogger_Content_Analyzer {
|
|||||||
$current_chars = 0;
|
$current_chars = 0;
|
||||||
|
|
||||||
foreach ( $items as $item ) {
|
foreach ( $items as $item ) {
|
||||||
|
// LLM research findings have no subreddit; use a cleaner label.
|
||||||
|
$source_label = 'llm_research' === $item['source_type']
|
||||||
|
? sprintf( '[LLM Research] Relevance: %d', (int) $item['score'] )
|
||||||
|
: sprintf( '[%s] r/%s | Score: %d | Comments: %d', $item['source_type'], $item['subreddit'] ?? 'unknown', (int) $item['score'], (int) $item['comment_count'] );
|
||||||
|
|
||||||
$entry = sprintf(
|
$entry = sprintf(
|
||||||
"[%s] r/%s | Score: %d | Comments: %d\nTitle: %s\n%s\n---",
|
"%s\nTitle: %s\n%s\n---",
|
||||||
$item['source_type'],
|
$source_label,
|
||||||
$item['subreddit'] ?? 'unknown',
|
|
||||||
(int) $item['score'],
|
|
||||||
(int) $item['comment_count'],
|
|
||||||
$item['title'] ?? '(comment)',
|
$item['title'] ?? '(comment)',
|
||||||
mb_substr( $item['content'] ?? '', 0, 500 )
|
mb_substr( $item['content'] ?? '', 0, 500 )
|
||||||
);
|
);
|
||||||
@@ -150,115 +170,21 @@ class PRAutoBlogger_Content_Analyzer {
|
|||||||
return implode( "\n", $parts );
|
return implode( "\n", $parts );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get past content performance data to feed into analysis for self-improvement.
|
|
||||||
*
|
|
||||||
* @return string Summary of what topics performed well/poorly.
|
|
||||||
*/
|
|
||||||
private function get_performance_context(): string {
|
|
||||||
global $wpdb;
|
|
||||||
$scores_table = $wpdb->prefix . 'prautoblogger_content_scores';
|
|
||||||
|
|
||||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
|
||||||
$top_posts = $wpdb->get_results(
|
|
||||||
"SELECT cs.post_id, cs.composite_score, p.post_title
|
|
||||||
FROM {$scores_table} cs
|
|
||||||
JOIN {$wpdb->posts} p ON p.ID = cs.post_id
|
|
||||||
WHERE cs.composite_score > 0
|
|
||||||
ORDER BY cs.composite_score DESC
|
|
||||||
LIMIT 5",
|
|
||||||
ARRAY_A
|
|
||||||
);
|
|
||||||
|
|
||||||
if ( empty( $top_posts ) ) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$lines = [ 'Top performing past articles (learn from these):' ];
|
|
||||||
foreach ( $top_posts as $post ) {
|
|
||||||
$lines[] = sprintf(
|
|
||||||
'- "%s" (score: %.1f)',
|
|
||||||
$post['post_title'],
|
|
||||||
(float) $post['composite_score']
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return implode( "\n", $lines );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the system prompt for content analysis.
|
|
||||||
*
|
|
||||||
* @param string $niche Niche description from settings.
|
|
||||||
* @param string $performance_context Past performance data.
|
|
||||||
*
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
private function build_system_prompt( string $niche, string $performance_context, int $target_count = 6 ): string {
|
|
||||||
$prompt = "You are a content strategist analyzing social media discussions to find article ideas for a blog";
|
|
||||||
if ( '' !== $niche ) {
|
|
||||||
$prompt .= " in the {$niche} niche";
|
|
||||||
}
|
|
||||||
$prompt .= ".\n\n";
|
|
||||||
|
|
||||||
$prompt .= "IMPORTANT: You must identify exactly {$target_count} DISTINCT article ideas. ";
|
|
||||||
$prompt .= "Each idea must cover a substantially different topic — no two ideas should overlap ";
|
|
||||||
$prompt .= "in their main subject. Aim for diversity across these categories:\n";
|
|
||||||
$prompt .= "1. QUESTIONS: Recurring questions people ask (\"How do I...\", \"What is...\", \"Is it safe to...\")\n";
|
|
||||||
$prompt .= "2. COMPLAINTS: Pain points, frustrations, or problems people report\n";
|
|
||||||
$prompt .= "3. COMPARISONS: Product/method comparisons people discuss (\"X vs Y\", \"Which is better\")\n";
|
|
||||||
$prompt .= "4. NEWS: Recent developments, rule changes, or trends people are discussing\n";
|
|
||||||
$prompt .= "5. GUIDES: How-to topics, best practices, or educational content people need\n\n";
|
|
||||||
|
|
||||||
$prompt .= "Spread your {$target_count} ideas across multiple categories. ";
|
|
||||||
$prompt .= "Be specific — \"peptide dosing for BPC-157\" is better than \"peptide information\". ";
|
|
||||||
$prompt .= "Each idea should be narrow enough to be a single focused blog post.\n\n";
|
|
||||||
|
|
||||||
$prompt .= "For each idea, provide:\n";
|
|
||||||
$prompt .= "- type: 'question', 'complaint', 'comparison', 'news', or 'guide'\n";
|
|
||||||
$prompt .= "- topic: A clear, specific, narrow topic description\n";
|
|
||||||
$prompt .= "- summary: 1-2 sentence summary of why this topic matters\n";
|
|
||||||
$prompt .= "- frequency: How many of the provided posts relate to this topic\n";
|
|
||||||
$prompt .= "- relevance_score: 0.0 to 1.0 indicating how relevant and article-worthy this is\n";
|
|
||||||
$prompt .= "- suggested_title: A compelling, unique blog post title\n";
|
|
||||||
$prompt .= "- key_points: Array of 3-5 key points the article should cover\n";
|
|
||||||
$prompt .= "- target_keywords: Array of SEO keywords for this topic\n\n";
|
|
||||||
|
|
||||||
if ( '' !== $performance_context ) {
|
|
||||||
$prompt .= $performance_context . "\n\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
$prompt .= "Respond with valid JSON containing exactly {$target_count} patterns:\n";
|
|
||||||
$prompt .= '{"patterns": [{"type": "...", "topic": "...", "summary": "...", "frequency": N, "relevance_score": 0.X, "suggested_title": "...", "key_points": [...], "target_keywords": [...]}]}';
|
|
||||||
|
|
||||||
return $prompt;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the user prompt containing the actual source data.
|
|
||||||
*
|
|
||||||
* @param string $summary Formatted source data summary.
|
|
||||||
*
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
private function build_user_prompt( string $summary, int $target_count = 6 ): string {
|
|
||||||
return "Here are the recent social media posts and comments to analyze. "
|
|
||||||
. "Find {$target_count} distinct, diverse article ideas from this data:\n\n"
|
|
||||||
. $summary;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse the LLM's JSON response into Analysis_Result objects.
|
* Parse the LLM's JSON response into Analysis_Result objects.
|
||||||
*
|
*
|
||||||
* @param string $content LLM response content (JSON).
|
* @param string $content LLM response content (JSON).
|
||||||
* @param array<int, array<string, mixed>> $source_items Original source data for ID mapping.
|
* @param array<int, array<string, mixed>> $source_items Original source data for ID mapping.
|
||||||
*
|
|
||||||
* @return PRAutoBlogger_Analysis_Result[]
|
* @return PRAutoBlogger_Analysis_Result[]
|
||||||
*/
|
*/
|
||||||
private function parse_analysis_response( string $content, array $source_items ): array {
|
private function parse_analysis_response( string $content, array $source_items ): array {
|
||||||
$data = json_decode( $content, true );
|
$data = PRAutoBlogger_Json_Extractor::decode( $content );
|
||||||
if ( ! isset( $data['patterns'] ) || ! is_array( $data['patterns'] ) ) {
|
if ( ! isset( $data['patterns'] ) || ! is_array( $data['patterns'] ) ) {
|
||||||
PRAutoBlogger_Logger::instance()->error( 'Analysis response was not valid JSON or missing patterns key.', 'analyzer' );
|
PRAutoBlogger_Logger::instance()->error(
|
||||||
|
'Analysis response was not valid JSON or missing patterns key. Raw (first 500 chars): '
|
||||||
|
. mb_substr( $content, 0, 500 ),
|
||||||
|
'analyzer'
|
||||||
|
);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,8 +217,6 @@ class PRAutoBlogger_Content_Analyzer {
|
|||||||
* Side effects: database inserts.
|
* Side effects: database inserts.
|
||||||
*
|
*
|
||||||
* @param PRAutoBlogger_Analysis_Result[] $results
|
* @param PRAutoBlogger_Analysis_Result[] $results
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
private function store_results( array $results ): void {
|
private function store_results( array $results ): void {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ declare(strict_types=1);
|
|||||||
* - multi_step: Outline → Draft → Polish pipeline (higher quality, more cost).
|
* - multi_step: Outline → Draft → Polish pipeline (higher quality, more cost).
|
||||||
*
|
*
|
||||||
* Triggered by: PRAutoBlogger::run_generation_pipeline() (step 4).
|
* Triggered by: PRAutoBlogger::run_generation_pipeline() (step 4).
|
||||||
* Dependencies: LLM_Provider_Interface, Cost_Tracker.
|
* Dependencies: LLM_Provider_Interface, Cost_Tracker, Content_Prompts.
|
||||||
*
|
*
|
||||||
* @see core/class-chief-editor.php — Reviews output of this class.
|
* @see 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 providers/interface-llm-provider.php — LLM used for generation.
|
||||||
* @see ARCHITECTURE.md — Data flow step 4.
|
* @see ARCHITECTURE.md — Data flow step 4.
|
||||||
*/
|
*/
|
||||||
class PRAutoBlogger_Content_Generator {
|
class PRAutoBlogger_Content_Generator {
|
||||||
|
|
||||||
@@ -32,13 +33,10 @@ class PRAutoBlogger_Content_Generator {
|
|||||||
* Generate a blog post from an article idea.
|
* Generate a blog post from an article idea.
|
||||||
*
|
*
|
||||||
* Reads pipeline mode from settings and dispatches to the appropriate method.
|
* Reads pipeline mode from settings and dispatches to the appropriate method.
|
||||||
*
|
|
||||||
* Side effects: LLM API calls (1-3 depending on mode), cost logging.
|
* 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.
|
||||||
*
|
|
||||||
* @return string Generated HTML content.
|
* @return string Generated HTML content.
|
||||||
*
|
|
||||||
* @throws \RuntimeException On generation failure.
|
* @throws \RuntimeException On generation failure.
|
||||||
*/
|
*/
|
||||||
public function generate( PRAutoBlogger_Article_Idea $idea ): string {
|
public function generate( PRAutoBlogger_Article_Idea $idea ): string {
|
||||||
@@ -51,7 +49,8 @@ class PRAutoBlogger_Content_Generator {
|
|||||||
absint( get_option( 'prautoblogger_min_word_count', 800 ) ),
|
absint( get_option( 'prautoblogger_min_word_count', 800 ) ),
|
||||||
absint( get_option( 'prautoblogger_max_word_count', 2000 ) ),
|
absint( get_option( 'prautoblogger_max_word_count', 2000 ) ),
|
||||||
get_option( 'prautoblogger_niche_description', '' ),
|
get_option( 'prautoblogger_niche_description', '' ),
|
||||||
json_decode( get_option( 'prautoblogger_topic_exclusions', '[]' ), true ) ?: []
|
json_decode( get_option( 'prautoblogger_topic_exclusions', '[]' ), true ) ?: [],
|
||||||
|
get_option( 'prautoblogger_writing_instructions', '' )
|
||||||
);
|
);
|
||||||
|
|
||||||
if ( 'single_pass' === $mode ) {
|
if ( 'single_pass' === $mode ) {
|
||||||
@@ -64,139 +63,16 @@ class PRAutoBlogger_Content_Generator {
|
|||||||
/**
|
/**
|
||||||
* Single-pass generation: one LLM call produces the complete article.
|
* Single-pass generation: one LLM call produces the complete article.
|
||||||
*
|
*
|
||||||
* @param PRAutoBlogger_Content_Request $request
|
* @param PRAutoBlogger_Content_Request $request Content request with settings.
|
||||||
*
|
|
||||||
* @return string Generated HTML content.
|
* @return string Generated HTML content.
|
||||||
*/
|
*/
|
||||||
private function generate_single_pass( PRAutoBlogger_Content_Request $request ): string {
|
private function generate_single_pass( PRAutoBlogger_Content_Request $request ): string {
|
||||||
$model = get_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
|
|
||||||
$idea = $request->get_idea();
|
|
||||||
|
|
||||||
$system = $this->build_writer_system_prompt( $request );
|
|
||||||
$user = $this->build_single_pass_prompt( $request );
|
|
||||||
|
|
||||||
$response = $this->llm->send_chat_completion(
|
|
||||||
[
|
|
||||||
[ 'role' => 'system', 'content' => $system ],
|
|
||||||
[ 'role' => 'user', 'content' => $user ],
|
|
||||||
],
|
|
||||||
$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 generation: Outline → Draft → Polish.
|
|
||||||
*
|
|
||||||
* @param PRAutoBlogger_Content_Request $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 );
|
$model = get_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
|
||||||
|
|
||||||
// Step 1: Generate outline.
|
|
||||||
$outline = $this->stage_outline( $request, $model );
|
|
||||||
|
|
||||||
// Step 2: Generate draft from outline.
|
|
||||||
$draft = $this->stage_draft( $request, $outline, $model );
|
|
||||||
|
|
||||||
// Step 3: Polish the draft.
|
|
||||||
$polished = $this->stage_polish( $request, $draft, $model );
|
|
||||||
|
|
||||||
return $polished;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Multi-step stage 1: Generate an article outline.
|
|
||||||
*
|
|
||||||
* @param PRAutoBlogger_Content_Request $request
|
|
||||||
* @param string $model
|
|
||||||
*
|
|
||||||
* @return string The outline text.
|
|
||||||
*/
|
|
||||||
private function stage_outline( PRAutoBlogger_Content_Request $request, string $model ): string {
|
|
||||||
$idea = $request->get_idea();
|
|
||||||
|
|
||||||
$prompt = 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.",
|
|
||||||
$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()
|
|
||||||
);
|
|
||||||
|
|
||||||
$response = $this->llm->send_chat_completion(
|
$response = $this->llm->send_chat_completion(
|
||||||
[
|
[
|
||||||
[ 'role' => 'system', 'content' => $this->build_writer_system_prompt( $request ) ],
|
[ 'role' => 'system', 'content' => PRAutoBlogger_Content_Prompts::build_system( $request ) ],
|
||||||
[ 'role' => 'user', 'content' => $prompt ],
|
[ 'role' => 'user', 'content' => PRAutoBlogger_Content_Prompts::build_single_pass( $request ) ],
|
||||||
],
|
|
||||||
$model,
|
|
||||||
[ 'temperature' => 0.5, 'max_tokens' => 1500 ]
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->cost_tracker->log_api_call(
|
|
||||||
null, 'outline', $this->llm->get_provider_name(),
|
|
||||||
$response['model'], $response['prompt_tokens'], $response['completion_tokens']
|
|
||||||
);
|
|
||||||
|
|
||||||
return $response['content'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Multi-step stage 2: Write the full draft from the outline.
|
|
||||||
*
|
|
||||||
* @param PRAutoBlogger_Content_Request $request
|
|
||||||
* @param string $outline
|
|
||||||
* @param string $model
|
|
||||||
*
|
|
||||||
* @return string Draft HTML content.
|
|
||||||
*/
|
|
||||||
private function stage_draft( PRAutoBlogger_Content_Request $request, string $outline, string $model ): string {
|
|
||||||
$prompt = 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",
|
|
||||||
$outline,
|
|
||||||
$request->get_tone(),
|
|
||||||
$request->get_min_word_count(),
|
|
||||||
$request->get_max_word_count(),
|
|
||||||
implode( ', ', $request->get_idea()->get_target_keywords() )
|
|
||||||
);
|
|
||||||
|
|
||||||
$response = $this->llm->send_chat_completion(
|
|
||||||
[
|
|
||||||
[ 'role' => 'system', 'content' => $this->build_writer_system_prompt( $request ) ],
|
|
||||||
[ 'role' => 'user', 'content' => $prompt ],
|
|
||||||
],
|
],
|
||||||
$model,
|
$model,
|
||||||
[ 'temperature' => 0.7, 'max_tokens' => 4000 ]
|
[ 'temperature' => 0.7, 'max_tokens' => 4000 ]
|
||||||
@@ -210,29 +86,67 @@ class PRAutoBlogger_Content_Generator {
|
|||||||
return $response['content'];
|
return $response['content'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Multi-step generation: Outline → Draft → Polish. */
|
||||||
* Multi-step stage 3: Polish and refine the draft.
|
private function generate_multi_step( PRAutoBlogger_Content_Request $request ): string {
|
||||||
*
|
$model = get_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
|
||||||
* @param PRAutoBlogger_Content_Request $request
|
|
||||||
* @param string $draft
|
$outline = $this->stage_outline( $request, $model );
|
||||||
* @param string $model
|
$draft = $this->stage_draft( $request, $outline, $model );
|
||||||
*
|
$polished = $this->stage_polish( $request, $draft, $model );
|
||||||
* @return string Polished HTML content.
|
|
||||||
*/
|
return $polished;
|
||||||
private function stage_polish( PRAutoBlogger_Content_Request $request, string $draft, string $model ): string {
|
}
|
||||||
$prompt = "Review and polish this blog post draft. Improve:\n" .
|
|
||||||
"1. Flow and readability\n" .
|
/** Multi-step stage 1: Generate an article outline. */
|
||||||
"2. SEO optimization (headings, keyword placement)\n" .
|
private function stage_outline( PRAutoBlogger_Content_Request $request, string $model ): string {
|
||||||
"3. Engagement (hooks, transitions, call-to-action)\n" .
|
$system = PRAutoBlogger_Content_Prompts::build_system( $request );
|
||||||
"4. Accuracy and clarity\n" .
|
|
||||||
"5. Remove any filler or redundant sentences\n\n" .
|
|
||||||
"Return the polished HTML content only. Do not add commentary.\n\n" .
|
|
||||||
"DRAFT:\n" . $draft;
|
|
||||||
|
|
||||||
$response = $this->llm->send_chat_completion(
|
$response = $this->llm->send_chat_completion(
|
||||||
[
|
[
|
||||||
[ 'role' => 'system', 'content' => $this->build_writer_system_prompt( $request ) ],
|
[ 'role' => 'system', 'content' => $system ],
|
||||||
[ 'role' => 'user', 'content' => $prompt ],
|
[ 'role' => 'user', 'content' => PRAutoBlogger_Content_Prompts::build_outline( $request ) ],
|
||||||
|
],
|
||||||
|
$model,
|
||||||
|
[ 'temperature' => 0.5, 'max_tokens' => 1500 ]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->cost_tracker->log_api_call(
|
||||||
|
null, 'outline', $this->llm->get_provider_name(),
|
||||||
|
$response['model'], $response['prompt_tokens'], $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,
|
$model,
|
||||||
[ 'temperature' => 0.4, 'max_tokens' => 4000 ]
|
[ 'temperature' => 0.4, 'max_tokens' => 4000 ]
|
||||||
@@ -245,53 +159,4 @@ class PRAutoBlogger_Content_Generator {
|
|||||||
|
|
||||||
return $response['content'];
|
return $response['content'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the system prompt shared across all writing stages.
|
|
||||||
*
|
|
||||||
* @param PRAutoBlogger_Content_Request $request
|
|
||||||
*
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
private function build_writer_system_prompt( PRAutoBlogger_Content_Request $request ): string {
|
|
||||||
$niche = $request->get_niche_description();
|
|
||||||
$prompt = "You are an expert blog writer";
|
|
||||||
if ( '' !== $niche ) {
|
|
||||||
$prompt .= " specializing in {$niche}";
|
|
||||||
}
|
|
||||||
$prompt .= ". Write well-researched, engaging, SEO-friendly content. ";
|
|
||||||
$prompt .= "Use a {$request->get_tone()} tone. ";
|
|
||||||
$prompt .= "Output HTML content only — no markdown, no code fences, no commentary.";
|
|
||||||
return $prompt;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the user prompt for single-pass generation.
|
|
||||||
*
|
|
||||||
* @param PRAutoBlogger_Content_Request $request
|
|
||||||
*
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
private function build_single_pass_prompt( PRAutoBlogger_Content_Request $request ): string {
|
|
||||||
$idea = $request->get_idea();
|
|
||||||
|
|
||||||
return sprintf(
|
|
||||||
"Write a complete blog post in HTML format.\n\n" .
|
|
||||||
"Title: %s\nTopic: %s\nType: %s\n\nKey points:\n- %s\n\n" .
|
|
||||||
"Keywords: %s\n\n" .
|
|
||||||
"Requirements:\n" .
|
|
||||||
"- %d-%d words\n" .
|
|
||||||
"- Proper HTML (h2, h3, p, ul/li)\n" .
|
|
||||||
"- Engaging intro, strong conclusion with CTA\n" .
|
|
||||||
"- Do NOT include the title or <html>/<body> tags\n" .
|
|
||||||
"- Output HTML only, no markdown or commentary",
|
|
||||||
$idea->get_suggested_title(),
|
|
||||||
$idea->get_topic(),
|
|
||||||
$idea->get_article_type(),
|
|
||||||
implode( "\n- ", $idea->get_key_points() ),
|
|
||||||
implode( ', ', $idea->get_target_keywords() ),
|
|
||||||
$request->get_min_word_count(),
|
|
||||||
$request->get_max_word_count()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
211
includes/core/class-content-prompts.php
Normal file
211
includes/core/class-content-prompts.php
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds all LLM prompts used by the content generation pipeline.
|
||||||
|
*
|
||||||
|
* Centralises prompt construction so Content_Generator stays lean and
|
||||||
|
* prompt logic (system prompt, stage prompts, linking rules) can evolve
|
||||||
|
* independently of the generation orchestration.
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger_Content_Generator (called for every stage).
|
||||||
|
* Dependencies: WordPress query functions (get_posts, get_the_title, get_permalink).
|
||||||
|
*
|
||||||
|
* @see core/class-content-generator.php — Consumer of these prompts.
|
||||||
|
* @see models/class-content-request.php — Data bag passed to every builder.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Content_Prompts {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the system prompt shared across all writing stages.
|
||||||
|
*
|
||||||
|
* Includes niche, tone, mandatory style guide, internal link reference,
|
||||||
|
* peptide database links, and the "never fabricate URLs" rule.
|
||||||
|
*
|
||||||
|
* @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.';
|
||||||
|
|
||||||
|
// Append user-defined writing instructions as a mandatory style guide.
|
||||||
|
$instructions = trim( $request->get_writing_instructions() );
|
||||||
|
if ( '' !== $instructions ) {
|
||||||
|
$prompt .= "\n\n--- MANDATORY STYLE GUIDE ---\n";
|
||||||
|
$prompt .= "You MUST follow every requirement below. These override any conflicting defaults:\n\n";
|
||||||
|
$prompt .= $instructions;
|
||||||
|
$prompt .= "\n--- END STYLE GUIDE ---";
|
||||||
|
}
|
||||||
|
|
||||||
|
$prompt .= "\n\n" . self::build_linking_rules();
|
||||||
|
|
||||||
|
return $prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the user prompt for single-pass generation.
|
||||||
|
*
|
||||||
|
* @param PRAutoBlogger_Content_Request $request Content request with settings.
|
||||||
|
* @return string User prompt.
|
||||||
|
*/
|
||||||
|
public static function build_single_pass( PRAutoBlogger_Content_Request $request ): string {
|
||||||
|
$idea = $request->get_idea();
|
||||||
|
|
||||||
|
return 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the outline stage prompt (multi-step stage 1).
|
||||||
|
*
|
||||||
|
* @param PRAutoBlogger_Content_Request $request Content request with settings.
|
||||||
|
* @return string Outline prompt.
|
||||||
|
*/
|
||||||
|
public static function build_outline( PRAutoBlogger_Content_Request $request ): string {
|
||||||
|
$idea = $request->get_idea();
|
||||||
|
|
||||||
|
return 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the draft stage prompt (multi-step stage 2).
|
||||||
|
*
|
||||||
|
* @param PRAutoBlogger_Content_Request $request Content request with settings.
|
||||||
|
* @param string $outline The outline from stage 1.
|
||||||
|
* @return string Draft prompt.
|
||||||
|
*/
|
||||||
|
public static function build_draft( PRAutoBlogger_Content_Request $request, string $outline ): string {
|
||||||
|
return 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() )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the polish stage prompt (multi-step stage 3).
|
||||||
|
*
|
||||||
|
* @param string $draft The draft from stage 2.
|
||||||
|
* @return string Polish prompt.
|
||||||
|
*/
|
||||||
|
public static function build_polish( string $draft ): string {
|
||||||
|
return "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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the linking rules section (internal articles + peptide database).
|
||||||
|
*
|
||||||
|
* Fetches published blog posts and peptide pages, formats them as a
|
||||||
|
* reference list so the model uses real URLs instead of fabricating them.
|
||||||
|
*
|
||||||
|
* @return string Complete linking rules block.
|
||||||
|
*/
|
||||||
|
private static function build_linking_rules(): string {
|
||||||
|
$rules = "--- LINKING RULES ---\n";
|
||||||
|
$rules .= "NEVER fabricate or invent URLs. You do NOT have access to external sources.\n";
|
||||||
|
$rules .= "Only use the internal links listed below when linking within the article.\n";
|
||||||
|
$rules .= "If no listed article is relevant to a section, do not insert a link.\n";
|
||||||
|
$rules .= "Do NOT add links for peptide names — those are injected automatically after generation.\n\n";
|
||||||
|
|
||||||
|
$rules .= self::build_article_links();
|
||||||
|
|
||||||
|
$rules .= "--- END LINKING RULES ---";
|
||||||
|
|
||||||
|
return $rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a reference list of published blog posts for internal linking.
|
||||||
|
*
|
||||||
|
* @return string Formatted list, or a fallback note.
|
||||||
|
*/
|
||||||
|
private static function build_article_links(): string {
|
||||||
|
$posts = get_posts( [
|
||||||
|
'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" ];
|
||||||
|
foreach ( $posts as $post_id ) {
|
||||||
|
$title = get_the_title( $post_id );
|
||||||
|
$url = get_permalink( $post_id );
|
||||||
|
if ( $title && $url ) {
|
||||||
|
$lines[] = "- {$title}: {$url}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode( "\n", $lines ) . "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -4,25 +4,25 @@ declare(strict_types=1);
|
|||||||
/**
|
/**
|
||||||
* Ranks and deduplicates article ideas from analysis results.
|
* Ranks and deduplicates article ideas from analysis results.
|
||||||
*
|
*
|
||||||
* Compares against existing published posts to avoid topic overlap,
|
* Uses PRAutoBlogger_Semantic_Dedup for similarity detection — embedding-based
|
||||||
* scores ideas by relevance, frequency, and freshness, then returns
|
* cosine similarity with automatic fallback to keyword overlap if the embedding
|
||||||
* the top N ideas matching the daily target.
|
* API is unavailable.
|
||||||
*
|
*
|
||||||
* Triggered by: PRAutoBlogger::run_generation_pipeline() (step 3).
|
* Triggered by: PRAutoBlogger_Pipeline_Runner (step 3).
|
||||||
* Dependencies: WordPress $wpdb, WP_Query.
|
* Dependencies: Semantic_Dedup, WP_Query.
|
||||||
*
|
*
|
||||||
|
* @see core/class-semantic-dedup.php — Embedding + keyword dedup engine.
|
||||||
* @see core/class-content-analyzer.php — Produces the analysis results we consume.
|
* @see core/class-content-analyzer.php — Produces the analysis results we consume.
|
||||||
* @see core/class-content-generator.php — Consumes the ranked ideas we produce.
|
* @see core/class-content-generator.php — Consumes the ranked ideas we produce.
|
||||||
* @see ARCHITECTURE.md — Data flow step 3.
|
* @see ARCHITECTURE.md — Data flow step 3.
|
||||||
*/
|
*/
|
||||||
class PRAutoBlogger_Idea_Scorer {
|
class PRAutoBlogger_Idea_Scorer {
|
||||||
|
|
||||||
/** @var bool Whether to skip the has_similar_post() check. */
|
/** @var bool Whether to skip deduplication entirely. */
|
||||||
private bool $skip_dedup = false;
|
private bool $skip_dedup = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param bool $skip True to skip deduplication.
|
* @param bool $skip True to skip deduplication.
|
||||||
*
|
|
||||||
* @return $this
|
* @return $this
|
||||||
*/
|
*/
|
||||||
public function set_skip_dedup( bool $skip ): self {
|
public function set_skip_dedup( bool $skip ): self {
|
||||||
@@ -33,29 +33,60 @@ class PRAutoBlogger_Idea_Scorer {
|
|||||||
/**
|
/**
|
||||||
* Score, deduplicate, and rank article ideas.
|
* Score, deduplicate, and rank article ideas.
|
||||||
*
|
*
|
||||||
* @param PRAutoBlogger_Analysis_Result[] $analysis_results Raw analysis output.
|
* Initializes the semantic dedup engine once, then checks each candidate
|
||||||
* @param int $target_count Number of ideas to return.
|
* against (1) intra-batch duplicates and (2) recently published posts.
|
||||||
*
|
*
|
||||||
* @return PRAutoBlogger_Article_Idea[] Top-ranked ideas, sorted by score descending.
|
* Side effects: embedding API calls (via Semantic_Dedup), WP_Query.
|
||||||
|
*
|
||||||
|
* @param PRAutoBlogger_Analysis_Result[] $analysis_results Raw analysis output.
|
||||||
|
* @param int $target_count Max ideas to return.
|
||||||
|
*
|
||||||
|
* @return PRAutoBlogger_Article_Idea[] Top-ranked ideas, sorted by score desc.
|
||||||
*/
|
*/
|
||||||
public function score_and_rank( array $analysis_results, int $target_count ): array {
|
public function score_and_rank( array $analysis_results, int $target_count ): array {
|
||||||
$exclusions = json_decode( get_option( 'prautoblogger_topic_exclusions', '[]' ), true ) ?: [];
|
$exclusions = json_decode( get_option( 'prautoblogger_topic_exclusions', '[]' ), true ) ?: [];
|
||||||
$ideas = [];
|
$ideas = [];
|
||||||
|
$excluded_count = 0;
|
||||||
|
$deduped_count = 0;
|
||||||
|
|
||||||
|
$dedup = new PRAutoBlogger_Semantic_Dedup();
|
||||||
|
if ( ! $this->skip_dedup ) {
|
||||||
|
$dedup->initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyword sets kept for fallback (passed through to Semantic_Dedup).
|
||||||
|
$accepted_keyword_sets = [];
|
||||||
|
|
||||||
foreach ( $analysis_results as $result ) {
|
foreach ( $analysis_results as $result ) {
|
||||||
// Skip excluded topics.
|
|
||||||
if ( $this->is_excluded( $result->get_topic(), $exclusions ) ) {
|
if ( $this->is_excluded( $result->get_topic(), $exclusions ) ) {
|
||||||
|
$excluded_count++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if we've already published something very similar
|
if ( ! $this->skip_dedup ) {
|
||||||
// (unless dedup is disabled for manual runs).
|
$topic = $result->get_topic();
|
||||||
if ( ! $this->skip_dedup && $this->has_similar_post( $result->get_topic() ) ) {
|
$topic_keywords = $this->extract_meaningful_keywords( $topic );
|
||||||
PRAutoBlogger_Logger::instance()->info(
|
|
||||||
sprintf( 'Skipping topic "%s" — similar post exists.', substr( $result->get_topic(), 0, 80 ) ),
|
if ( $dedup->is_batch_duplicate( $topic, $topic_keywords, $accepted_keyword_sets ) ) {
|
||||||
'scorer'
|
$deduped_count++;
|
||||||
);
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
continue;
|
sprintf( 'Dedup(batch): skipping "%s"', substr( $topic, 0, 80 ) ),
|
||||||
|
'scorer'
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( $dedup->is_recent_duplicate( $topic, $topic_keywords ) ) {
|
||||||
|
$deduped_count++;
|
||||||
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
|
sprintf( 'Dedup(recent): skipping "%s"', substr( $topic, 0, 80 ) ),
|
||||||
|
'scorer'
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$accepted_keyword_sets[] = $topic_keywords;
|
||||||
|
$dedup->record_accepted( $topic );
|
||||||
}
|
}
|
||||||
|
|
||||||
$metadata = $result->get_metadata() ?? [];
|
$metadata = $result->get_metadata() ?? [];
|
||||||
@@ -73,7 +104,18 @@ class PRAutoBlogger_Idea_Scorer {
|
|||||||
] );
|
] );
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by score descending.
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
|
sprintf(
|
||||||
|
'Scorer: %d input → %d viable, %d deduped, %d excluded. Returning top %d.',
|
||||||
|
count( $analysis_results ),
|
||||||
|
count( $ideas ),
|
||||||
|
$deduped_count,
|
||||||
|
$excluded_count,
|
||||||
|
min( $target_count, count( $ideas ) )
|
||||||
|
),
|
||||||
|
'scorer'
|
||||||
|
);
|
||||||
|
|
||||||
usort( $ideas, static function ( PRAutoBlogger_Article_Idea $a, PRAutoBlogger_Article_Idea $b ): int {
|
usort( $ideas, static function ( PRAutoBlogger_Article_Idea $a, PRAutoBlogger_Article_Idea $b ): int {
|
||||||
return $b->get_score() <=> $a->get_score();
|
return $b->get_score() <=> $a->get_score();
|
||||||
} );
|
} );
|
||||||
@@ -87,14 +129,12 @@ class PRAutoBlogger_Idea_Scorer {
|
|||||||
* Factors: LLM relevance score (50%), frequency (30%), recency bonus (20%).
|
* Factors: LLM relevance score (50%), frequency (30%), recency bonus (20%).
|
||||||
*
|
*
|
||||||
* @param PRAutoBlogger_Analysis_Result $result
|
* @param PRAutoBlogger_Analysis_Result $result
|
||||||
*
|
|
||||||
* @return float Score between 0 and 1.
|
* @return float Score between 0 and 1.
|
||||||
*/
|
*/
|
||||||
private function compute_score( PRAutoBlogger_Analysis_Result $result ): float {
|
private function compute_score( PRAutoBlogger_Analysis_Result $result ): float {
|
||||||
$relevance = min( $result->get_relevance_score(), 1.0 );
|
$relevance = min( $result->get_relevance_score(), 1.0 );
|
||||||
$frequency = min( $result->get_frequency() / 10.0, 1.0 );
|
$frequency = min( $result->get_frequency() / 10.0, 1.0 );
|
||||||
|
|
||||||
// Recency: analyzed within last 24h gets full bonus.
|
|
||||||
$hours_ago = ( time() - strtotime( $result->get_analyzed_at() ) ) / 3600.0;
|
$hours_ago = ( time() - strtotime( $result->get_analyzed_at() ) ) / 3600.0;
|
||||||
$recency = max( 0.0, 1.0 - ( $hours_ago / 48.0 ) );
|
$recency = max( 0.0, 1.0 - ( $hours_ago / 48.0 ) );
|
||||||
|
|
||||||
@@ -106,7 +146,6 @@ class PRAutoBlogger_Idea_Scorer {
|
|||||||
*
|
*
|
||||||
* @param string $topic Topic text.
|
* @param string $topic Topic text.
|
||||||
* @param string[] $exclusions List of excluded terms/phrases.
|
* @param string[] $exclusions List of excluded terms/phrases.
|
||||||
*
|
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
private function is_excluded( string $topic, array $exclusions ): bool {
|
private function is_excluded( string $topic, array $exclusions ): bool {
|
||||||
@@ -120,33 +159,41 @@ class PRAutoBlogger_Idea_Scorer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a substantially similar post already exists.
|
* Extract meaningful keywords from a text string (for fallback dedup).
|
||||||
*
|
*
|
||||||
* Uses a simple keyword overlap check against existing published post titles.
|
* @param string $text Input text.
|
||||||
*
|
* @return string[] Unique lowercase keywords.
|
||||||
* @param string $topic Topic to check.
|
|
||||||
*
|
|
||||||
* @return bool True if a similar post exists.
|
|
||||||
*/
|
*/
|
||||||
private function has_similar_post( string $topic ): bool {
|
private function extract_meaningful_keywords( string $text ): array {
|
||||||
$query = new \WP_Query( [
|
static $stopwords = [
|
||||||
'post_type' => 'post',
|
'a', 'an', 'the', 'and', 'or', 'but', 'is', 'are', 'was', 'were',
|
||||||
'post_status' => [ 'publish', 'draft', 'pending' ],
|
'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'from', 'as',
|
||||||
'meta_key' => '_prautoblogger_generated',
|
'it', 'its', 'this', 'that', 'your', 'you', 'how', 'what', 'when',
|
||||||
'meta_value' => '1',
|
'why', 'where', 'which', 'who', 'do', 'does', 'did', 'not', 'no',
|
||||||
's' => $topic,
|
'can', 'will', 'should', 'could', 'would', 'may', 'might', 'have',
|
||||||
'posts_per_page' => 1,
|
'has', 'had', 'be', 'been', 'being', 'about', 'into', 'through',
|
||||||
'fields' => 'ids',
|
'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',
|
||||||
|
];
|
||||||
|
|
||||||
return $query->found_posts > 0;
|
$text = strtolower( $text );
|
||||||
|
$words = preg_split( '/[^a-z0-9-]+/', $text, -1, PREG_SPLIT_NO_EMPTY );
|
||||||
|
$words = array_filter( $words, static function ( string $w ) use ( $stopwords ): bool {
|
||||||
|
return strlen( $w ) >= 2 && ! in_array( $w, $stopwords, true );
|
||||||
|
} );
|
||||||
|
|
||||||
|
return array_values( array_unique( $words ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map analysis type to a reader-friendly article type.
|
* Map analysis type to a reader-friendly article type.
|
||||||
*
|
*
|
||||||
* @param string $analysis_type 'question', 'complaint', 'comparison'.
|
* @param string $analysis_type 'question', 'complaint', 'comparison', etc.
|
||||||
*
|
|
||||||
* @return string Article type label.
|
* @return string Article type label.
|
||||||
*/
|
*/
|
||||||
private function map_type( string $analysis_type ): string {
|
private function map_type( string $analysis_type ): string {
|
||||||
|
|||||||
@@ -39,6 +39,16 @@ class PRAutoBlogger_Image_Media_Sideloader {
|
|||||||
* @return int|\WP_Error Attachment ID on success, WP_Error on failure.
|
* @return int|\WP_Error Attachment ID on success, WP_Error on failure.
|
||||||
*/
|
*/
|
||||||
public function sideload_image( array $image_data, int $post_id, string $alt_text = '' ) {
|
public function sideload_image( array $image_data, int $post_id, string $alt_text = '' ) {
|
||||||
|
// WordPress media functions live in wp-admin/includes/ and are NOT
|
||||||
|
// loaded automatically in cron or REST contexts. Load them if missing.
|
||||||
|
// Why: WP-Cron runs as a frontend request, so admin-only files like
|
||||||
|
// media.php, file.php, and image.php are not included by default.
|
||||||
|
if ( ! function_exists( 'media_handle_sideload' ) ) {
|
||||||
|
require_once ABSPATH . 'wp-admin/includes/media.php';
|
||||||
|
require_once ABSPATH . 'wp-admin/includes/file.php';
|
||||||
|
require_once ABSPATH . 'wp-admin/includes/image.php';
|
||||||
|
}
|
||||||
|
|
||||||
// Validate input.
|
// Validate input.
|
||||||
if ( empty( $image_data['bytes'] ) ) {
|
if ( empty( $image_data['bytes'] ) ) {
|
||||||
return new \WP_Error( 'invalid_image_data', 'Image data (bytes) is empty.' );
|
return new \WP_Error( 'invalid_image_data', 'Image data (bytes) is empty.' );
|
||||||
@@ -135,7 +145,7 @@ class PRAutoBlogger_Image_Media_Sideloader {
|
|||||||
gmdate( 'Y-m-d_H-i-s' )
|
gmdate( 'Y-m-d_H-i-s' )
|
||||||
);
|
);
|
||||||
|
|
||||||
return $base . '.png'; // Default to PNG since FLUX.1 outputs PNG.
|
return $base . '.png'; // Default to PNG — most image models output PNG.
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
139
includes/core/class-image-nsfw-retry.php
Normal file
139
includes/core/class-image-nsfw-retry.php
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-shot retry of NSFW-blocked image slots with a sanitized prompt.
|
||||||
|
*
|
||||||
|
* When an image provider rejects a prompt via its content filter, its
|
||||||
|
* batch result carries
|
||||||
|
* `['error_type' => 'nsfw_blocked']`. This helper reruns just the blocked
|
||||||
|
* slot once with the rule-based fallback prompt (article title + style
|
||||||
|
* suffix, no LLM rewrite) and merges the new result back into the batch.
|
||||||
|
*
|
||||||
|
* Intentionally single-retry: if the fallback is also blocked, we log a
|
||||||
|
* WARNING and leave the `['error' => ...]` entry in place so the pipeline
|
||||||
|
* proceeds to publish without a featured image (current behaviour for any
|
||||||
|
* unrecoverable image failure).
|
||||||
|
*
|
||||||
|
* Gated behind the `prautoblogger_image_nsfw_retry` setting (default on).
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger_Image_Pipeline::generate_and_attach_images.
|
||||||
|
* Dependencies: PRAutoBlogger_Image_Provider_Interface (retry HTTP call),
|
||||||
|
* PRAutoBlogger_Image_Prompt_Builder (fallback prompt),
|
||||||
|
* PRAutoBlogger_Logger (warning + info lines).
|
||||||
|
*
|
||||||
|
* @see core/class-image-pipeline.php — Sole caller.
|
||||||
|
* @see core/class-image-prompt-builder.php — Supplies the fallback prompt.
|
||||||
|
* @see providers/class-image-nsfw-blocked.php — Typed exception raised by providers.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Image_NSFW_Retry {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Option key for the admin toggle.
|
||||||
|
*/
|
||||||
|
private const SETTING_KEY = 'prautoblogger_image_nsfw_retry';
|
||||||
|
|
||||||
|
/** @var PRAutoBlogger_Image_Provider_Interface */
|
||||||
|
private PRAutoBlogger_Image_Provider_Interface $provider;
|
||||||
|
|
||||||
|
/** @var PRAutoBlogger_Image_Prompt_Builder */
|
||||||
|
private PRAutoBlogger_Image_Prompt_Builder $prompt_builder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param PRAutoBlogger_Image_Provider_Interface $provider Provider to retry against.
|
||||||
|
* @param PRAutoBlogger_Image_Prompt_Builder $prompt_builder Fallback prompt source.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
PRAutoBlogger_Image_Provider_Interface $provider,
|
||||||
|
PRAutoBlogger_Image_Prompt_Builder $prompt_builder
|
||||||
|
) {
|
||||||
|
$this->provider = $provider;
|
||||||
|
$this->prompt_builder = $prompt_builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the admin setting currently allows NSFW retries.
|
||||||
|
*
|
||||||
|
* Defaults to on. Kept as a static so callers can short-circuit before
|
||||||
|
* constructing a retry object when nothing needs retrying anyway.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function is_enabled(): bool {
|
||||||
|
return '1' === (string) get_option( self::SETTING_KEY, '1' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace every NSFW-blocked slot in $batch_results with a fresh result
|
||||||
|
* from a sanitized fallback prompt.
|
||||||
|
*
|
||||||
|
* Mutates $batch_results and $captions in place. Also updates the
|
||||||
|
* per-slot entry in $captions so the caller writes the fallback
|
||||||
|
* caption under the post if the retry succeeds.
|
||||||
|
*
|
||||||
|
* Side effects: up to one HTTP call per blocked slot; Logger lines
|
||||||
|
* for every retry attempt (info on success, warning on second block).
|
||||||
|
*
|
||||||
|
* @param int $post_id Target post ID (for logs).
|
||||||
|
* @param array $batch_requests Original per-slot request map.
|
||||||
|
* @param array<string, array|null> $batch_results Provider result map (mutated).
|
||||||
|
* @param array<string, string> $captions Caption map (mutated).
|
||||||
|
* @param array<string, mixed> $article_data Article data (for title).
|
||||||
|
* @param array<string, mixed>|null $source_data Source data (for title on image_b).
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function retry_blocked_slots(
|
||||||
|
int $post_id,
|
||||||
|
array $batch_requests,
|
||||||
|
array &$batch_results,
|
||||||
|
array &$captions,
|
||||||
|
array $article_data,
|
||||||
|
?array $source_data
|
||||||
|
): void {
|
||||||
|
foreach ( $batch_results as $key => $entry ) {
|
||||||
|
if ( ! is_array( $entry ) || 'nsfw_blocked' !== ( $entry['error_type'] ?? '' ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$title = 'image_b' === $key
|
||||||
|
? (string) ( $source_data['title'] ?? '' )
|
||||||
|
: (string) ( $article_data['post_title'] ?? $article_data['suggested_title'] ?? '' );
|
||||||
|
|
||||||
|
$fallback = $this->prompt_builder->build_fallback_prompt( $title );
|
||||||
|
|
||||||
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
|
sprintf( 'NSFW retry for post %d slot "%s" with sanitized fallback prompt.', $post_id, $key ),
|
||||||
|
'image_pipeline'
|
||||||
|
);
|
||||||
|
|
||||||
|
$retry_request = [
|
||||||
|
'prompt' => $fallback['prompt'],
|
||||||
|
'width' => (int) ( $batch_requests[ $key ]['width'] ?? 0 ),
|
||||||
|
'height' => (int) ( $batch_requests[ $key ]['height'] ?? 0 ),
|
||||||
|
'options' => $batch_requests[ $key ]['options'] ?? [],
|
||||||
|
];
|
||||||
|
|
||||||
|
$retry_results = $this->provider->generate_image_batch( [ $key => $retry_request ] );
|
||||||
|
$retry_entry = $retry_results[ $key ] ?? [ 'error' => 'Retry provider returned no result.' ];
|
||||||
|
|
||||||
|
if ( isset( $retry_entry['error'] ) ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->warning(
|
||||||
|
sprintf(
|
||||||
|
'NSFW retry for post %d slot "%s" also failed: %s',
|
||||||
|
$post_id,
|
||||||
|
$key,
|
||||||
|
(string) $retry_entry['error']
|
||||||
|
),
|
||||||
|
'image_pipeline'
|
||||||
|
);
|
||||||
|
// Leave the original ['error' => ...] entry so the pipeline
|
||||||
|
// publishes without this image, matching current behaviour
|
||||||
|
// for any unrecoverable image failure.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$batch_results[ $key ] = $retry_entry;
|
||||||
|
$captions[ $key ] = $fallback['caption'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,277 +4,295 @@ declare(strict_types=1);
|
|||||||
/**
|
/**
|
||||||
* Orchestrates image generation for published articles.
|
* Orchestrates image generation for published articles.
|
||||||
*
|
*
|
||||||
* Generates two images per article (A/B experiment):
|
* Generates two images per article (A/B): Image A (article-driven) as featured
|
||||||
* - Image A: Article-driven prompt → featured image
|
* image, Image B (source-driven) in post meta. Each is independently fallible.
|
||||||
* - Image B: Source-driven prompt → post meta (_prautoblogger_image_b_id)
|
|
||||||
*
|
|
||||||
* Each image is independently fallible — if A fails, still try B. If both fail,
|
|
||||||
* the article is published without images (graceful degradation).
|
|
||||||
*
|
*
|
||||||
* Triggered by: PRAutoBlogger_Pipeline_Runner after publisher creates the post.
|
* Triggered by: PRAutoBlogger_Pipeline_Runner after publisher creates the post.
|
||||||
* Dependencies: PRAutoBlogger_Image_Provider_Interface (concrete provider),
|
* Dependencies: Image provider, Prompt builder, Sideloader, Cost tracker, Logger.
|
||||||
* PRAutoBlogger_Image_Prompt_Builder, PRAutoBlogger_Image_Media_Sideloader,
|
|
||||||
* PRAutoBlogger_Cost_Tracker, PRAutoBlogger_Logger.
|
|
||||||
*
|
*
|
||||||
* @see core/class-pipeline-runner.php — Calls this after publisher.
|
* @see core/class-pipeline-runner.php — Calls this after publisher.
|
||||||
* @see core/class-publisher.php — Sets featured image + post meta.
|
* @see core/class-image-prompt-builder.php — Prompt generation (scene + caption).
|
||||||
* @see core/class-image-prompt-builder.php — Prompt generation.
|
|
||||||
* @see core/class-image-media-sideloader.php — Media library integration.
|
* @see core/class-image-media-sideloader.php — Media library integration.
|
||||||
* @see ARCHITECTURE.md — Image pipeline data flow.
|
|
||||||
*/
|
*/
|
||||||
class PRAutoBlogger_Image_Pipeline {
|
class PRAutoBlogger_Image_Pipeline {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default image dimensions (landscape).
|
* Default image dimensions (landscape). 1200×632 is the closest
|
||||||
|
* 8-aligned pair to the standard OG image size (1200×630). Some
|
||||||
|
* providers require dimensions divisible by 8.
|
||||||
*/
|
*/
|
||||||
private const DEFAULT_WIDTH = 1200;
|
private const DEFAULT_WIDTH = 1200;
|
||||||
private const DEFAULT_HEIGHT = 630;
|
private const DEFAULT_HEIGHT = 632;
|
||||||
|
|
||||||
/**
|
/** @var PRAutoBlogger_Image_Provider_Interface Image gen provider. */
|
||||||
* The image provider (FLUX.1 via Cloudflare or another).
|
|
||||||
*
|
|
||||||
* @var PRAutoBlogger_Image_Provider_Interface
|
|
||||||
*/
|
|
||||||
private PRAutoBlogger_Image_Provider_Interface $provider;
|
private PRAutoBlogger_Image_Provider_Interface $provider;
|
||||||
|
/** @var PRAutoBlogger_Image_Prompt_Builder Builds scene + caption from article data. */
|
||||||
/**
|
|
||||||
* Prompt builder.
|
|
||||||
*
|
|
||||||
* @var PRAutoBlogger_Image_Prompt_Builder
|
|
||||||
*/
|
|
||||||
private PRAutoBlogger_Image_Prompt_Builder $prompt_builder;
|
private PRAutoBlogger_Image_Prompt_Builder $prompt_builder;
|
||||||
|
/** @var PRAutoBlogger_Image_Media_Sideloader Downloads images into WP media library. */
|
||||||
/**
|
|
||||||
* Media library sideloader.
|
|
||||||
*
|
|
||||||
* @var PRAutoBlogger_Image_Media_Sideloader
|
|
||||||
*/
|
|
||||||
private PRAutoBlogger_Image_Media_Sideloader $sideloader;
|
private PRAutoBlogger_Image_Media_Sideloader $sideloader;
|
||||||
|
/** @var PRAutoBlogger_Cost_Tracker Logs image generation spend. */
|
||||||
/**
|
|
||||||
* Cost tracker for logging spend.
|
|
||||||
*
|
|
||||||
* @var PRAutoBlogger_Cost_Tracker
|
|
||||||
*/
|
|
||||||
private PRAutoBlogger_Cost_Tracker $cost_tracker;
|
private PRAutoBlogger_Cost_Tracker $cost_tracker;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Construct with dependencies.
|
* Construct with dependencies.
|
||||||
*
|
*
|
||||||
* @param PRAutoBlogger_Image_Provider_Interface|null $provider Optional provider (defaults to Cloudflare).
|
* When no provider is injected, the constructor reads the
|
||||||
|
* `prautoblogger_image_provider` setting and instantiates the
|
||||||
|
* matching 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_Cost_Tracker|null $cost_tracker Optional cost tracker.
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
?PRAutoBlogger_Image_Provider_Interface $provider = null,
|
?PRAutoBlogger_Image_Provider_Interface $provider = null,
|
||||||
?PRAutoBlogger_Cost_Tracker $cost_tracker = null
|
?PRAutoBlogger_Cost_Tracker $cost_tracker = null
|
||||||
) {
|
) {
|
||||||
$this->provider = $provider ?? new PRAutoBlogger_Cloudflare_Image_Provider();
|
$this->provider = $provider ?? self::create_default_provider();
|
||||||
$this->prompt_builder = new PRAutoBlogger_Image_Prompt_Builder();
|
$this->prompt_builder = new PRAutoBlogger_Image_Prompt_Builder();
|
||||||
$this->sideloader = new PRAutoBlogger_Image_Media_Sideloader();
|
$this->sideloader = new PRAutoBlogger_Image_Media_Sideloader();
|
||||||
$this->cost_tracker = $cost_tracker ?? new PRAutoBlogger_Cost_Tracker();
|
$this->cost_tracker = $cost_tracker ?? new PRAutoBlogger_Cost_Tracker();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instantiate the image provider based on the admin setting. Defaults
|
||||||
|
* to Runware on unrecognised values (CF was removed in v0.10.0).
|
||||||
|
*
|
||||||
|
* @return PRAutoBlogger_Image_Provider_Interface
|
||||||
|
*/
|
||||||
|
private static function create_default_provider(): PRAutoBlogger_Image_Provider_Interface {
|
||||||
|
$provider_id = (string) get_option( 'prautoblogger_image_provider', PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER );
|
||||||
|
if ( 'openrouter' === $provider_id ) {
|
||||||
|
return new PRAutoBlogger_OpenRouter_Image_Provider();
|
||||||
|
}
|
||||||
|
return new PRAutoBlogger_Runware_Image_Provider();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate and attach images to a published post.
|
* Generate and attach images to a published post.
|
||||||
*
|
*
|
||||||
* Generates Image A (article-driven) and Image B (source-driven). If enabled
|
* Builds all prompts upfront, fires a single batch call (parallel via
|
||||||
* in settings. Each image is independently fallible.
|
* curl_multi on OpenRouter), then processes results. Wall-clock time
|
||||||
|
* equals the slowest image, not the sum — solving the Image B timeout.
|
||||||
*
|
*
|
||||||
* @param int $post_id Post ID to attach images to.
|
* @param int $post_id Post ID to attach images to.
|
||||||
* @param array{
|
* @param array $article_data Article title + content.
|
||||||
* post_title?: string,
|
* @param array|null $source_data Optional source data for Image B.
|
||||||
* post_content?: string,
|
|
||||||
* suggested_title?: string,
|
|
||||||
* } $article_data Article title + content.
|
|
||||||
* @param array{
|
|
||||||
* title?: string,
|
|
||||||
* selftext?: string,
|
|
||||||
* comments?: string[],
|
|
||||||
* }|null $source_data Optional Reddit source data for Image B.
|
|
||||||
*
|
*
|
||||||
* @return array{
|
* @return array{image_a_id?: int, image_b_id?: int, cost_usd: float, errors: string[]}
|
||||||
* image_a_id?: int,
|
|
||||||
* image_b_id?: int,
|
|
||||||
* cost_usd: float,
|
|
||||||
* errors: string[],
|
|
||||||
* } Attachment IDs (if successful) and cost/errors.
|
|
||||||
*/
|
*/
|
||||||
public function generate_and_attach_images(
|
public function generate_and_attach_images(
|
||||||
int $post_id,
|
int $post_id,
|
||||||
array $article_data,
|
array $article_data,
|
||||||
?array $source_data = null
|
?array $source_data = null
|
||||||
): array {
|
): array {
|
||||||
$result = [
|
$result = [ 'cost_usd' => 0.0, 'errors' => [] ];
|
||||||
'cost_usd' => 0.0,
|
|
||||||
'errors' => [],
|
|
||||||
];
|
|
||||||
|
|
||||||
// Early exit if image generation is disabled.
|
|
||||||
if ( ! get_option( 'prautoblogger_image_enabled' ) ) {
|
if ( ! get_option( 'prautoblogger_image_enabled' ) ) {
|
||||||
PRAutoBlogger_Logger::instance()->info( 'Image generation disabled in settings.', 'image_pipeline' );
|
PRAutoBlogger_Logger::instance()->info( 'Image generation disabled in settings.', 'image_pipeline' );
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate Image A (article-driven prompt).
|
// Check whether Image B is enabled in settings.
|
||||||
$image_a_result = $this->generate_image_a( $post_id, $article_data );
|
$image_b_enabled = get_option( 'prautoblogger_image_b_enabled', '1' );
|
||||||
if ( ! is_wp_error( $image_a_result ) && isset( $image_a_result['attachment_id'] ) ) {
|
$has_source_data = null !== $source_data && ! empty( $source_data );
|
||||||
$result['image_a_id'] = $image_a_result['attachment_id'];
|
$generate_b = $has_source_data && '1' === $image_b_enabled;
|
||||||
} else {
|
|
||||||
$result['errors'][] = is_wp_error( $image_a_result )
|
// Budget pre-check: estimate cost for all images before any HTTP calls.
|
||||||
? $image_a_result->get_error_message()
|
$image_count = $generate_b ? 2 : 1;
|
||||||
: 'Image A generation produced no attachment ID.';
|
$estimated_cost = $this->provider->estimate_cost( self::DEFAULT_WIDTH, self::DEFAULT_HEIGHT ) * $image_count;
|
||||||
}
|
if ( $this->cost_tracker->would_exceed_budget( $estimated_cost ) ) {
|
||||||
if ( ! is_wp_error( $image_a_result ) ) {
|
$result['errors'][] = 'Image generation would exceed monthly budget.';
|
||||||
$result['cost_usd'] += $image_a_result['cost_usd'] ?? 0.0;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate Image B (source-driven prompt) if source data is available.
|
// Build all prompts upfront (LLM calls happen here, sequentially).
|
||||||
if ( null !== $source_data && ! empty( $source_data ) ) {
|
$article_prompt = $this->prompt_builder->build_article_prompt( $article_data );
|
||||||
$image_b_result = $this->generate_image_b( $post_id, $source_data );
|
$batch_requests = [
|
||||||
if ( ! is_wp_error( $image_b_result ) && isset( $image_b_result['attachment_id'] ) ) {
|
'image_a' => [
|
||||||
$result['image_b_id'] = $image_b_result['attachment_id'];
|
'prompt' => $article_prompt['prompt'],
|
||||||
} else {
|
'width' => self::DEFAULT_WIDTH,
|
||||||
$result['errors'][] = is_wp_error( $image_b_result )
|
'height' => self::DEFAULT_HEIGHT,
|
||||||
? $image_b_result->get_error_message()
|
],
|
||||||
: 'Image B generation produced no attachment ID.';
|
];
|
||||||
}
|
$captions = [ 'image_a' => $article_prompt['caption'] ];
|
||||||
if ( ! is_wp_error( $image_b_result ) ) {
|
|
||||||
$result['cost_usd'] += $image_b_result['cost_usd'] ?? 0.0;
|
$source_prompt = null;
|
||||||
}
|
if ( $generate_b ) {
|
||||||
|
$source_prompt = $this->prompt_builder->build_source_prompt( $source_data );
|
||||||
|
$batch_requests['image_b'] = [
|
||||||
|
'prompt' => $source_prompt['prompt'],
|
||||||
|
'width' => self::DEFAULT_WIDTH,
|
||||||
|
'height' => self::DEFAULT_HEIGHT,
|
||||||
|
];
|
||||||
|
$captions['image_b'] = $source_prompt['caption'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire all image generation requests in parallel.
|
||||||
|
try {
|
||||||
|
$batch_results = $this->provider->generate_image_batch( $batch_requests );
|
||||||
|
} catch ( \Throwable $e ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->error( 'Batch generation failed: ' . $e->getMessage(), 'image_pipeline' );
|
||||||
|
$result['errors'][] = $e->getMessage();
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process Image A result.
|
||||||
|
$this->process_image_a( $post_id, $batch_results, $captions, $result );
|
||||||
|
|
||||||
|
// Process Image B result (if requested).
|
||||||
|
if ( isset( $batch_results['image_b'] ) ) {
|
||||||
|
$this->process_image_b( $post_id, $batch_results, $captions, $result );
|
||||||
|
} elseif ( null === $source_data || empty( $source_data ) ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
|
sprintf( 'Image B skipped for post %d: no source data.', $post_id ),
|
||||||
|
'image_pipeline'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate Image A from article content.
|
* Process Image A batch result: sideload, set featured image, insert caption.
|
||||||
*
|
*
|
||||||
* @param int $post_id Post ID.
|
* @param int $post_id Post ID.
|
||||||
* @param array $article_data Article data.
|
* @param array $batch_results Keyed results from generate_image_batch().
|
||||||
*
|
* @param array $captions Keyed captions from prompt builder.
|
||||||
* @return array{
|
* @param array $result Pipeline result array (modified by reference).
|
||||||
* attachment_id?: int,
|
|
||||||
* cost_usd: float,
|
|
||||||
* }|\WP_Error
|
|
||||||
*/
|
*/
|
||||||
private function generate_image_a( int $post_id, array $article_data ) {
|
private function process_image_a( int $post_id, array $batch_results, array $captions, array &$result ): void {
|
||||||
try {
|
$image_data = $batch_results['image_a'] ?? null;
|
||||||
// Build the article-driven prompt.
|
if ( ! $image_data || isset( $image_data['error'] ) ) {
|
||||||
$prompt = $this->prompt_builder->build_article_prompt( $article_data );
|
$err_msg = $image_data['error'] ?? 'Image A missing from batch results.';
|
||||||
|
$result['errors'][] = $err_msg;
|
||||||
// Estimate cost before generating.
|
// Log at WARNING so the path is visible even if the outer catch in the
|
||||||
$estimated_cost = $this->provider->estimate_cost( self::DEFAULT_WIDTH, self::DEFAULT_HEIGHT );
|
// caller doesn't fire. See thread image-mime-bug Issue 2.
|
||||||
if ( $this->cost_tracker->would_exceed_budget( $estimated_cost ) ) {
|
PRAutoBlogger_Logger::instance()->warning(
|
||||||
return new \WP_Error(
|
sprintf( 'Image A generation failed for post %d: %s', $post_id, $err_msg ),
|
||||||
'budget_exceeded',
|
|
||||||
'Image generation would exceed monthly budget.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate the image.
|
|
||||||
$image_data = $this->provider->generate_image( $prompt, self::DEFAULT_WIDTH, self::DEFAULT_HEIGHT );
|
|
||||||
|
|
||||||
// Sideload the image into media library.
|
|
||||||
$attachment_id = $this->sideloader->sideload_image(
|
|
||||||
$image_data,
|
|
||||||
$post_id,
|
|
||||||
substr( $prompt, 0, 100 ) // Use first 100 chars of prompt as alt text.
|
|
||||||
);
|
|
||||||
|
|
||||||
if ( is_wp_error( $attachment_id ) ) {
|
|
||||||
return $attachment_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log the cost.
|
|
||||||
$this->cost_tracker->log_image_generation(
|
|
||||||
$image_data['cost_usd'],
|
|
||||||
'flux-1-schnell',
|
|
||||||
$post_id,
|
|
||||||
'image_a'
|
|
||||||
);
|
|
||||||
|
|
||||||
PRAutoBlogger_Logger::instance()->info(
|
|
||||||
sprintf( 'Image A generated for post %d (attachment %d, cost $%.4f)', $post_id, $attachment_id, $image_data['cost_usd'] ),
|
|
||||||
'image_pipeline'
|
'image_pipeline'
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
$attachment_id = $this->sideload_and_log( $image_data, $post_id, 'image_a', 'Image A' );
|
||||||
'attachment_id' => $attachment_id,
|
if ( is_wp_error( $attachment_id ) ) {
|
||||||
'cost_usd' => $image_data['cost_usd'],
|
$result['errors'][] = $attachment_id->get_error_message();
|
||||||
];
|
return;
|
||||||
} catch ( \Exception $e ) {
|
}
|
||||||
PRAutoBlogger_Logger::instance()->error(
|
|
||||||
'Image A generation failed: ' . $e->getMessage(),
|
|
||||||
'image_pipeline'
|
|
||||||
);
|
|
||||||
|
|
||||||
return new \WP_Error( 'image_generation_failed', $e->getMessage() );
|
$result['image_a_id'] = $attachment_id;
|
||||||
|
$result['cost_usd'] += $image_data['cost_usd'];
|
||||||
|
set_post_thumbnail( $post_id, $attachment_id );
|
||||||
|
|
||||||
|
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'] );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate Image B from source data.
|
* Process Image B batch result: sideload, store meta.
|
||||||
*
|
*
|
||||||
* @param int $post_id Post ID.
|
* @param int $post_id Post ID.
|
||||||
* @param array $source_data Source Reddit data.
|
* @param array $batch_results Keyed results from generate_image_batch().
|
||||||
*
|
* @param array $captions Keyed captions from prompt builder.
|
||||||
* @return array{
|
* @param array $result Pipeline result array (modified by reference).
|
||||||
* attachment_id?: int,
|
|
||||||
* cost_usd: float,
|
|
||||||
* }|\WP_Error
|
|
||||||
*/
|
*/
|
||||||
private function generate_image_b( int $post_id, array $source_data ) {
|
private function process_image_b( int $post_id, array $batch_results, array $captions, array &$result ): void {
|
||||||
try {
|
$image_data = $batch_results['image_b'] ?? null;
|
||||||
// Build the source-driven prompt.
|
if ( ! $image_data || isset( $image_data['error'] ) ) {
|
||||||
$prompt = $this->prompt_builder->build_source_prompt( $source_data );
|
$err_msg = $image_data['error'] ?? 'Image B missing from batch results.';
|
||||||
|
$result['errors'][] = $err_msg;
|
||||||
// Estimate cost before generating.
|
// Log at WARNING so the path is visible even if the outer catch in the
|
||||||
$estimated_cost = $this->provider->estimate_cost( self::DEFAULT_WIDTH, self::DEFAULT_HEIGHT );
|
// caller doesn't fire. See thread image-mime-bug Issue 2.
|
||||||
if ( $this->cost_tracker->would_exceed_budget( $estimated_cost ) ) {
|
PRAutoBlogger_Logger::instance()->warning(
|
||||||
return new \WP_Error(
|
sprintf( 'Image B generation failed for post %d: %s', $post_id, $err_msg ),
|
||||||
'budget_exceeded',
|
|
||||||
'Image generation would exceed monthly budget.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate the image.
|
|
||||||
$image_data = $this->provider->generate_image( $prompt, self::DEFAULT_WIDTH, self::DEFAULT_HEIGHT );
|
|
||||||
|
|
||||||
// Sideload the image into media library.
|
|
||||||
$attachment_id = $this->sideloader->sideload_image(
|
|
||||||
$image_data,
|
|
||||||
$post_id,
|
|
||||||
substr( $prompt, 0, 100 ) // Use first 100 chars of prompt as alt text.
|
|
||||||
);
|
|
||||||
|
|
||||||
if ( is_wp_error( $attachment_id ) ) {
|
|
||||||
return $attachment_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log the cost.
|
|
||||||
$this->cost_tracker->log_image_generation(
|
|
||||||
$image_data['cost_usd'],
|
|
||||||
'flux-1-schnell',
|
|
||||||
$post_id,
|
|
||||||
'image_b'
|
|
||||||
);
|
|
||||||
|
|
||||||
PRAutoBlogger_Logger::instance()->info(
|
|
||||||
sprintf( 'Image B generated for post %d (attachment %d, cost $%.4f)', $post_id, $attachment_id, $image_data['cost_usd'] ),
|
|
||||||
'image_pipeline'
|
'image_pipeline'
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
$attachment_id = $this->sideload_and_log( $image_data, $post_id, 'image_b', 'Image B' );
|
||||||
'attachment_id' => $attachment_id,
|
if ( is_wp_error( $attachment_id ) ) {
|
||||||
'cost_usd' => $image_data['cost_usd'],
|
$result['errors'][] = $attachment_id->get_error_message();
|
||||||
];
|
return;
|
||||||
} catch ( \Exception $e ) {
|
}
|
||||||
PRAutoBlogger_Logger::instance()->error(
|
|
||||||
'Image B generation failed: ' . $e->getMessage(),
|
|
||||||
'image_pipeline'
|
|
||||||
);
|
|
||||||
|
|
||||||
return new \WP_Error( 'image_generation_failed', $e->getMessage() );
|
$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,
|
||||||
|
] );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,77 +4,224 @@ declare(strict_types=1);
|
|||||||
/**
|
/**
|
||||||
* Builds image prompts from article content or source data.
|
* Builds image prompts from article content or source data.
|
||||||
*
|
*
|
||||||
* Generates concise visual concepts suitable for FLUX.1 image generation,
|
* Uses a cheap LLM (via OpenRouter) to distill article content into concise
|
||||||
* extracting key themes from article titles/content and Reddit source data.
|
* visual scene descriptions optimised for image generation. Falls back to rule-based
|
||||||
|
* synthesis if the LLM call fails, so image generation never blocks on a
|
||||||
|
* prompt-rewriting outage.
|
||||||
*
|
*
|
||||||
* Triggered by: PRAutoBlogger_Image_Pipeline during content generation run.
|
* Triggered by: PRAutoBlogger_Image_Pipeline during content generation run.
|
||||||
* Dependencies: None — pure data transformation.
|
* Dependencies: PRAutoBlogger_OpenRouter_Provider (LLM call),
|
||||||
|
* PRAutoBlogger_Cost_Tracker (logs prompt-rewrite cost),
|
||||||
|
* PRAutoBlogger_Logger (diagnostics).
|
||||||
*
|
*
|
||||||
* @see core/class-image-pipeline.php — Consumes build_article_prompt() and build_source_prompt().
|
* @see core/class-image-pipeline.php — Consumes build_article_prompt() and build_source_prompt().
|
||||||
|
* @see providers/class-open-router-provider.php — LLM provider used for rewriting.
|
||||||
* @see ARCHITECTURE.md — Image generation data flow.
|
* @see ARCHITECTURE.md — Image generation data flow.
|
||||||
*/
|
*/
|
||||||
class PRAutoBlogger_Image_Prompt_Builder {
|
class PRAutoBlogger_Image_Prompt_Builder {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default system prompt that teaches the LLM how to write image-gen
|
||||||
|
* prompts. Exposed as a `public const` so the admin-settings layer can
|
||||||
|
* use it as the default value for `prautoblogger_image_prompt_instructions`.
|
||||||
|
* The option, when non-empty, wins at call time (see rewrite_via_llm).
|
||||||
|
*/
|
||||||
|
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.
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
"They told me the peptide did the work, not the squats."
|
||||||
|
PROMPT;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Max tokens for the rewriter response. Comic concepts need more room
|
||||||
|
* for the scene description plus the caption punchline.
|
||||||
|
*/
|
||||||
|
private const REWRITER_MAX_TOKENS = 180;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenRouter provider for LLM calls. Null until first use.
|
||||||
|
*
|
||||||
|
* @var PRAutoBlogger_OpenRouter_Provider|null
|
||||||
|
*/
|
||||||
|
private ?PRAutoBlogger_OpenRouter_Provider $llm = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a visual prompt from finished article content.
|
* Build a visual prompt from finished article content.
|
||||||
*
|
*
|
||||||
* Extracts the title and first paragraph to synthesize a product-marketing
|
* Tries LLM rewriting first; falls back to rule-based synthesis on
|
||||||
* angle visual concept suitable for a featured image. Appends the style suffix
|
* failure. Splits scene (for image gen) from caption (HTML below the
|
||||||
* from plugin options.
|
* image) and appends the style suffix.
|
||||||
*
|
*
|
||||||
* @param array{
|
* @param array{post_title?: string, post_content?: string, suggested_title?: string} $article_data
|
||||||
* post_title?: string,
|
* @return array{prompt: string, caption: string}
|
||||||
* post_content?: string,
|
|
||||||
* suggested_title?: string,
|
|
||||||
* } $article_data Article data with title and HTML content.
|
|
||||||
*
|
|
||||||
* @return string Concise prompt (under 200 words) with style suffix appended.
|
|
||||||
*/
|
*/
|
||||||
public function build_article_prompt( array $article_data ): string {
|
public function build_article_prompt( array $article_data ): array {
|
||||||
$title = $article_data['post_title'] ?? $article_data['suggested_title'] ?? 'Product';
|
$title = $article_data['post_title'] ?? $article_data['suggested_title'] ?? 'Product';
|
||||||
$content = $article_data['post_content'] ?? '';
|
$content = $article_data['post_content'] ?? '';
|
||||||
|
|
||||||
// Extract first paragraph from HTML content.
|
|
||||||
$first_para = $this->extract_first_paragraph( $content );
|
$first_para = $this->extract_first_paragraph( $content );
|
||||||
|
|
||||||
// Build visual concepts from title + first paragraph.
|
$parsed = $this->rewrite_via_llm( $title, $first_para );
|
||||||
$concepts = $this->synthesize_visual_concepts( $title, $first_para );
|
|
||||||
|
|
||||||
// Append style suffix.
|
|
||||||
$style_suffix = get_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
|
$style_suffix = get_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
|
||||||
|
|
||||||
return trim( $concepts . ' ' . $style_suffix );
|
return [
|
||||||
|
'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ),
|
||||||
|
'caption' => $parsed['caption'],
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a visual prompt from source Reddit thread data.
|
* Build a visual prompt from source Reddit thread data. Tries LLM
|
||||||
|
* rewriting first; falls back to rule-based synthesis on failure.
|
||||||
*
|
*
|
||||||
* Extracts the Reddit thread title and top comments to synthesize an
|
* @param array{title?: string, selftext?: string, comments?: string[]} $source_data
|
||||||
* editorial-cartoonist angle visual concept. Appends the style suffix
|
* @return array{prompt: string, caption: string}
|
||||||
* from plugin options.
|
|
||||||
*
|
|
||||||
* @param array{
|
|
||||||
* title?: string,
|
|
||||||
* selftext?: string,
|
|
||||||
* comments?: string[],
|
|
||||||
* } $source_data Reddit source data with title and comments.
|
|
||||||
*
|
|
||||||
* @return string Concise prompt (under 200 words) with style suffix appended.
|
|
||||||
*/
|
*/
|
||||||
public function build_source_prompt( array $source_data ): string {
|
public function build_source_prompt( array $source_data ): array {
|
||||||
$title = $source_data['title'] ?? 'Reddit Discussion';
|
$title = $source_data['title'] ?? 'Reddit Discussion';
|
||||||
$comments = $source_data['comments'] ?? [];
|
$comments = $source_data['comments'] ?? [];
|
||||||
|
$context = is_array( $comments ) && ! empty( $comments ) ? $comments[0] : '';
|
||||||
|
|
||||||
// Build visual concepts from title + top comments.
|
$parsed = $this->rewrite_via_llm( $title, $context );
|
||||||
$concepts = $this->synthesize_visual_concepts(
|
|
||||||
$title,
|
|
||||||
is_array( $comments ) && ! empty( $comments ) ? $comments[0] : ''
|
|
||||||
);
|
|
||||||
|
|
||||||
// Append style suffix.
|
|
||||||
$style_suffix = get_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
|
$style_suffix = get_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
|
||||||
|
|
||||||
return trim( $concepts . ' ' . $style_suffix );
|
return [
|
||||||
|
'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ),
|
||||||
|
'caption' => $parsed['caption'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use a cheap LLM to distill title + context into a visual scene + caption.
|
||||||
|
*
|
||||||
|
* The LLM response contains a SCENE line and a CAPTION line separated by a
|
||||||
|
* blank line. This method parses them apart so the scene drives image gen
|
||||||
|
* (no text baked in) and the caption is inserted as HTML below the image.
|
||||||
|
*
|
||||||
|
* Falls back to rule-based synthesis if the LLM call fails for any
|
||||||
|
* reason (network, auth, timeout, unexpected response shape).
|
||||||
|
*
|
||||||
|
* Side effects: one OpenRouter API call; one cost-tracker log entry.
|
||||||
|
*
|
||||||
|
* @param string $title Article or thread title.
|
||||||
|
* @param string $context First paragraph or top comment.
|
||||||
|
* @return array{scene: string, caption: string} Scene for image gen, caption for HTML.
|
||||||
|
*/
|
||||||
|
private function rewrite_via_llm( string $title, string $context ): array {
|
||||||
|
$title = trim( sanitize_text_field( $title ) );
|
||||||
|
$context = trim( sanitize_text_field( $context ) );
|
||||||
|
|
||||||
|
// Truncate context to keep prompt tokens low.
|
||||||
|
if ( strlen( $context ) > 300 ) {
|
||||||
|
$context = substr( $context, 0, 300 ) . '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
$user_message = "Article title: {$title}";
|
||||||
|
if ( '' !== $context ) {
|
||||||
|
$user_message .= "\n\nSummary: {$context}";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$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 ],
|
||||||
|
],
|
||||||
|
$model,
|
||||||
|
[
|
||||||
|
'temperature' => 0.7,
|
||||||
|
'max_tokens' => self::REWRITER_MAX_TOKENS,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$raw = trim( $result['content'] ?? '' );
|
||||||
|
|
||||||
|
// Log the rewrite cost so it shows in the analytics dashboard.
|
||||||
|
( new PRAutoBlogger_Cost_Tracker() )->log_api_call(
|
||||||
|
null,
|
||||||
|
'image_prompt_rewrite',
|
||||||
|
'openrouter',
|
||||||
|
$model,
|
||||||
|
$result['prompt_tokens'] ?? 0,
|
||||||
|
$result['completion_tokens'] ?? 0
|
||||||
|
);
|
||||||
|
|
||||||
|
$cost = $llm->estimate_cost(
|
||||||
|
$model,
|
||||||
|
$result['prompt_tokens'] ?? 0,
|
||||||
|
$result['completion_tokens'] ?? 0
|
||||||
|
);
|
||||||
|
|
||||||
|
PRAutoBlogger_Logger::instance()->debug(
|
||||||
|
sprintf( 'Image prompt rewritten (%d→%d chars, $%.6f): %s', strlen( $user_message ), strlen( $raw ), $cost, substr( $raw, 0, 120 ) ),
|
||||||
|
'image_prompt_builder'
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( '' !== $raw ) {
|
||||||
|
return $this->parse_scene_and_caption( $raw );
|
||||||
|
}
|
||||||
|
} catch ( \Throwable $e ) {
|
||||||
|
// LLM failure is not fatal — fall back to rule-based synthesis.
|
||||||
|
PRAutoBlogger_Logger::instance()->warning(
|
||||||
|
sprintf( 'Image prompt LLM rewrite %s, using fallback: %s', get_class( $e ), $e->getMessage() ),
|
||||||
|
'image_prompt_builder'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $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' => '' ];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,18 +231,13 @@ class PRAutoBlogger_Image_Prompt_Builder {
|
|||||||
* or 200 characters, whichever comes first.
|
* or 200 characters, whichever comes first.
|
||||||
*
|
*
|
||||||
* @param string $html HTML content.
|
* @param string $html HTML content.
|
||||||
*
|
|
||||||
* @return string Plain text of the first paragraph.
|
* @return string Plain text of the first paragraph.
|
||||||
*/
|
*/
|
||||||
private function extract_first_paragraph( string $html ): string {
|
private function extract_first_paragraph( string $html ): string {
|
||||||
// Remove HTML tags.
|
$text = wp_strip_all_tags( $html );
|
||||||
$text = wp_strip_all_tags( $html );
|
$paras = preg_split( '/\n\n+/', $text );
|
||||||
|
$para = isset( $paras[0] ) ? trim( $paras[0] ) : '';
|
||||||
|
|
||||||
// Split on double-newline and take the first paragraph.
|
|
||||||
$paras = preg_split( '/\n\n+/', $text );
|
|
||||||
$para = isset( $paras[0] ) ? trim( $paras[0] ) : '';
|
|
||||||
|
|
||||||
// Limit to first 200 chars to keep prompt concise.
|
|
||||||
if ( strlen( $para ) > 200 ) {
|
if ( strlen( $para ) > 200 ) {
|
||||||
$para = substr( $para, 0, 200 ) . '...';
|
$para = substr( $para, 0, 200 ) . '...';
|
||||||
}
|
}
|
||||||
@@ -104,33 +246,55 @@ class PRAutoBlogger_Image_Prompt_Builder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Synthesize visual concepts from a title and supporting text.
|
* Public entry into the rule-based fallback, used by NSFW retry to
|
||||||
|
* rebuild a provider-safe prompt from just the article title. Matches
|
||||||
|
* the return shape of build_article_prompt() / build_source_prompt().
|
||||||
*
|
*
|
||||||
* Combines key words and themes into a short visual narrative that FLUX.1
|
* @param string $title Article or source title.
|
||||||
* can render. This is intentionally rule-based rather than LLM-driven to
|
* @return array{prompt: string, caption: string}
|
||||||
* avoid another API call per image generation.
|
|
||||||
*
|
|
||||||
* @param string $title Main heading / topic.
|
|
||||||
* @param string $supporting_text Additional context (first para or comment).
|
|
||||||
*
|
|
||||||
* @return string Synthesized visual prompt concept.
|
|
||||||
*/
|
*/
|
||||||
private function synthesize_visual_concepts( string $title, string $supporting_text ): string {
|
public function build_fallback_prompt( string $title ): array {
|
||||||
$title = trim( sanitize_text_field( $title ) );
|
$parsed = $this->synthesize_visual_concepts_fallback( $title, '' );
|
||||||
$text = trim( sanitize_text_field( $supporting_text ) );
|
$style_suffix = get_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
|
||||||
|
return [
|
||||||
|
'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ),
|
||||||
|
'caption' => $parsed['caption'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// Build a simple declarative concept from title and text snippets.
|
/**
|
||||||
$prompt = "A visual representation of: {$title}";
|
* 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
|
||||||
|
);
|
||||||
|
|
||||||
if ( ! empty( $text ) ) {
|
return [
|
||||||
// Take first 150 chars to avoid bloating the prompt.
|
'scene' => trim( $scene ),
|
||||||
$snippet = strlen( $text ) > 150
|
'caption' => 'Science is full of surprises.',
|
||||||
? substr( $text, 0, 150 ) . '...'
|
];
|
||||||
: $text;
|
}
|
||||||
|
|
||||||
$prompt .= ". Context: {$snippet}";
|
/** Lazy-load the OpenRouter provider. */
|
||||||
|
private function get_llm_provider(): PRAutoBlogger_OpenRouter_Provider {
|
||||||
|
if ( null === $this->llm ) {
|
||||||
|
$this->llm = new PRAutoBlogger_OpenRouter_Provider();
|
||||||
}
|
}
|
||||||
|
return $this->llm;
|
||||||
|
}
|
||||||
|
|
||||||
return trim( $prompt );
|
/** Admin option wins; blank falls back to REWRITER_SYSTEM_PROMPT. */
|
||||||
|
private function resolve_system_prompt(): string {
|
||||||
|
$override = (string) get_option( 'prautoblogger_image_prompt_instructions', '' );
|
||||||
|
return '' !== trim( $override ) ? $override : self::REWRITER_SYSTEM_PROMPT;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
60
includes/core/class-json-extractor.php
Normal file
60
includes/core/class-json-extractor.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts valid JSON from potentially dirty LLM response strings.
|
||||||
|
*
|
||||||
|
* Many models ignore the `response_format: json_object` instruction and wrap
|
||||||
|
* their output in markdown fences, add preamble text, or append trailing
|
||||||
|
* commentary. This class strips all of that and returns clean decoded data.
|
||||||
|
*
|
||||||
|
* Triggered by: Any class that parses JSON from an LLM response.
|
||||||
|
* Dependencies: None (pure utility).
|
||||||
|
*
|
||||||
|
* @see core/class-content-analyzer.php — Uses this for analysis JSON parsing.
|
||||||
|
* @see core/class-chief-editor.php — Uses this for editorial review JSON parsing.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Json_Extractor {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode JSON from an LLM response, tolerating markdown fences and preamble.
|
||||||
|
*
|
||||||
|
* Attempts, in order:
|
||||||
|
* 1. Direct json_decode (model returned clean JSON).
|
||||||
|
* 2. Strip ```json ... ``` fences, then decode.
|
||||||
|
* 3. Find the first `{` and last `}` and decode that substring.
|
||||||
|
*
|
||||||
|
* @param string $raw The raw LLM response content.
|
||||||
|
* @return array<string, mixed>|null Decoded associative array, or null on failure.
|
||||||
|
*/
|
||||||
|
public static function decode( string $raw ): ?array {
|
||||||
|
$trimmed = trim( $raw );
|
||||||
|
|
||||||
|
// 1. Clean JSON — best case.
|
||||||
|
$decoded = json_decode( $trimmed, true );
|
||||||
|
if ( is_array( $decoded ) ) {
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Markdown-fenced JSON (```json ... ``` or ``` ... ```).
|
||||||
|
if ( preg_match( '/```(?:json)?\s*\n?(.*?)\n?\s*```/s', $trimmed, $matches ) ) {
|
||||||
|
$decoded = json_decode( trim( $matches[1] ), true );
|
||||||
|
if ( is_array( $decoded ) ) {
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Brute-force: find outermost { ... } substring.
|
||||||
|
$first_brace = strpos( $trimmed, '{' );
|
||||||
|
$last_brace = strrpos( $trimmed, '}' );
|
||||||
|
if ( false !== $first_brace && false !== $last_brace && $last_brace > $first_brace ) {
|
||||||
|
$candidate = substr( $trimmed, $first_brace, $last_brace - $first_brace + 1 );
|
||||||
|
$decoded = json_decode( $candidate, true );
|
||||||
|
if ( is_array( $decoded ) ) {
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
149
includes/core/class-peptide-linker.php
Normal file
149
includes/core/class-peptide-linker.php
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic post-processor that hyperlinks peptide mentions in HTML content.
|
||||||
|
*
|
||||||
|
* Scans generated article HTML for any peptide name that exists in the PR Core
|
||||||
|
* database and wraps every mention in an <a> tag pointing to its /peptides/ page.
|
||||||
|
* Skips text that is already inside an existing <a> tag to avoid nested links.
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger_Publisher::create_post() (before wp_insert_post).
|
||||||
|
* Dependencies: PR Core plugin (pr_peptide CPT). Gracefully no-ops if PR Core is inactive.
|
||||||
|
*
|
||||||
|
* @see core/class-publisher.php — Calls inject_links() on post_content.
|
||||||
|
* @see peptide-repo-core/includes/cpt/class-pr-core-peptide-cpt.php — Peptide CPT.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Peptide_Linker {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject peptide hyperlinks into HTML content.
|
||||||
|
*
|
||||||
|
* Every occurrence of a known peptide name is wrapped in a link to its
|
||||||
|
* database page, except when the text is already inside an <a> tag.
|
||||||
|
*
|
||||||
|
* @param string $html Article HTML content.
|
||||||
|
* @return string HTML with peptide names hyperlinked.
|
||||||
|
*/
|
||||||
|
public static function inject_links( string $html ): string {
|
||||||
|
if ( ! post_type_exists( 'pr_peptide' ) ) {
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
$link_map = self::build_link_map();
|
||||||
|
if ( empty( $link_map ) ) {
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 );
|
||||||
|
} );
|
||||||
|
|
||||||
|
$escaped = array_map( static function ( string $name ): string {
|
||||||
|
return preg_quote( $name, '/' );
|
||||||
|
}, $names );
|
||||||
|
|
||||||
|
// Word-boundary match, case-insensitive.
|
||||||
|
$pattern = '/\b(' . implode( '|', $escaped ) . ')\b/iu';
|
||||||
|
|
||||||
|
// Split HTML into segments: inside <a>…</a> tags vs. outside.
|
||||||
|
// Only replace in segments that are NOT inside an anchor tag.
|
||||||
|
return self::replace_outside_links( $html, $pattern, $link_map );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a map of peptide display names → permalink URLs.
|
||||||
|
*
|
||||||
|
* Includes the canonical title and common aliases (e.g., "BPC-157" and
|
||||||
|
* "BPC 157") so both hyphenated and space-separated forms get linked.
|
||||||
|
*
|
||||||
|
* @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',
|
||||||
|
] );
|
||||||
|
|
||||||
|
$map = [];
|
||||||
|
foreach ( $peptides as $peptide_id ) {
|
||||||
|
$title = get_the_title( $peptide_id );
|
||||||
|
$url = get_permalink( $peptide_id );
|
||||||
|
if ( ! $title || ! $url ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canonical name (e.g., "BPC-157").
|
||||||
|
$map[ $title ] = $url;
|
||||||
|
|
||||||
|
// 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 );
|
||||||
|
$map[ $space_variant ] = $url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace peptide names with links, but only outside existing <a> tags.
|
||||||
|
*
|
||||||
|
* Splits HTML into chunks by <a …>…</a> boundaries. Replacement only
|
||||||
|
* runs on chunks that are outside anchors, preventing nested <a> tags.
|
||||||
|
*
|
||||||
|
* @param string $html Full HTML content.
|
||||||
|
* @param string $pattern Regex pattern matching peptide names.
|
||||||
|
* @param array<string,string> $link_map Name → URL map (case-preserved keys).
|
||||||
|
* @return string HTML with replacements applied.
|
||||||
|
*/
|
||||||
|
private static function replace_outside_links( string $html, string $pattern, array $link_map ): string {
|
||||||
|
// Split on anchor tags, keeping the delimiters.
|
||||||
|
// This regex captures full <a …>…</a> blocks as array elements.
|
||||||
|
$parts = preg_split( '/(<a\s[^>]*>.*?<\/a>)/is', $html, -1, PREG_SPLIT_DELIM_CAPTURE );
|
||||||
|
|
||||||
|
if ( false === $parts ) {
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a lowercase → URL lookup for case-insensitive matching.
|
||||||
|
$lower_map = [];
|
||||||
|
foreach ( $link_map as $name => $url ) {
|
||||||
|
$lower_map[ mb_strtolower( $name ) ] = $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = '';
|
||||||
|
foreach ( $parts as $part ) {
|
||||||
|
// If this chunk is an anchor tag, pass it through untouched.
|
||||||
|
if ( preg_match( '/^<a\s/i', $part ) ) {
|
||||||
|
$result .= $part;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace peptide names with hyperlinks.
|
||||||
|
$result .= preg_replace_callback(
|
||||||
|
$pattern,
|
||||||
|
static function ( array $matches ) use ( $lower_map ): string {
|
||||||
|
$matched_text = $matches[0];
|
||||||
|
$key = mb_strtolower( $matched_text );
|
||||||
|
$url = $lower_map[ $key ] ?? '';
|
||||||
|
if ( '' === $url ) {
|
||||||
|
return $matched_text;
|
||||||
|
}
|
||||||
|
return '<a href="' . esc_url( $url ) . '">' . esc_html( $matched_text ) . '</a>';
|
||||||
|
},
|
||||||
|
$part
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,41 +2,37 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes the full content generation pipeline.
|
* Executes the content generation pipeline using a chained-job architecture.
|
||||||
*
|
*
|
||||||
* Orchestrates: Budget check → Collect source data → Analyze patterns →
|
* Orchestrates: Budget check → Collect source data → Analyze patterns →
|
||||||
* Score ideas → Generate content → Editorial review → Publish.
|
* Score ideas → Generate article 1 in-process → Queue articles 2..N as
|
||||||
|
* individual WP-Cron events so each gets its own PHP execution time budget.
|
||||||
*
|
*
|
||||||
* Triggered by: PRAutoBlogger::on_daily_generation() and PRAutoBlogger::on_ajax_generate_now().
|
* Why chained jobs: Hostinger kills PHP processes at ~120s. A single article
|
||||||
* Dependencies: All core content classes (Collector, Analyzer, Scorer, Generator, Editor, Publisher).
|
* (LLM generation + editorial + 2 images) takes ~60-90s, so one-per-process
|
||||||
|
* is safe. Running N articles sequentially in one process times out at N≥2.
|
||||||
*
|
*
|
||||||
* @see class-prautoblogger.php — Instantiates this class and calls run().
|
* Triggered by: PRAutoBlogger_Executor (daily cron + manual "Generate Now").
|
||||||
* @see core/class-cost-tracker.php — Tracks cumulative API costs throughout the run.
|
* Dependencies: Core content classes, Article_Worker, WP-Cron for chaining.
|
||||||
* @see ARCHITECTURE.md — Data flow diagram showing pipeline steps.
|
*
|
||||||
|
* @see class-executor.php — Cron/AJAX handlers that call us.
|
||||||
|
* @see core/class-article-worker.php — Single-article generation worker.
|
||||||
|
* @see core/class-cost-tracker.php — Tracks costs throughout the run.
|
||||||
|
* @see ARCHITECTURE.md — Data flow diagram.
|
||||||
*/
|
*/
|
||||||
class PRAutoBlogger_Pipeline_Runner {
|
class PRAutoBlogger_Pipeline_Runner {
|
||||||
|
|
||||||
/**
|
/** Option key for the article generation queue. */
|
||||||
* Execute the full content generation pipeline.
|
private const QUEUE_KEY = 'prautoblogger_article_queue';
|
||||||
*
|
|
||||||
* Steps: Budget check → Collect source data → Analyze patterns →
|
/** WP-Cron action for chained article generation. */
|
||||||
* Score ideas → Generate content → Editorial review → Publish.
|
public const CRON_ACTION = 'prautoblogger_generate_queued_article';
|
||||||
*
|
|
||||||
* Side effects: API calls (OpenRouter, Reddit), database writes, post creation.
|
/** @var bool Whether to skip duplicate-topic detection. */
|
||||||
*
|
|
||||||
* @return array{generated: int, published: int, rejected: int, cost: float} Pipeline results.
|
|
||||||
*
|
|
||||||
* @throws \RuntimeException If budget is exceeded or critical step fails.
|
|
||||||
*/
|
|
||||||
/** @var bool Whether to skip duplicate-topic detection (used for manual runs). */
|
|
||||||
private bool $skip_dedup = false;
|
private bool $skip_dedup = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allow manual runs to skip the has_similar_post() check so "Generate Now"
|
|
||||||
* always produces content even when a related article was recently published.
|
|
||||||
*
|
|
||||||
* @param bool $skip True to skip deduplication.
|
* @param bool $skip True to skip deduplication.
|
||||||
*
|
|
||||||
* @return $this
|
* @return $this
|
||||||
*/
|
*/
|
||||||
public function set_skip_dedup( bool $skip ): self {
|
public function set_skip_dedup( bool $skip ): self {
|
||||||
@@ -44,104 +40,245 @@ class PRAutoBlogger_Pipeline_Runner {
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run orchestration and generate the first article in-process.
|
||||||
|
*
|
||||||
|
* Steps: Budget check → Collect → Analyze → Score → Generate article 1.
|
||||||
|
* Remaining articles are queued as chained WP-Cron events.
|
||||||
|
*
|
||||||
|
* Side effects: API calls, DB writes, post creation, cron scheduling.
|
||||||
|
*
|
||||||
|
* @return array{generated: int, published: int, rejected: int, cost: float}
|
||||||
|
* @throws \RuntimeException If budget exceeded.
|
||||||
|
*/
|
||||||
public function run(): array {
|
public function run(): array {
|
||||||
$cost_tracker = new PRAutoBlogger_Cost_Tracker();
|
$cost_tracker = new PRAutoBlogger_Cost_Tracker();
|
||||||
|
|
||||||
// Tag all log entries in this run with a unique ID so link_generation_logs()
|
|
||||||
// can accurately attribute costs to the correct post in batch runs.
|
|
||||||
$cost_tracker->set_run_id( wp_generate_uuid4() );
|
$cost_tracker->set_run_id( wp_generate_uuid4() );
|
||||||
|
|
||||||
// Hard stop if monthly budget is exceeded.
|
|
||||||
if ( $cost_tracker->is_budget_exceeded() ) {
|
if ( $cost_tracker->is_budget_exceeded() ) {
|
||||||
throw new \RuntimeException(
|
throw new \RuntimeException(
|
||||||
__( 'Monthly API budget exceeded. Generation halted.', 'prautoblogger' )
|
__( 'Monthly API budget exceeded. Generation halted.', 'prautoblogger' )
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$target_count = absint( get_option( 'prautoblogger_daily_article_target', 1 ) );
|
$ideas = $this->orchestrate( $cost_tracker );
|
||||||
|
|
||||||
// Step 1: Collect source data.
|
|
||||||
$collector = new PRAutoBlogger_Source_Collector();
|
|
||||||
$collector->collect_from_all_sources();
|
|
||||||
|
|
||||||
// Step 2: Analyze collected data for patterns.
|
|
||||||
// Pass a generous target so the LLM returns enough diverse ideas for
|
|
||||||
// scoring to pick from — ask for 2× the daily target so dedup has room.
|
|
||||||
$llm = new PRAutoBlogger_OpenRouter_Provider();
|
|
||||||
$analyzer = new PRAutoBlogger_Content_Analyzer( $llm, $cost_tracker );
|
|
||||||
$analysis = $analyzer->analyze_recent_data( max( $target_count * 2, 6 ) );
|
|
||||||
|
|
||||||
// Step 3: Score and deduplicate ideas.
|
|
||||||
$scorer = new PRAutoBlogger_Idea_Scorer();
|
|
||||||
$scorer->set_skip_dedup( $this->skip_dedup );
|
|
||||||
$ideas = $scorer->score_and_rank( $analysis, $target_count );
|
|
||||||
|
|
||||||
if ( empty( $ideas ) ) {
|
if ( empty( $ideas ) ) {
|
||||||
PRAutoBlogger_Logger::instance()->info( 'No viable article ideas found. Skipping generation.', 'pipeline' );
|
|
||||||
return [ 'generated' => 0, 'published' => 0, 'rejected' => 0, 'cost' => 0.0 ];
|
return [ 'generated' => 0, 'published' => 0, 'rejected' => 0, 'cost' => 0.0 ];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4 & 5: Generate content and run through editor for each idea.
|
// Generate article 1 in this process — always fits within time limit.
|
||||||
$generator = new PRAutoBlogger_Content_Generator( $llm, $cost_tracker );
|
$first_idea = array_shift( $ideas );
|
||||||
$editor = new PRAutoBlogger_Chief_Editor( $llm, $cost_tracker );
|
$worker = new PRAutoBlogger_Article_Worker( $cost_tracker );
|
||||||
$publisher = new PRAutoBlogger_Publisher();
|
$result = $worker->generate( $first_idea );
|
||||||
$auto_publish = in_array( get_option( 'prautoblogger_auto_publish', '0' ), [ '1', 'yes' ], true );
|
|
||||||
|
|
||||||
$generated = 0;
|
// Queue remaining articles as chained cron events.
|
||||||
$published = 0;
|
if ( ! empty( $ideas ) ) {
|
||||||
$rejected = 0;
|
$this->queue_remaining( $ideas, $cost_tracker->get_run_id(), $result );
|
||||||
|
} else {
|
||||||
foreach ( $ideas as $idea ) {
|
// Single-article run — amortize research costs now (1 article = full cost).
|
||||||
// Re-check budget before each article — stop early if we hit the limit.
|
PRAutoBlogger_Post_Assembler::amortize_research_costs( $cost_tracker->get_run_id() );
|
||||||
if ( $cost_tracker->is_budget_exceeded() ) {
|
$this->log_summary( $result );
|
||||||
PRAutoBlogger_Logger::instance()->warning( 'Budget limit reached during generation. Stopping early.', 'pipeline' );
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$content = $generator->generate( $idea );
|
|
||||||
$generated++;
|
|
||||||
|
|
||||||
$review = $editor->review( $content, $idea );
|
|
||||||
|
|
||||||
if ( 'approved' === $review->get_verdict() || 'revised' === $review->get_verdict() ) {
|
|
||||||
// Null-coalesce: if editor returns 'revised' verdict but
|
|
||||||
// get_revised_content() is null (e.g. malformed LLM response),
|
|
||||||
// fall back to the original generated content.
|
|
||||||
$final_content = 'revised' === $review->get_verdict()
|
|
||||||
? ( $review->get_revised_content() ?? $content )
|
|
||||||
: $content;
|
|
||||||
|
|
||||||
if ( $auto_publish ) {
|
|
||||||
$publisher->publish( $final_content, $idea, $review, $cost_tracker->get_run_id() );
|
|
||||||
$published++;
|
|
||||||
} else {
|
|
||||||
// Auto-publish disabled: send to review queue as draft.
|
|
||||||
$publisher->save_as_draft( $final_content, $idea, $review, $cost_tracker->get_run_id() );
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$publisher->save_as_draft( $content, $idea, $review, $cost_tracker->get_run_id() );
|
|
||||||
$rejected++;
|
|
||||||
PRAutoBlogger_Logger::instance()->info( 'Article rejected by editor: ' . $idea->get_topic(), 'pipeline' );
|
|
||||||
}
|
|
||||||
} catch ( \Exception $e ) {
|
|
||||||
PRAutoBlogger_Logger::instance()->error( 'Failed to generate article for topic "' . $idea->get_topic() . '": ' . $e->getMessage(), 'pipeline' );
|
|
||||||
// Continue with next idea — don't let one failure stop the batch.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$total_cost = $cost_tracker->get_current_run_cost();
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
PRAutoBlogger_Logger::instance()->info(
|
/**
|
||||||
sprintf( 'Pipeline complete: %d generated, %d published, %d rejected. Cost: $%.4f', $generated, $published, $rejected, $total_cost ),
|
* Process the next article from the persistent queue.
|
||||||
'pipeline'
|
*
|
||||||
|
* Called by WP-Cron for articles 2, 3, …N. Pops one idea, generates
|
||||||
|
* it, updates running totals, and chains the next job or finalizes.
|
||||||
|
*
|
||||||
|
* Side effects: API calls, DB writes, post creation, cron scheduling.
|
||||||
|
*/
|
||||||
|
public function process_next_queued_article(): void {
|
||||||
|
$queue = get_option( self::QUEUE_KEY );
|
||||||
|
if ( ! is_array( $queue ) || empty( $queue['ideas'] ) ) {
|
||||||
|
$this->finish_queue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$idea_data = array_shift( $queue['ideas'] );
|
||||||
|
$idea = new PRAutoBlogger_Article_Idea( $idea_data );
|
||||||
|
$cost_tracker = new PRAutoBlogger_Cost_Tracker();
|
||||||
|
$cost_tracker->set_run_id( $queue['run_id'] );
|
||||||
|
|
||||||
|
if ( $cost_tracker->is_budget_exceeded() ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->warning(
|
||||||
|
'Budget limit reached. Aborting remaining queued articles.',
|
||||||
|
'pipeline'
|
||||||
|
);
|
||||||
|
$this->finish_queue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist the consumed queue BEFORE generating so the status poller
|
||||||
|
// cannot re-schedule a cron event for the same idea. Without this,
|
||||||
|
// the DB still holds the old queue during the ~90s generation window,
|
||||||
|
// and the poller's orphan-recovery logic duplicates the last article.
|
||||||
|
if ( ! empty( $queue['ideas'] ) ) {
|
||||||
|
update_option( self::QUEUE_KEY, $queue, false );
|
||||||
|
} else {
|
||||||
|
delete_option( self::QUEUE_KEY );
|
||||||
|
}
|
||||||
|
|
||||||
|
$worker = new PRAutoBlogger_Article_Worker( $cost_tracker );
|
||||||
|
$article_result = $worker->generate( $idea );
|
||||||
|
|
||||||
|
// Merge into running totals.
|
||||||
|
$queue['results']['generated'] += $article_result['generated'];
|
||||||
|
$queue['results']['published'] += $article_result['published'];
|
||||||
|
$queue['results']['rejected'] += $article_result['rejected'];
|
||||||
|
$queue['results']['cost'] += $article_result['cost'];
|
||||||
|
|
||||||
|
if ( ! empty( $queue['ideas'] ) ) {
|
||||||
|
update_option( self::QUEUE_KEY, $queue, false );
|
||||||
|
$this->update_queue_status( $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'] );
|
||||||
|
delete_option( self::QUEUE_KEY );
|
||||||
|
PRAutoBlogger_Generation_Lock::release();
|
||||||
|
$this->log_summary( $queue['results'] );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect → Analyze → Score. Returns ranked ideas.
|
||||||
|
*
|
||||||
|
* @param PRAutoBlogger_Cost_Tracker $cost_tracker
|
||||||
|
* @return PRAutoBlogger_Article_Idea[]
|
||||||
|
*/
|
||||||
|
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 );
|
||||||
|
$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 ) );
|
||||||
|
( new PRAutoBlogger_Source_Collector() )
|
||||||
|
->set_cost_tracker( $cost_tracker )
|
||||||
|
->collect_from_all_sources();
|
||||||
|
|
||||||
|
$this->broadcast_stage( __( 'Analyzing topics and scoring…', 'prautoblogger' ) );
|
||||||
|
$llm = new PRAutoBlogger_OpenRouter_Provider();
|
||||||
|
$analyzer = new PRAutoBlogger_Content_Analyzer( $llm, $cost_tracker );
|
||||||
|
$analysis = $analyzer->analyze_recent_data( max( $target * 2, 6 ) );
|
||||||
|
|
||||||
|
$this->broadcast_stage( __( 'Selecting best topic…', 'prautoblogger' ) );
|
||||||
|
$scorer = new PRAutoBlogger_Idea_Scorer();
|
||||||
|
$scorer->set_skip_dedup( $this->skip_dedup );
|
||||||
|
$ideas = $scorer->score_and_rank( $analysis, $target );
|
||||||
|
|
||||||
|
if ( empty( $ideas ) ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
|
'No viable article ideas found. Skipping generation.',
|
||||||
|
'pipeline'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ideas;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store remaining ideas and schedule the next chained job.
|
||||||
|
*
|
||||||
|
* @param PRAutoBlogger_Article_Idea[] $ideas Remaining ideas (2..N).
|
||||||
|
* @param string $run_id Pipeline run UUID.
|
||||||
|
* @param array $first Results from article 1.
|
||||||
|
*/
|
||||||
|
private function queue_remaining( array $ideas, string $run_id, array $first ): void {
|
||||||
|
$serialized = array_map(
|
||||||
|
static fn( PRAutoBlogger_Article_Idea $i ) => $i->to_array(),
|
||||||
|
$ideas
|
||||||
);
|
);
|
||||||
|
|
||||||
return [
|
$queue = [
|
||||||
'generated' => $generated,
|
'run_id' => $run_id,
|
||||||
'published' => $published,
|
'ideas' => $serialized,
|
||||||
'rejected' => $rejected,
|
'results' => $first,
|
||||||
'cost' => $total_cost,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
update_option( self::QUEUE_KEY, $queue, false );
|
||||||
|
$this->update_queue_status( $queue );
|
||||||
|
$this->schedule_next();
|
||||||
|
|
||||||
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
|
sprintf( 'Article 1 done. Queued %d more as chained jobs.', count( $serialized ) ),
|
||||||
|
'pipeline'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schedule the next chained cron event and fire it immediately. */
|
||||||
|
private function schedule_next(): void {
|
||||||
|
if ( ! wp_next_scheduled( self::CRON_ACTION ) ) {
|
||||||
|
wp_schedule_single_event( time(), self::CRON_ACTION );
|
||||||
|
}
|
||||||
|
spawn_cron();
|
||||||
|
wp_remote_post(
|
||||||
|
site_url( 'wp-cron.php?doing_wp_cron=' . sprintf( '%.22F', microtime( true ) ) ),
|
||||||
|
[ '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'] );
|
||||||
|
}
|
||||||
|
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 );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
262
includes/core/class-post-assembler.php
Normal file
262
includes/core/class-post-assembler.php
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-creation assembly helpers: taxonomy, log linking, images, content sanitization.
|
||||||
|
*
|
||||||
|
* What: After wp_insert_post, assigns categories/tags, links generation logs,
|
||||||
|
* generates and attaches images, and sanitizes raw LLM content.
|
||||||
|
* Who calls it: PRAutoBlogger_Publisher::create_post().
|
||||||
|
* Dependencies: WordPress taxonomy/meta APIs, PRAutoBlogger_Image_Pipeline, PRAutoBlogger_Logger.
|
||||||
|
*
|
||||||
|
* @see core/class-publisher.php — Orchestrates post creation and calls these helpers.
|
||||||
|
* @see core/class-image-pipeline.php — Image generation for attach_generated_images.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Post_Assembler {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign category and tags to a generated post.
|
||||||
|
*
|
||||||
|
* @param int $post_id Post ID.
|
||||||
|
* @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 = [
|
||||||
|
'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' );
|
||||||
|
|
||||||
|
if ( ! $category ) {
|
||||||
|
$result = wp_insert_term( $category_name, 'category' );
|
||||||
|
if ( ! is_wp_error( $result ) ) {
|
||||||
|
wp_set_post_categories( $post_id, [ $result['term_id'] ] );
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
wp_set_post_categories( $post_id, [ $category->term_id ] );
|
||||||
|
}
|
||||||
|
|
||||||
|
$keywords = $idea->get_target_keywords();
|
||||||
|
if ( ! empty( $keywords ) ) {
|
||||||
|
wp_set_post_tags( $post_id, $keywords, true );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link generation log entries to the newly created post.
|
||||||
|
* Uses run_id for precise linking; falls back to timestamp for legacy data.
|
||||||
|
*
|
||||||
|
* @param int $post_id Post ID.
|
||||||
|
* @param string|null $run_id Pipeline run identifier, or null for legacy fallback.
|
||||||
|
*/
|
||||||
|
public static function link_generation_logs( int $post_id, ?string $run_id = null ): void {
|
||||||
|
global $wpdb;
|
||||||
|
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||||
|
|
||||||
|
if ( null !== $run_id && '' !== $run_id ) {
|
||||||
|
// Exclude 'llm_research' stage — those shared costs are amortized
|
||||||
|
// across all articles after the full pipeline completes, not linked
|
||||||
|
// to a single post. See amortize_research_costs().
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
$wpdb->query(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"UPDATE {$table} SET post_id = %d WHERE post_id IS NULL AND run_id = %s AND stage != 'llm_research'",
|
||||||
|
$post_id,
|
||||||
|
$run_id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Legacy fallback: timestamp-based linking for pre-migration log entries.
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
$wpdb->query(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"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 )
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Amortize shared LLM research costs across all articles in a pipeline run.
|
||||||
|
*
|
||||||
|
* The research LLM call runs once per pipeline execution before any articles
|
||||||
|
* are generated. Its cost is logged with the run_id but no post_id. After all
|
||||||
|
* articles finish, this method divides the research cost evenly and inserts
|
||||||
|
* one 'llm_research' row per article so the cost breakdown popover includes
|
||||||
|
* the amortized research overhead.
|
||||||
|
*
|
||||||
|
* Side effects: database SELECT, INSERT, DELETE on generation_log.
|
||||||
|
*
|
||||||
|
* @param string|null $run_id Pipeline run UUID. Null or empty is a no-op.
|
||||||
|
*/
|
||||||
|
public static function amortize_research_costs( ?string $run_id ): void {
|
||||||
|
if ( null === $run_id || '' === $run_id ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||||
|
|
||||||
|
// Find the unlinked research cost row for this run.
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
$research_row = $wpdb->get_row(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"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
|
||||||
|
LIMIT 1",
|
||||||
|
$run_id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( ! $research_row || (float) $research_row->estimated_cost <= 0.0 ) {
|
||||||
|
return; // No research cost to amortize (LLM research not enabled or free).
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count distinct articles produced in this run.
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
$post_ids = $wpdb->get_col(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"SELECT DISTINCT post_id FROM {$table} WHERE run_id = %s AND post_id IS NOT NULL",
|
||||||
|
$run_id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$post_count = count( $post_ids );
|
||||||
|
if ( $post_count < 1 ) {
|
||||||
|
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 );
|
||||||
|
|
||||||
|
// 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' ),
|
||||||
|
] );
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 ] );
|
||||||
|
|
||||||
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
|
sprintf(
|
||||||
|
'Amortized LLM research cost $%.4f across %d articles ($%.4f each).',
|
||||||
|
$total_cost,
|
||||||
|
$post_count,
|
||||||
|
$amortized_cost
|
||||||
|
),
|
||||||
|
'cost-tracker'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip common LLM output artifacts from generated content.
|
||||||
|
* Removes markdown fences, HTML document wrappers, stray head-level tags.
|
||||||
|
*
|
||||||
|
* @param string $content Raw LLM output.
|
||||||
|
* @return string Cleaned HTML suitable for post_content.
|
||||||
|
*/
|
||||||
|
public static function sanitize_llm_content( string $content ): string {
|
||||||
|
$content = trim( $content );
|
||||||
|
|
||||||
|
// Strip markdown code fences: ```html ... ``` or ``` ... ```
|
||||||
|
if ( preg_match( '/^```(?:\w+)?\s*\n(.*?)```\s*$/s', $content, $matches ) ) {
|
||||||
|
$content = trim( $matches[1] );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip full HTML document wrappers — extract only <body> inner content.
|
||||||
|
if ( preg_match( '/<body[^>]*>(.*)<\/body>/is', $content, $matches ) ) {
|
||||||
|
$content = trim( $matches[1] );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove stray document-level tags.
|
||||||
|
$content = preg_replace( '/<\/?(html|head|body|meta|title|!DOCTYPE)[^>]*>/i', '', $content );
|
||||||
|
$content = preg_replace( '/<link\s[^>]*>/i', '', $content );
|
||||||
|
$content = preg_replace( '/<style[^>]*>.*?<\/style>/is', '', $content );
|
||||||
|
$content = preg_replace( '/\n{3,}/', "\n\n", $content );
|
||||||
|
|
||||||
|
return trim( $content );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default author ID for generated posts.
|
||||||
|
*
|
||||||
|
* @return int WordPress user ID.
|
||||||
|
*/
|
||||||
|
public static function get_default_author_id(): int {
|
||||||
|
$author_id = absint( get_option( 'prautoblogger_default_author', 0 ) );
|
||||||
|
if ( $author_id > 0 ) {
|
||||||
|
return $author_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$admins = get_users( [ 'role' => 'administrator', 'number' => 1, 'fields' => 'ID' ] );
|
||||||
|
return ! empty( $admins ) ? (int) $admins[0] : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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).
|
||||||
|
*/
|
||||||
|
public static function attach_generated_images( int $post_id, PRAutoBlogger_Article_Idea $idea, array $post_data ): 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.
|
||||||
|
$source_data = ( new PRAutoBlogger_Source_Collector() )->get_source_data_for_image( $idea->get_source_ids() );
|
||||||
|
|
||||||
|
try {
|
||||||
|
// The pipeline now sets featured image and Image B meta
|
||||||
|
// 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 );
|
||||||
|
|
||||||
|
if ( ! empty( $result['errors'] ) ) {
|
||||||
|
foreach ( $result['errors'] as $error ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->warning( 'Image warning for post ' . $post_id . ': ' . $error, 'publisher' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( $result['cost_usd'] > 0.0 ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
|
sprintf( 'Image generation cost: $%.4f for post %d', $result['cost_usd'], $post_id ),
|
||||||
|
'publisher'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch ( \Throwable $e ) {
|
||||||
|
// Catch \Throwable (not just \Exception) so PHP 8 TypeError/Error
|
||||||
|
// doesn't kill the process silently without any log entry.
|
||||||
|
PRAutoBlogger_Logger::instance()->error(
|
||||||
|
sprintf( 'Image pipeline %s for post %d: %s', get_class( $e ), $post_id, $e->getMessage() ),
|
||||||
|
'publisher'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,9 +7,12 @@ declare(strict_types=1);
|
|||||||
* Handles both publishing (editor-approved) and saving as draft (editor-rejected).
|
* Handles both publishing (editor-approved) and saving as draft (editor-rejected).
|
||||||
* Stores all generation metadata as post_meta for transparency and auditability.
|
* Stores all generation metadata as post_meta for transparency and auditability.
|
||||||
*
|
*
|
||||||
* Triggered by: PRAutoBlogger::run_generation_pipeline() (step 6).
|
* Triggered by: PRAutoBlogger_Pipeline_Runner (step 6).
|
||||||
* Dependencies: WordPress wp_insert_post(), post meta API.
|
* Dependencies: PRAutoBlogger_Post_Assembler (taxonomy, images, sanitization),
|
||||||
|
* WordPress wp_insert_post(), post meta API.
|
||||||
*
|
*
|
||||||
|
* @see core/class-post-assembler.php — Post-creation helpers (taxonomy, images, logs).
|
||||||
|
* @see core/class-peptide-linker.php — Injects peptide database hyperlinks into content.
|
||||||
* @see core/class-chief-editor.php — Produces the editorial review we consume.
|
* @see core/class-chief-editor.php — Produces the editorial review we consume.
|
||||||
* @see core/class-content-generator.php — Produces the content we publish.
|
* @see core/class-content-generator.php — Produces the content we publish.
|
||||||
* @see ARCHITECTURE.md — Data flow step 6.
|
* @see ARCHITECTURE.md — Data flow step 6.
|
||||||
@@ -19,18 +22,11 @@ class PRAutoBlogger_Publisher {
|
|||||||
/**
|
/**
|
||||||
* Publish an editor-approved article.
|
* Publish an editor-approved article.
|
||||||
*
|
*
|
||||||
* Creates a WordPress post with 'publish' status and stores all generation
|
|
||||||
* metadata as post_meta.
|
|
||||||
*
|
|
||||||
* Side effects: Creates a WordPress post, writes post_meta.
|
|
||||||
*
|
|
||||||
* @param string $content The final HTML content.
|
* @param string $content The final HTML content.
|
||||||
* @param PRAutoBlogger_Article_Idea $idea The original article idea.
|
* @param PRAutoBlogger_Article_Idea $idea The original article idea.
|
||||||
* @param PRAutoBlogger_Editorial_Review $review The editor's review.
|
* @param PRAutoBlogger_Editorial_Review $review The editor's review.
|
||||||
* @param string|null $run_id Pipeline run ID for log linking.
|
* @param string|null $run_id Pipeline run ID for log linking.
|
||||||
*
|
|
||||||
* @return int The created post ID.
|
* @return int The created post ID.
|
||||||
*
|
|
||||||
* @throws \RuntimeException If post creation fails.
|
* @throws \RuntimeException If post creation fails.
|
||||||
*/
|
*/
|
||||||
public function publish(
|
public function publish(
|
||||||
@@ -49,9 +45,7 @@ class PRAutoBlogger_Publisher {
|
|||||||
* @param PRAutoBlogger_Article_Idea $idea The original article idea.
|
* @param PRAutoBlogger_Article_Idea $idea The original article idea.
|
||||||
* @param PRAutoBlogger_Editorial_Review $review The editor's review with rejection notes.
|
* @param PRAutoBlogger_Editorial_Review $review The editor's review with rejection notes.
|
||||||
* @param string|null $run_id Pipeline run ID for log linking.
|
* @param string|null $run_id Pipeline run ID for log linking.
|
||||||
*
|
|
||||||
* @return int The created post ID.
|
* @return int The created post ID.
|
||||||
*
|
|
||||||
* @throws \RuntimeException If post creation fails.
|
* @throws \RuntimeException If post creation fails.
|
||||||
*/
|
*/
|
||||||
public function save_as_draft(
|
public function save_as_draft(
|
||||||
@@ -71,9 +65,7 @@ class PRAutoBlogger_Publisher {
|
|||||||
* @param PRAutoBlogger_Editorial_Review $review Editorial review.
|
* @param PRAutoBlogger_Editorial_Review $review Editorial review.
|
||||||
* @param string $post_status 'publish' or 'draft'.
|
* @param string $post_status 'publish' or 'draft'.
|
||||||
* @param string|null $run_id Pipeline run ID for log linking.
|
* @param string|null $run_id Pipeline run ID for log linking.
|
||||||
*
|
|
||||||
* @return int Post ID.
|
* @return int Post ID.
|
||||||
*
|
|
||||||
* @throws \RuntimeException If wp_insert_post fails.
|
* @throws \RuntimeException If wp_insert_post fails.
|
||||||
*/
|
*/
|
||||||
private function create_post(
|
private function create_post(
|
||||||
@@ -83,47 +75,36 @@ class PRAutoBlogger_Publisher {
|
|||||||
string $post_status,
|
string $post_status,
|
||||||
?string $run_id = null
|
?string $run_id = null
|
||||||
): int {
|
): int {
|
||||||
|
// 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 = [
|
||||||
'post_title' => sanitize_text_field( $idea->get_suggested_title() ),
|
'post_title' => sanitize_text_field( $idea->get_suggested_title() ),
|
||||||
'post_content' => wp_kses_post( $this->sanitize_llm_content( $content ) ),
|
'post_content' => wp_kses_post( $linked_content ),
|
||||||
'post_status' => $post_status,
|
'post_status' => $post_status,
|
||||||
'post_type' => 'post',
|
'post_type' => 'post',
|
||||||
'post_author' => $this->get_default_author_id(),
|
'post_author' => PRAutoBlogger_Post_Assembler::get_default_author_id(),
|
||||||
'meta_input' => $this->build_meta( $idea, $review ),
|
'meta_input' => $this->build_meta( $idea, $review, $run_id ),
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/** @see class-prautoblogger.php — listeners registered in main loader. */
|
||||||
* Fires before an PRAutoBlogger post is created.
|
|
||||||
*
|
|
||||||
* Listeners registered in: class-prautoblogger.php (main loader).
|
|
||||||
*
|
|
||||||
* @param array $post_data Post data array.
|
|
||||||
* @param PRAutoBlogger_Article_Idea $idea The article idea.
|
|
||||||
* @param PRAutoBlogger_Editorial_Review $review The editorial review.
|
|
||||||
*/
|
|
||||||
$post_data = apply_filters( 'prautoblogger_filter_post_data', $post_data, $idea, $review );
|
$post_data = apply_filters( 'prautoblogger_filter_post_data', $post_data, $idea, $review );
|
||||||
|
|
||||||
$post_id = wp_insert_post( $post_data, true );
|
$post_id = wp_insert_post( $post_data, true );
|
||||||
|
|
||||||
if ( is_wp_error( $post_id ) ) {
|
if ( is_wp_error( $post_id ) ) {
|
||||||
throw new \RuntimeException(
|
throw new \RuntimeException(
|
||||||
sprintf(
|
sprintf( __( 'Failed to create post: %s', 'prautoblogger' ), $post_id->get_error_message() )
|
||||||
/* translators: %s: WordPress error message */
|
|
||||||
__( 'Failed to create post: %s', 'prautoblogger' ),
|
|
||||||
$post_id->get_error_message()
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assign categories/tags based on article type and keywords.
|
PRAutoBlogger_Post_Assembler::assign_taxonomy_terms( $post_id, $idea );
|
||||||
$this->assign_taxonomy_terms( $post_id, $idea );
|
PRAutoBlogger_Post_Assembler::link_generation_logs( $post_id, $run_id );
|
||||||
|
|
||||||
// Update generation log entries to reference this post.
|
|
||||||
$this->link_generation_logs( $post_id, $run_id );
|
|
||||||
|
|
||||||
// Generate and attach images if published and images enabled.
|
|
||||||
if ( 'publish' === $post_status ) {
|
if ( 'publish' === $post_status ) {
|
||||||
$this->attach_generated_images( $post_id, $idea, $post_data );
|
PRAutoBlogger_Post_Assembler::attach_generated_images( $post_id, $idea, $post_data );
|
||||||
}
|
}
|
||||||
|
|
||||||
PRAutoBlogger_Logger::instance()->info(
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
@@ -131,14 +112,6 @@ class PRAutoBlogger_Publisher {
|
|||||||
'publisher'
|
'publisher'
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* Fires after an PRAutoBlogger post is successfully created.
|
|
||||||
*
|
|
||||||
* @param int $post_id The new post ID.
|
|
||||||
* @param string $post_status 'publish' or 'draft'.
|
|
||||||
* @param PRAutoBlogger_Article_Idea $idea The article idea.
|
|
||||||
* @param PRAutoBlogger_Editorial_Review $review The editorial review.
|
|
||||||
*/
|
|
||||||
do_action( 'prautoblogger_post_created', $post_id, $post_status, $idea, $review );
|
do_action( 'prautoblogger_post_created', $post_id, $post_status, $idea, $review );
|
||||||
|
|
||||||
return $post_id;
|
return $post_id;
|
||||||
@@ -147,241 +120,39 @@ class PRAutoBlogger_Publisher {
|
|||||||
/**
|
/**
|
||||||
* Build post_meta array for generation metadata.
|
* Build post_meta array for generation metadata.
|
||||||
*
|
*
|
||||||
|
* The `_prautoblogger_run_id` key (added v0.8.1) lets the orphan-research
|
||||||
|
* reaper attribute an orphan `llm_research` row back to its sibling
|
||||||
|
* articles without re-walking the gen_log table. See
|
||||||
|
* core/class-research-reaper.php.
|
||||||
|
*
|
||||||
* @param PRAutoBlogger_Article_Idea $idea
|
* @param PRAutoBlogger_Article_Idea $idea
|
||||||
* @param PRAutoBlogger_Editorial_Review $review
|
* @param PRAutoBlogger_Editorial_Review $review
|
||||||
*
|
* @param string|null $run_id Pipeline run UUID, or null in legacy paths.
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
private function build_meta(
|
private function build_meta(
|
||||||
PRAutoBlogger_Article_Idea $idea,
|
PRAutoBlogger_Article_Idea $idea,
|
||||||
PRAutoBlogger_Editorial_Review $review
|
PRAutoBlogger_Editorial_Review $review,
|
||||||
|
?string $run_id = null
|
||||||
): array {
|
): array {
|
||||||
$pipeline_mode = get_option( 'prautoblogger_writing_pipeline', 'multi_step' );
|
$meta = [
|
||||||
$model = get_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
|
'_prautoblogger_generated' => '1',
|
||||||
|
'_prautoblogger_analysis_id' => $idea->get_analysis_id(),
|
||||||
return [
|
'_prautoblogger_source_ids' => wp_json_encode( $idea->get_source_ids() ),
|
||||||
'_prautoblogger_generated' => '1',
|
'_prautoblogger_model_used' => get_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL ),
|
||||||
'_prautoblogger_analysis_id' => $idea->get_analysis_id(),
|
'_prautoblogger_pipeline_mode' => get_option( 'prautoblogger_writing_pipeline', 'multi_step' ),
|
||||||
'_prautoblogger_source_ids' => wp_json_encode( $idea->get_source_ids() ),
|
'_prautoblogger_editor_verdict' => $review->get_verdict(),
|
||||||
'_prautoblogger_model_used' => $model,
|
'_prautoblogger_editor_notes' => $review->get_notes(),
|
||||||
'_prautoblogger_pipeline_mode' => $pipeline_mode,
|
'_prautoblogger_quality_score' => $review->get_quality_score(),
|
||||||
'_prautoblogger_editor_verdict' => $review->get_verdict(),
|
'_prautoblogger_seo_score' => $review->get_seo_score(),
|
||||||
'_prautoblogger_editor_notes' => $review->get_notes(),
|
'_prautoblogger_generated_at' => gmdate( 'c' ),
|
||||||
'_prautoblogger_quality_score' => $review->get_quality_score(),
|
'_prautoblogger_topic' => $idea->get_topic(),
|
||||||
'_prautoblogger_seo_score' => $review->get_seo_score(),
|
'_prautoblogger_article_type' => $idea->get_article_type(),
|
||||||
'_prautoblogger_generated_at' => gmdate( 'c' ),
|
'_prautoblogger_target_keywords' => wp_json_encode( $idea->get_target_keywords() ),
|
||||||
'_prautoblogger_topic' => $idea->get_topic(),
|
|
||||||
'_prautoblogger_article_type' => $idea->get_article_type(),
|
|
||||||
'_prautoblogger_target_keywords' => wp_json_encode( $idea->get_target_keywords() ),
|
|
||||||
];
|
];
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assign category and tags to a generated post.
|
|
||||||
*
|
|
||||||
* @param int $post_id
|
|
||||||
* @param PRAutoBlogger_Article_Idea $idea
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
private function assign_taxonomy_terms( int $post_id, PRAutoBlogger_Article_Idea $idea ): void {
|
|
||||||
$type_category_map = [
|
|
||||||
'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' );
|
|
||||||
if ( ! $category ) {
|
|
||||||
$result = wp_insert_term( $category_name, 'category' );
|
|
||||||
if ( ! is_wp_error( $result ) ) {
|
|
||||||
wp_set_post_categories( $post_id, [ $result['term_id'] ] );
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
wp_set_post_categories( $post_id, [ $category->term_id ] );
|
|
||||||
}
|
|
||||||
|
|
||||||
$keywords = $idea->get_target_keywords();
|
|
||||||
if ( ! empty( $keywords ) ) {
|
|
||||||
wp_set_post_tags( $post_id, $keywords, true );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Link generation log entries to the newly created post using run_id.
|
|
||||||
*
|
|
||||||
* The previous timestamp-based approach could misattribute costs in batch runs
|
|
||||||
* where multiple articles are generated in the same pipeline execution. Using
|
|
||||||
* run_id ensures each post is linked only to the log entries from its own run.
|
|
||||||
*
|
|
||||||
* Falls back to timestamp-based linking if no run_id is set (e.g. legacy data
|
|
||||||
* from before the run_id migration).
|
|
||||||
*
|
|
||||||
* @param int $post_id The newly created post ID.
|
|
||||||
* @param string|null $run_id Pipeline run identifier, or null for legacy fallback.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
private function link_generation_logs( int $post_id, ?string $run_id = null ): void {
|
|
||||||
global $wpdb;
|
|
||||||
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
|
||||||
|
|
||||||
if ( null !== $run_id && '' !== $run_id ) {
|
if ( null !== $run_id && '' !== $run_id ) {
|
||||||
// Precise linking via run_id — no risk of cross-article misattribution.
|
$meta['_prautoblogger_run_id'] = $run_id;
|
||||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
|
||||||
$wpdb->query(
|
|
||||||
$wpdb->prepare(
|
|
||||||
"UPDATE {$table} SET post_id = %d WHERE post_id IS NULL AND run_id = %s",
|
|
||||||
$post_id,
|
|
||||||
$run_id
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Legacy fallback: timestamp-based linking for pre-migration log entries.
|
|
||||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
|
||||||
$wpdb->query(
|
|
||||||
$wpdb->prepare(
|
|
||||||
"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 )
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Strip common LLM output artifacts from generated content.
|
|
||||||
*
|
|
||||||
* LLMs sometimes ignore format instructions and wrap content in markdown
|
|
||||||
* code fences (```html ... ```) or full HTML document structures
|
|
||||||
* (<html><head><body>). This method strips those wrappers so only clean
|
|
||||||
* article-body HTML reaches WordPress.
|
|
||||||
*
|
|
||||||
* @param string $content Raw LLM output.
|
|
||||||
*
|
|
||||||
* @return string Cleaned HTML suitable for post_content.
|
|
||||||
*/
|
|
||||||
private function sanitize_llm_content( string $content ): string {
|
|
||||||
$content = trim( $content );
|
|
||||||
|
|
||||||
// Strip markdown code fences: ```html ... ``` or ``` ... ```
|
|
||||||
// Handles optional language identifier after opening fence.
|
|
||||||
if ( preg_match( '/^```(?:\w+)?\s*\n(.*?)```\s*$/s', $content, $matches ) ) {
|
|
||||||
$content = trim( $matches[1] );
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strip full HTML document wrappers: <html>...<body>content</body>...</html>
|
|
||||||
// Extract only the <body> inner content.
|
|
||||||
if ( preg_match( '/<body[^>]*>(.*)<\/body>/is', $content, $matches ) ) {
|
|
||||||
$content = trim( $matches[1] );
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove stray <html>, </html>, <head>...</head>, <body>, </body> tags
|
|
||||||
// in case they appear without a proper document structure.
|
|
||||||
$content = preg_replace( '/<\/?(html|head|body|meta|title|!DOCTYPE)[^>]*>/i', '', $content );
|
|
||||||
|
|
||||||
// Remove leftover <link> and <style> tags that belong in <head>, not post content.
|
|
||||||
$content = preg_replace( '/<link\s[^>]*>/i', '', $content );
|
|
||||||
$content = preg_replace( '/<style[^>]*>.*?<\/style>/is', '', $content );
|
|
||||||
|
|
||||||
// Collapse any resulting excess whitespace (multiple blank lines → single).
|
|
||||||
$content = preg_replace( '/\n{3,}/', "\n\n", $content );
|
|
||||||
|
|
||||||
return trim( $content );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the default author ID for generated posts.
|
|
||||||
*
|
|
||||||
* @return int WordPress user ID.
|
|
||||||
*/
|
|
||||||
private function get_default_author_id(): int {
|
|
||||||
$author_id = absint( get_option( 'prautoblogger_default_author', 0 ) );
|
|
||||||
if ( $author_id > 0 ) {
|
|
||||||
return $author_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
$admins = get_users( [
|
|
||||||
'role' => 'administrator',
|
|
||||||
'number' => 1,
|
|
||||||
'fields' => 'ID',
|
|
||||||
] );
|
|
||||||
|
|
||||||
return ! empty( $admins ) ? (int) $admins[0] : 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate and attach images to a published post.
|
|
||||||
*
|
|
||||||
* Calls the image pipeline to generate Image A (featured image) and Image B
|
|
||||||
* (secondary image). Sets _thumbnail_id and _prautoblogger_image_b_id post meta.
|
|
||||||
* Gracefully handles image generation failures — post is already created, so
|
|
||||||
* this is a non-blocking operation.
|
|
||||||
*
|
|
||||||
* @param int $post_id The newly created post ID.
|
|
||||||
* @param PRAutoBlogger_Article_Idea $idea The article idea (contains source IDs).
|
|
||||||
* @param array $post_data The post data array (for article content).
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
private function attach_generated_images( int $post_id, PRAutoBlogger_Article_Idea $idea, array $post_data ): void {
|
|
||||||
// Build source data from idea's source IDs (if available).
|
|
||||||
$source_data = null;
|
|
||||||
$source_ids = $idea->get_source_ids();
|
|
||||||
if ( ! empty( $source_ids ) ) {
|
|
||||||
// Attempt to fetch the original source data for Image B.
|
|
||||||
// For now, we pass null; the image pipeline will handle gracefully.
|
|
||||||
$source_data = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$image_pipeline = new PRAutoBlogger_Image_Pipeline();
|
|
||||||
$result = $image_pipeline->generate_and_attach_images( $post_id, $post_data, $source_data );
|
|
||||||
|
|
||||||
// Set Image A as the featured image.
|
|
||||||
if ( isset( $result['image_a_id'] ) ) {
|
|
||||||
set_post_thumbnail( $post_id, $result['image_a_id'] );
|
|
||||||
PRAutoBlogger_Logger::instance()->info(
|
|
||||||
sprintf( 'Set featured image (attachment %d) for post %d', $result['image_a_id'], $post_id ),
|
|
||||||
'publisher'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store Image B ID in post meta for A/B tracking.
|
|
||||||
if ( isset( $result['image_b_id'] ) ) {
|
|
||||||
update_post_meta( $post_id, '_prautoblogger_image_b_id', $result['image_b_id'] );
|
|
||||||
PRAutoBlogger_Logger::instance()->info(
|
|
||||||
sprintf( 'Stored Image B (attachment %d) in post meta for post %d', $result['image_b_id'], $post_id ),
|
|
||||||
'publisher'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log errors if any occurred (but don't let them block post creation).
|
|
||||||
if ( ! empty( $result['errors'] ) ) {
|
|
||||||
foreach ( $result['errors'] as $error ) {
|
|
||||||
PRAutoBlogger_Logger::instance()->warning(
|
|
||||||
'Image generation warning for post ' . $post_id . ': ' . $error,
|
|
||||||
'publisher'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log total cost.
|
|
||||||
if ( $result['cost_usd'] > 0.0 ) {
|
|
||||||
PRAutoBlogger_Logger::instance()->info(
|
|
||||||
sprintf( 'Image generation cost: $%.4f for post %d', $result['cost_usd'], $post_id ),
|
|
||||||
'publisher'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch ( \Exception $e ) {
|
|
||||||
// Log the error but don't re-throw — post is already created.
|
|
||||||
PRAutoBlogger_Logger::instance()->warning(
|
|
||||||
'Image pipeline exception for post ' . $post_id . ': ' . $e->getMessage(),
|
|
||||||
'publisher'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
return $meta;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
183
includes/core/class-research-reaper.php
Normal file
183
includes/core/class-research-reaper.php
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Daily cron that retroactively amortizes orphan `llm_research` rows.
|
||||||
|
*
|
||||||
|
* When a pipeline run dies between post creation and the final
|
||||||
|
* `Post_Assembler::amortize_research_costs()` call (Hostinger exec kill,
|
||||||
|
* OOM, LiteSpeed cut, fatal), the shared research-cost row stays in
|
||||||
|
* `wp_prautoblogger_generation_log` with `post_id = NULL` and the
|
||||||
|
* articles from that run understate their true cost in the popover.
|
||||||
|
*
|
||||||
|
* This reaper scans for such orphan rows once a day, finds their sibling
|
||||||
|
* posts via the `_prautoblogger_run_id` post_meta (primary) or the
|
||||||
|
* gen_log's own post-linked rows (fallback for legacy posts that
|
||||||
|
* predate the meta), and delegates to the existing idempotent
|
||||||
|
* `amortize_research_costs()` method. Orphans that still have no
|
||||||
|
* siblings after 7 days are deleted outright as sunk cost.
|
||||||
|
*
|
||||||
|
* Spec-wise the CTO asked for the reap method to live on Post_Assembler;
|
||||||
|
* hosting it here instead keeps Post_Assembler under the 300-line cap
|
||||||
|
* (it grew past 300 with the reap method attached) and colocates the
|
||||||
|
* attribution logic next to the cron/CLI plumbing that triggers it.
|
||||||
|
*
|
||||||
|
* Triggered by:
|
||||||
|
* - WP-Cron: `prautoblogger_reap_orphan_research_rows` (daily, 03:15).
|
||||||
|
* - WP-CLI: `wp prautoblogger reap-research` (manual ops trigger).
|
||||||
|
* Dependencies: PRAutoBlogger_Post_Assembler (amortize_research_costs),
|
||||||
|
* PRAutoBlogger_Logger, WordPress $wpdb + get_posts.
|
||||||
|
*
|
||||||
|
* @see core/class-post-assembler.php — amortize_research_costs() does the work.
|
||||||
|
* @see class-activator.php — Schedules the daily cron.
|
||||||
|
* @see class-deactivator.php — Unschedules on deactivate.
|
||||||
|
* @see ARCHITECTURE.md — Cost tracking data flow.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Research_Reaper {
|
||||||
|
|
||||||
|
/** Grace window — live pipelines have this long to finish amortizing before reaping. */
|
||||||
|
private const GRACE_WINDOW_SECONDS = 3600; // 1 hour.
|
||||||
|
|
||||||
|
/** Stale window — orphans with no sibling posts are deleted outright after this long. */
|
||||||
|
private const STALE_WINDOW_SECONDS = 7 * 86400; // 7 days.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron action handler — entrypoint for the daily WP-Cron event.
|
||||||
|
*
|
||||||
|
* Pure delegate. Never throws.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function on_cron(): void {
|
||||||
|
self::reap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan and reap orphan `llm_research` rows. Returns a summary that the
|
||||||
|
* WP-CLI command prints and the cron path ignores.
|
||||||
|
*
|
||||||
|
* Side effects: SELECT / INSERT / DELETE on generation_log; post_meta
|
||||||
|
* reads; one INFO log line per reaped or stale-deleted
|
||||||
|
* orphan; one WARNING on DB error.
|
||||||
|
*
|
||||||
|
* @return array{reaped: int, deleted: int, skipped: int}
|
||||||
|
*/
|
||||||
|
public static function reap(): array {
|
||||||
|
$stats = [ 'reaped' => 0, 'deleted' => 0, 'skipped' => 0 ];
|
||||||
|
|
||||||
|
try {
|
||||||
|
global $wpdb;
|
||||||
|
$log_table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||||
|
$grace = gmdate( 'Y-m-d H:i:s', time() - self::GRACE_WINDOW_SECONDS );
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
$orphans = $wpdb->get_results(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"SELECT id, run_id, created_at FROM {$log_table}
|
||||||
|
WHERE stage = 'llm_research' AND post_id IS NULL AND created_at < %s",
|
||||||
|
$grace
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if ( ! is_array( $orphans ) ) {
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stale_cutoff = time() - self::STALE_WINDOW_SECONDS;
|
||||||
|
foreach ( $orphans as $orphan ) {
|
||||||
|
self::handle_orphan( $orphan, $stale_cutoff, $log_table, $stats );
|
||||||
|
}
|
||||||
|
} catch ( \Throwable $e ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->warning(
|
||||||
|
'Reaper encountered DB error: ' . $e->getMessage(),
|
||||||
|
'cost-tracker'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route one orphan row: amortize if siblings exist, delete if stale,
|
||||||
|
* otherwise skip and try again next run.
|
||||||
|
*
|
||||||
|
* @param object $orphan DB row with id, run_id, created_at.
|
||||||
|
* @param int $stale_cutoff Unix timestamp — orphans older than this delete.
|
||||||
|
* @param string $log_table Fully-qualified gen_log table name.
|
||||||
|
* @param array $stats Counters, mutated by reference.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private static function handle_orphan( object $orphan, int $stale_cutoff, string $log_table, array &$stats ): void {
|
||||||
|
global $wpdb;
|
||||||
|
$run_id = (string) ( $orphan->run_id ?? '' );
|
||||||
|
$created_ts = strtotime( (string) ( $orphan->created_at ?? '' ) );
|
||||||
|
$is_stale = false !== $created_ts && $created_ts < $stale_cutoff;
|
||||||
|
|
||||||
|
if ( '' === $run_id ) {
|
||||||
|
if ( $is_stale ) {
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
$wpdb->delete( $log_table, [ 'id' => (int) $orphan->id ] );
|
||||||
|
$stats['deleted']++;
|
||||||
|
} else {
|
||||||
|
$stats['skipped']++;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$post_ids = self::find_posts_for_run_id( $run_id );
|
||||||
|
if ( count( $post_ids ) >= 1 ) {
|
||||||
|
PRAutoBlogger_Post_Assembler::amortize_research_costs( $run_id );
|
||||||
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
|
sprintf( 'Reaped research cost across %d articles for run %s.', count( $post_ids ), $run_id ),
|
||||||
|
'cost-tracker'
|
||||||
|
);
|
||||||
|
$stats['reaped']++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( $is_stale ) {
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
$wpdb->delete( $log_table, [ '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']++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stats['skipped']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return distinct post IDs for a given run_id. Prefers post_meta;
|
||||||
|
* falls back to gen_log rows for legacy posts.
|
||||||
|
*
|
||||||
|
* @param string $run_id Pipeline run UUID.
|
||||||
|
* @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,
|
||||||
|
] );
|
||||||
|
if ( is_array( $meta_ids ) && ! empty( $meta_ids ) ) {
|
||||||
|
return array_map( 'intval', $meta_ids );
|
||||||
|
}
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$log_table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
$ids = $wpdb->get_col(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"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 ) : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
272
includes/core/class-semantic-dedup.php
Normal file
272
includes/core/class-semantic-dedup.php
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Semantic deduplication using embedding cosine similarity.
|
||||||
|
*
|
||||||
|
* Embeds candidate topics and recent post titles in a single batch API call,
|
||||||
|
* then compares via cosine similarity. Falls back to keyword-overlap matching
|
||||||
|
* if the embedding API is unavailable or fails.
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger_Idea_Scorer::score_and_rank().
|
||||||
|
* Dependencies: OpenRouter_Embedding_Provider, WP_Query.
|
||||||
|
*
|
||||||
|
* @see core/class-idea-scorer.php — Consumer.
|
||||||
|
* @see providers/class-open-router-embedding-provider.php — Embedding API.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Semantic_Dedup {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cosine similarity threshold for "too similar". MiniLM embeddings treat
|
||||||
|
* ~0.7+ as semantically equivalent for short texts. We use 0.72 to avoid
|
||||||
|
* over-aggressive blocking while still catching rephrasings.
|
||||||
|
*/
|
||||||
|
private const SIMILARITY_THRESHOLD = 0.72;
|
||||||
|
|
||||||
|
/** @var float[][] Cached embeddings for recent post titles, keyed by index. */
|
||||||
|
private array $title_embeddings = [];
|
||||||
|
|
||||||
|
/** @var string[] Recent post titles. */
|
||||||
|
private array $recent_titles = [];
|
||||||
|
|
||||||
|
/** @var float[][] Cached embeddings for accepted ideas, accumulated per-batch. */
|
||||||
|
private array $accepted_embeddings = [];
|
||||||
|
|
||||||
|
/** @var bool Whether embeddings are available (false after API failure). */
|
||||||
|
private bool $embeddings_available = true;
|
||||||
|
|
||||||
|
/** @var PRAutoBlogger_OpenRouter_Embedding_Provider|null Reused across calls. */
|
||||||
|
private ?PRAutoBlogger_OpenRouter_Embedding_Provider $provider = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize by fetching recent titles and pre-computing their embeddings.
|
||||||
|
*
|
||||||
|
* Called once at the start of scoring. If the embedding API fails,
|
||||||
|
* sets a flag so all subsequent checks fall back to keyword matching.
|
||||||
|
*
|
||||||
|
* Side effects: HTTP request to OpenRouter embedding API, WP_Query.
|
||||||
|
*/
|
||||||
|
public function initialize(): void {
|
||||||
|
$this->recent_titles = $this->fetch_recent_post_titles();
|
||||||
|
if ( empty( $this->recent_titles ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->provider = new PRAutoBlogger_OpenRouter_Embedding_Provider();
|
||||||
|
$this->title_embeddings = $this->provider->get_embeddings( $this->recent_titles );
|
||||||
|
} catch ( \Throwable $e ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->warning(
|
||||||
|
'Semantic dedup unavailable, falling back to keyword matching: ' . $e->getMessage(),
|
||||||
|
'scorer'
|
||||||
|
);
|
||||||
|
$this->embeddings_available = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a topic is too similar to any already-accepted idea in this batch.
|
||||||
|
*
|
||||||
|
* @param string $topic Candidate topic text.
|
||||||
|
* @param string[] $topic_keywords Pre-extracted keywords (for fallback).
|
||||||
|
* @param array $keyword_sets Accepted keyword sets (for fallback).
|
||||||
|
*
|
||||||
|
* @return bool True if the topic should be skipped.
|
||||||
|
*/
|
||||||
|
public function is_batch_duplicate( string $topic, array $topic_keywords, array $keyword_sets ): bool {
|
||||||
|
if ( ! $this->embeddings_available || empty( $this->accepted_embeddings ) ) {
|
||||||
|
return $this->keyword_overlaps_any( $topic_keywords, $keyword_sets );
|
||||||
|
}
|
||||||
|
|
||||||
|
$embedding = $this->embed_single( $topic );
|
||||||
|
if ( null === $embedding ) {
|
||||||
|
return $this->keyword_overlaps_any( $topic_keywords, $keyword_sets );
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ( $this->accepted_embeddings as $accepted ) {
|
||||||
|
$sim = PRAutoBlogger_OpenRouter_Embedding_Provider::cosine_similarity( $embedding, $accepted );
|
||||||
|
if ( $sim >= self::SIMILARITY_THRESHOLD ) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a topic is too similar to any recently published post.
|
||||||
|
*
|
||||||
|
* @param string $topic Candidate topic text.
|
||||||
|
* @param string[] $topic_keywords Pre-extracted keywords (for fallback).
|
||||||
|
*
|
||||||
|
* @return bool True if a recent post is semantically too similar.
|
||||||
|
*/
|
||||||
|
public function is_recent_duplicate( string $topic, array $topic_keywords ): bool {
|
||||||
|
if ( ! $this->embeddings_available || empty( $this->title_embeddings ) ) {
|
||||||
|
return $this->keyword_matches_recent( $topic_keywords );
|
||||||
|
}
|
||||||
|
|
||||||
|
$embedding = $this->embed_single( $topic );
|
||||||
|
if ( null === $embedding ) {
|
||||||
|
return $this->keyword_matches_recent( $topic_keywords );
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ( $this->title_embeddings as $i => $title_emb ) {
|
||||||
|
$sim = PRAutoBlogger_OpenRouter_Embedding_Provider::cosine_similarity( $embedding, $title_emb );
|
||||||
|
if ( $sim >= self::SIMILARITY_THRESHOLD ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
|
sprintf(
|
||||||
|
'Semantic match (%.2f): "%s" ≈ "%s"',
|
||||||
|
$sim,
|
||||||
|
substr( $topic, 0, 60 ),
|
||||||
|
substr( $this->recent_titles[ $i ] ?? '', 0, 60 )
|
||||||
|
),
|
||||||
|
'scorer'
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record an accepted idea's embedding for intra-batch dedup.
|
||||||
|
*
|
||||||
|
* @param string $topic The accepted topic text.
|
||||||
|
*/
|
||||||
|
public function record_accepted( string $topic ): void {
|
||||||
|
if ( ! $this->embeddings_available ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$embedding = $this->embed_single( $topic );
|
||||||
|
if ( null !== $embedding ) {
|
||||||
|
$this->accepted_embeddings[] = $embedding;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Embed a single text, with error handling.
|
||||||
|
*
|
||||||
|
* @param string $text Text to embed.
|
||||||
|
* @return float[]|null Embedding vector, or null on failure.
|
||||||
|
*/
|
||||||
|
private function embed_single( string $text ): ?array {
|
||||||
|
try {
|
||||||
|
if ( null === $this->provider ) {
|
||||||
|
$this->provider = new PRAutoBlogger_OpenRouter_Embedding_Provider();
|
||||||
|
}
|
||||||
|
$result = $this->provider->get_embeddings( [ $text ] );
|
||||||
|
return $result[0] ?? null;
|
||||||
|
} catch ( \Throwable $e ) {
|
||||||
|
// Degrade gracefully — one failed embed shouldn't kill the pipeline.
|
||||||
|
$this->embeddings_available = false;
|
||||||
|
PRAutoBlogger_Logger::instance()->warning(
|
||||||
|
'Embedding failed, disabling semantic dedup for this run: ' . $e->getMessage(),
|
||||||
|
'scorer'
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyword-overlap fallback for intra-batch dedup.
|
||||||
|
*
|
||||||
|
* @param string[] $keywords Keywords to check.
|
||||||
|
* @param array $keyword_sets List of previously accepted keyword arrays.
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private function keyword_overlaps_any( array $keywords, array $keyword_sets ): bool {
|
||||||
|
if ( count( $keywords ) < 2 ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
foreach ( $keyword_sets as $existing ) {
|
||||||
|
$overlap = count( array_intersect( $keywords, $existing ) );
|
||||||
|
if ( $overlap / count( $keywords ) >= 0.6 ) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyword-overlap fallback for recent-post dedup.
|
||||||
|
*
|
||||||
|
* @param string[] $topic_keywords Pre-extracted keywords.
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private function keyword_matches_recent( array $topic_keywords ): bool {
|
||||||
|
if ( count( $topic_keywords ) < 2 ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
foreach ( $this->recent_titles as $title ) {
|
||||||
|
$title_keywords = $this->extract_meaningful_keywords( $title );
|
||||||
|
$overlap = count( array_intersect( $topic_keywords, $title_keywords ) );
|
||||||
|
if ( $overlap / count( $topic_keywords ) >= 0.6 ) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch titles of generated posts from the last 30 days.
|
||||||
|
*
|
||||||
|
* Widened from 7 days (the old keyword window) because semantic similarity
|
||||||
|
* catches rephrasings that keywords miss — a wider net is safe here.
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
private function fetch_recent_post_titles(): array {
|
||||||
|
$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,
|
||||||
|
] );
|
||||||
|
|
||||||
|
$titles = [];
|
||||||
|
foreach ( $query->posts as $post_id ) {
|
||||||
|
$titles[] = get_the_title( $post_id );
|
||||||
|
}
|
||||||
|
return $titles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract meaningful keywords from text (fallback dedup).
|
||||||
|
*
|
||||||
|
* @param string $text Input text.
|
||||||
|
* @return string[] Unique lowercase keywords.
|
||||||
|
*/
|
||||||
|
private function extract_meaningful_keywords( string $text ): array {
|
||||||
|
static $stopwords = [
|
||||||
|
'a', 'an', 'the', 'and', 'or', 'but', 'is', 'are', 'was', 'were',
|
||||||
|
'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'from', 'as',
|
||||||
|
'it', 'its', 'this', 'that', 'your', 'you', 'how', 'what', 'when',
|
||||||
|
'why', 'where', 'which', 'who', 'do', 'does', 'did', 'not', 'no',
|
||||||
|
'can', 'will', 'should', 'could', 'would', 'may', 'might', 'have',
|
||||||
|
'has', 'had', 'be', 'been', 'being', 'about', 'into', 'through',
|
||||||
|
'during', 'before', 'after', 'above', 'below', 'between', 'same',
|
||||||
|
'up', 'down', 'out', 'off', 'over', 'under', 'again', 'then',
|
||||||
|
'here', 'there', 'all', 'each', 'every', 'both', 'few', 'more',
|
||||||
|
'most', 'other', 'some', 'such', 'than', 'too', 'very', 'just',
|
||||||
|
'also', 'only', 'own', 'so', 'if', 'while', 'because', 'until',
|
||||||
|
'vs', 'versus', 'guide', 'complete', 'ultimate', 'best', 'top',
|
||||||
|
'new', 'first', 'need', 'know', 'everything',
|
||||||
|
];
|
||||||
|
|
||||||
|
$text = strtolower( $text );
|
||||||
|
$words = preg_split( '/[^a-z0-9-]+/', $text, -1, PREG_SPLIT_NO_EMPTY );
|
||||||
|
$words = array_filter( $words, static function ( string $w ) use ( $stopwords ): bool {
|
||||||
|
return strlen( $w ) >= 2 && ! in_array( $w, $stopwords, true );
|
||||||
|
} );
|
||||||
|
|
||||||
|
return array_values( array_unique( $words ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,19 +4,40 @@ declare(strict_types=1);
|
|||||||
/**
|
/**
|
||||||
* Orchestrates data collection from all enabled source providers.
|
* Orchestrates data collection from all enabled source providers.
|
||||||
*
|
*
|
||||||
* Iterates through enabled sources (currently Reddit), calls their
|
* Iterates through enabled sources (Reddit, LLM Research), calls their
|
||||||
* collect_data() method, and stores results in the ab_source_data table.
|
* collect_data() method, and stores results in the ab_source_data table.
|
||||||
* Handles deduplication via UNIQUE index on (source_type, source_id).
|
* Handles deduplication via UNIQUE index on (source_type, source_id).
|
||||||
|
* Re-seen items get their collected_at timestamp refreshed so the
|
||||||
|
* analyzer's 24-hour window picks them up for re-analysis.
|
||||||
*
|
*
|
||||||
* Triggered by: PRAutoBlogger::run_generation_pipeline() (step 1).
|
* Triggered by: PRAutoBlogger::run_generation_pipeline() (step 1).
|
||||||
* Dependencies: Source provider implementations, WordPress $wpdb.
|
* Dependencies: Source provider implementations, WordPress $wpdb.
|
||||||
*
|
*
|
||||||
* @see providers/interface-source-provider.php — Interface providers implement.
|
* @see providers/interface-source-provider.php — Interface providers implement.
|
||||||
* @see providers/class-reddit-provider.php — Primary source provider.
|
* @see providers/class-reddit-provider.php — Reddit source provider.
|
||||||
|
* @see providers/class-llm-research-provider.php — LLM deep research provider.
|
||||||
* @see ARCHITECTURE.md — Data flow step 1.
|
* @see ARCHITECTURE.md — Data flow step 1.
|
||||||
*/
|
*/
|
||||||
class PRAutoBlogger_Source_Collector {
|
class PRAutoBlogger_Source_Collector {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pipeline cost tracker — passed to providers that need run_id tagging.
|
||||||
|
* Null when called outside the pipeline (e.g., manual test collection).
|
||||||
|
*/
|
||||||
|
private ?PRAutoBlogger_Cost_Tracker $cost_tracker = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject the pipeline's cost tracker so source providers can log costs
|
||||||
|
* with the correct run_id. Without this, provider costs are orphaned.
|
||||||
|
*
|
||||||
|
* @param PRAutoBlogger_Cost_Tracker $cost_tracker Pipeline cost tracker with run_id set.
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function set_cost_tracker( PRAutoBlogger_Cost_Tracker $cost_tracker ): self {
|
||||||
|
$this->cost_tracker = $cost_tracker;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collect data from all enabled source providers and store in database.
|
* Collect data from all enabled source providers and store in database.
|
||||||
*
|
*
|
||||||
@@ -25,7 +46,10 @@ class PRAutoBlogger_Source_Collector {
|
|||||||
* @return int Total number of new records inserted.
|
* @return int Total number of new records inserted.
|
||||||
*/
|
*/
|
||||||
public function collect_from_all_sources(): int {
|
public function collect_from_all_sources(): int {
|
||||||
$enabled = json_decode( get_option( 'prautoblogger_enabled_sources', '["reddit"]' ), true );
|
$raw = get_option( 'prautoblogger_enabled_sources', '["reddit"]' );
|
||||||
|
$enabled = json_decode( (string) $raw, true );
|
||||||
|
// Only fall back to reddit if the option is corrupt (non-JSON).
|
||||||
|
// An empty array '[]' is a valid choice — the user disabled all sources.
|
||||||
if ( ! is_array( $enabled ) ) {
|
if ( ! is_array( $enabled ) ) {
|
||||||
$enabled = [ 'reddit' ];
|
$enabled = [ 'reddit' ];
|
||||||
}
|
}
|
||||||
@@ -42,14 +66,15 @@ class PRAutoBlogger_Source_Collector {
|
|||||||
try {
|
try {
|
||||||
$data = $provider->collect_data( $this->get_config_for_source( $source_type ) );
|
$data = $provider->collect_data( $this->get_config_for_source( $source_type ) );
|
||||||
$inserted = $this->store_data( $data );
|
$inserted = $this->store_data( $data );
|
||||||
|
$refreshed = count( $data ) - $inserted;
|
||||||
$total_inserted += $inserted;
|
$total_inserted += $inserted;
|
||||||
|
|
||||||
PRAutoBlogger_Logger::instance()->info(
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
sprintf( 'Collected %d items from %s (%d new).', count( $data ), $source_type, $inserted ),
|
sprintf( 'Collected %d items from %s (%d new, %d refreshed).', count( $data ), $source_type, $inserted, $refreshed ),
|
||||||
'collector'
|
'collector'
|
||||||
);
|
);
|
||||||
} catch ( \Exception $e ) {
|
} catch ( \Throwable $e ) {
|
||||||
PRAutoBlogger_Logger::instance()->error( "Collection from {$source_type} FAILED: " . $e->getMessage(), 'collector' );
|
PRAutoBlogger_Logger::instance()->error( sprintf( 'Collection %s from %s: %s', get_class( $e ), $source_type, $e->getMessage() ), 'collector' );
|
||||||
// Continue with other sources — one failure shouldn't stop everything.
|
// Continue with other sources — one failure shouldn't stop everything.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,16 +82,61 @@ class PRAutoBlogger_Source_Collector {
|
|||||||
return $total_inserted;
|
return $total_inserted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve source data rows by their database IDs.
|
||||||
|
*
|
||||||
|
* Used by the image pipeline to build Image B's source-driven prompt from
|
||||||
|
* the source_ids stored on the Article_Idea. Returns the first matching
|
||||||
|
* row's title and content; these map directly to what
|
||||||
|
* Image_Prompt_Builder::build_source_prompt() expects.
|
||||||
|
*
|
||||||
|
* Side effects: one SELECT query.
|
||||||
|
*
|
||||||
|
* @param int[] $ids Database row IDs from the source_data table.
|
||||||
|
* @return array{title: string, selftext: string}|null First source row, or null if none found.
|
||||||
|
*/
|
||||||
|
public function get_source_data_for_image( array $ids ): ?array {
|
||||||
|
if ( empty( $ids ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$table = $wpdb->prefix . 'prautoblogger_source_data';
|
||||||
|
$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
|
||||||
|
$row = $wpdb->get_row(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"SELECT title, content, subreddit FROM {$table} WHERE id IN ({$placeholders}) ORDER BY score DESC LIMIT 1",
|
||||||
|
...$ids
|
||||||
|
),
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( null === $row ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => $row['title'] ?? 'Reddit Discussion',
|
||||||
|
'selftext' => $row['content'] ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store collected source data in the database.
|
* Store collected source data in the database.
|
||||||
*
|
*
|
||||||
* Uses INSERT IGNORE to handle duplicates gracefully (UNIQUE index).
|
* Uses INSERT … ON DUPLICATE KEY UPDATE so that re-seen items refresh
|
||||||
|
* their collected_at timestamp (plus score/comment_count which change
|
||||||
|
* on Reddit over time). Without this, items that age past the
|
||||||
|
* analyzer's 24-hour window would never be re-analyzed even though
|
||||||
|
* they keep appearing in the RSS feed.
|
||||||
*
|
*
|
||||||
* Side effects: database inserts.
|
* Side effects: database inserts/updates.
|
||||||
*
|
*
|
||||||
* @param PRAutoBlogger_Source_Data[] $items
|
* @param PRAutoBlogger_Source_Data[] $items
|
||||||
*
|
*
|
||||||
* @return int Number of new records inserted.
|
* @return int Number of new records inserted (updates not counted).
|
||||||
*/
|
*/
|
||||||
private function store_data( array $items ): int {
|
private function store_data( array $items ): int {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
@@ -76,13 +146,18 @@ class PRAutoBlogger_Source_Collector {
|
|||||||
foreach ( $items as $item ) {
|
foreach ( $items as $item ) {
|
||||||
$row = $item->to_db_row();
|
$row = $item->to_db_row();
|
||||||
|
|
||||||
// Use INSERT IGNORE so duplicate (source_type, source_id) pairs are silently skipped.
|
// Upsert: insert new items, refresh timestamps on duplicates so
|
||||||
|
// the analyzer's 24-hour window picks them up again.
|
||||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
$result = $wpdb->query(
|
$result = $wpdb->query(
|
||||||
$wpdb->prepare(
|
$wpdb->prepare(
|
||||||
"INSERT IGNORE INTO {$table}
|
"INSERT INTO {$table}
|
||||||
(source_type, source_id, subreddit, title, content, author, score, comment_count, permalink, collected_at, metadata_json)
|
(source_type, source_id, subreddit, title, content, author, score, comment_count, permalink, collected_at, metadata_json)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %d, %d, %s, %s, %s)",
|
VALUES (%s, %s, %s, %s, %s, %s, %d, %d, %s, %s, %s)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
collected_at = VALUES(collected_at),
|
||||||
|
score = VALUES(score),
|
||||||
|
comment_count = VALUES(comment_count)",
|
||||||
$row['source_type'],
|
$row['source_type'],
|
||||||
$row['source_id'],
|
$row['source_id'],
|
||||||
$row['subreddit'],
|
$row['subreddit'],
|
||||||
@@ -97,7 +172,9 @@ class PRAutoBlogger_Source_Collector {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
if ( false !== $result && $result > 0 ) {
|
// ON DUPLICATE KEY UPDATE returns 2 for updates, 1 for inserts.
|
||||||
|
// Only count genuine inserts toward the "new" tally.
|
||||||
|
if ( false !== $result && 1 === (int) $result ) {
|
||||||
$inserted++;
|
$inserted++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,8 +191,8 @@ class PRAutoBlogger_Source_Collector {
|
|||||||
*/
|
*/
|
||||||
private function get_provider( string $source_type ): ?PRAutoBlogger_Source_Provider_Interface {
|
private function get_provider( string $source_type ): ?PRAutoBlogger_Source_Provider_Interface {
|
||||||
$providers = [
|
$providers = [
|
||||||
'reddit' => PRAutoBlogger_Reddit_Provider::class,
|
'reddit' => PRAutoBlogger_Reddit_Provider::class,
|
||||||
// New providers can be added here — see CONVENTIONS.md for how-to.
|
'llm_research' => PRAutoBlogger_LLM_Research_Provider::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -154,14 +231,27 @@ class PRAutoBlogger_Source_Collector {
|
|||||||
private function get_config_for_source( string $source_type ): array {
|
private function get_config_for_source( string $source_type ): array {
|
||||||
if ( 'reddit' === $source_type ) {
|
if ( 'reddit' === $source_type ) {
|
||||||
return [
|
return [
|
||||||
'subreddits' => json_decode( get_option( 'prautoblogger_target_subreddits', '[]' ), true ) ?: [],
|
'subreddits' => json_decode( get_option( 'prautoblogger_target_subreddits', '[]' ), true ) ?: [],
|
||||||
'limit' => 25,
|
'limit' => 25,
|
||||||
'time_filter' => 'day',
|
'time_filter' => 'day',
|
||||||
'include_comments' => true,
|
'include_comments' => true,
|
||||||
'comments_per_post' => 10,
|
'comments_per_post' => 10,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ( 'llm_research' === $source_type ) {
|
||||||
|
$config = [
|
||||||
|
'model' => get_option( 'prautoblogger_research_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL ),
|
||||||
|
'prompt' => get_option( 'prautoblogger_research_prompt', '' ),
|
||||||
|
];
|
||||||
|
// Pass the pipeline's cost tracker so research costs get tagged
|
||||||
|
// with the run_id for post-pipeline amortization across articles.
|
||||||
|
if ( null !== $this->cost_tracker ) {
|
||||||
|
$config['cost_tracker'] = $this->cost_tracker;
|
||||||
|
}
|
||||||
|
return $config;
|
||||||
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
256
includes/frontend/class-article-typography.php
Normal file
256
includes/frontend/class-article-typography.php
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injects frontend typography and table styles for PRAutoBlogger-generated posts.
|
||||||
|
*
|
||||||
|
* What: Outputs inline CSS on all single post pages, applying
|
||||||
|
* user-configured font family, font size, and table border
|
||||||
|
* styles from the Display admin settings.
|
||||||
|
* Who calls it: PRAutoBlogger::register_frontend_hooks() hooks this
|
||||||
|
* into wp_head.
|
||||||
|
* Dependencies: Reads wp_options for typography settings.
|
||||||
|
*
|
||||||
|
* @see class-prautoblogger.php — Registers the wp_head hook.
|
||||||
|
* @see class-settings-fields.php — Defines the Display settings section.
|
||||||
|
* @see admin/class-admin-page.php — Renders the settings form.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Article_Typography {
|
||||||
|
|
||||||
|
/** Option key for article font family. */
|
||||||
|
private const OPT_FONT_FAMILY = 'prautoblogger_article_font_family';
|
||||||
|
|
||||||
|
/** Option key for article body font size in pixels. */
|
||||||
|
private const OPT_FONT_SIZE = 'prautoblogger_article_font_size';
|
||||||
|
|
||||||
|
/** Option key for table border toggle. */
|
||||||
|
private const OPT_TABLE_BORDERS = 'prautoblogger_table_borders';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Output inline CSS in <head> for single post pages.
|
||||||
|
*
|
||||||
|
* Applies to all posts — typography settings are site-wide.
|
||||||
|
* The CSS targets .entry-content (PR Theme's article body class)
|
||||||
|
* and overrides the theme's CSS custom properties so the cascade
|
||||||
|
* works naturally with headings and sub-elements.
|
||||||
|
*
|
||||||
|
* Side effects: echoes a <style> block.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function on_wp_head(): void {
|
||||||
|
if ( ! is_singular( 'post' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$css = $this->build_css();
|
||||||
|
if ( '' === $css ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(
|
||||||
|
"\n<style id=\"prautoblogger-typography\">\n%s\n</style>\n",
|
||||||
|
$css // Already escaped — contains only sanitized option values.
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the CSS string from current settings.
|
||||||
|
*
|
||||||
|
* Reads each setting at call time (never cached) so changes
|
||||||
|
* take effect immediately without cache invalidation.
|
||||||
|
*
|
||||||
|
* @return string CSS rules, or empty string if all defaults.
|
||||||
|
*/
|
||||||
|
private function build_css(): string {
|
||||||
|
$rules = [];
|
||||||
|
|
||||||
|
$font_family = $this->get_font_family_stack();
|
||||||
|
if ( '' !== $font_family ) {
|
||||||
|
$rules[] = sprintf(
|
||||||
|
".entry-content,\n.entry-content p,\n.entry-content li {\n\tfont-family: %s;\n}",
|
||||||
|
$font_family
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$font_size = absint( get_option( self::OPT_FONT_SIZE, 0 ) );
|
||||||
|
if ( $font_size > 0 ) {
|
||||||
|
$rules[] = sprintf(
|
||||||
|
".entry-content,\n.entry-content p,\n.entry-content li {\n\tfont-size: %dpx;\n\tline-height: 1.7;\n}",
|
||||||
|
$font_size
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore bullet points — the theme's global reset strips
|
||||||
|
// list-style from all ul/ol, which removes bullets from article
|
||||||
|
// content. This re-applies them inside .entry-content only.
|
||||||
|
$rules[] = implode( "\n", [
|
||||||
|
'.entry-content ul {',
|
||||||
|
"\tlist-style: disc;",
|
||||||
|
"\tpadding-left: 1.5em;",
|
||||||
|
"\tmargin: 1em 0;",
|
||||||
|
'}',
|
||||||
|
'.entry-content ol {',
|
||||||
|
"\tlist-style: decimal;",
|
||||||
|
"\tpadding-left: 1.5em;",
|
||||||
|
"\tmargin: 1em 0;",
|
||||||
|
'}',
|
||||||
|
'.entry-content li {',
|
||||||
|
"\tmargin-bottom: 0.4em;",
|
||||||
|
'}',
|
||||||
|
] );
|
||||||
|
|
||||||
|
$table_borders = get_option( self::OPT_TABLE_BORDERS, '1' );
|
||||||
|
if ( '1' === $table_borders ) {
|
||||||
|
// Uses theme CSS custom properties for dark mode compatibility.
|
||||||
|
// --color-border-default, --color-bg-secondary, --color-bg-primary,
|
||||||
|
// --color-text-primary are set by PR Theme for both light and dark.
|
||||||
|
$rules[] = implode( "\n", [
|
||||||
|
'.entry-content .prab-table-wrap {',
|
||||||
|
"\toverflow-x: auto;",
|
||||||
|
"\t-webkit-overflow-scrolling: touch;",
|
||||||
|
"\tmargin: 1.5em 0;",
|
||||||
|
'}',
|
||||||
|
'.entry-content table {',
|
||||||
|
"\tborder-collapse: collapse;",
|
||||||
|
"\twidth: 100%;",
|
||||||
|
"\tmin-width: 480px;",
|
||||||
|
'}',
|
||||||
|
'.entry-content th,',
|
||||||
|
'.entry-content td {',
|
||||||
|
"\tborder: 1px solid var(--color-border-default, #d1d5db);",
|
||||||
|
"\tpadding: 0.6em 1em;",
|
||||||
|
"\ttext-align: left;",
|
||||||
|
"\tcolor: var(--color-text-primary, #111827);",
|
||||||
|
'}',
|
||||||
|
'.entry-content thead th {',
|
||||||
|
"\tbackground: var(--color-bg-secondary, #f3f4f6);",
|
||||||
|
"\tfont-weight: 600;",
|
||||||
|
'}',
|
||||||
|
'.entry-content tbody tr:nth-child(even) {',
|
||||||
|
"\tbackground: var(--color-bg-secondary, #f9fafb);",
|
||||||
|
'}',
|
||||||
|
'@media (max-width: 600px) {',
|
||||||
|
"\t.entry-content th,",
|
||||||
|
"\t.entry-content td {",
|
||||||
|
"\t\tpadding: 0.4em 0.6em;",
|
||||||
|
"\t\tfont-size: 14px;",
|
||||||
|
"\t}",
|
||||||
|
'}',
|
||||||
|
] );
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode( "\n\n", $rules );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map the stored font-family key to a full CSS font stack.
|
||||||
|
*
|
||||||
|
* Returns empty string for 'default' (let the theme decide).
|
||||||
|
*
|
||||||
|
* @return string CSS font-family value, or empty for theme default.
|
||||||
|
*/
|
||||||
|
private function get_font_family_stack(): string {
|
||||||
|
$key = sanitize_text_field( get_option( self::OPT_FONT_FAMILY, 'default' ) );
|
||||||
|
|
||||||
|
$stacks = [
|
||||||
|
'default' => '',
|
||||||
|
'inter' => '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||||
|
'georgia' => '"Georgia", "Times New Roman", Times, serif',
|
||||||
|
'merriweather' => '"Merriweather", Georgia, "Times New Roman", serif',
|
||||||
|
'lora' => '"Lora", Georgia, "Times New Roman", serif',
|
||||||
|
'open_sans' => '"Open Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||||
|
'roboto' => '"Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||||
|
'system' => '-apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", sans-serif',
|
||||||
|
];
|
||||||
|
|
||||||
|
return $stacks[ $key ] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available font choices for the settings UI.
|
||||||
|
*
|
||||||
|
* @return array<string, string> Map of key => display label.
|
||||||
|
*/
|
||||||
|
public static function get_font_choices(): array {
|
||||||
|
return [
|
||||||
|
'default' => __( 'Theme Default (Inter)', 'prautoblogger' ),
|
||||||
|
'inter' => __( 'Inter (Sans-serif)', 'prautoblogger' ),
|
||||||
|
'georgia' => __( 'Georgia (Serif)', 'prautoblogger' ),
|
||||||
|
'merriweather' => __( 'Merriweather (Serif)', 'prautoblogger' ),
|
||||||
|
'lora' => __( 'Lora (Serif)', 'prautoblogger' ),
|
||||||
|
'open_sans' => __( 'Open Sans (Sans-serif)', 'prautoblogger' ),
|
||||||
|
'roboto' => __( 'Roboto (Sans-serif)', 'prautoblogger' ),
|
||||||
|
'system' => __( 'System Default', 'prautoblogger' ),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueue Google Fonts stylesheet when a web font is selected.
|
||||||
|
*
|
||||||
|
* Only loads on singular post pages that use a Google-hosted font.
|
||||||
|
*
|
||||||
|
* Side effects: enqueues a remote stylesheet.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function on_enqueue_fonts(): void {
|
||||||
|
if ( ! is_singular( 'post' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$font = sanitize_text_field( get_option( self::OPT_FONT_FAMILY, 'default' ) );
|
||||||
|
|
||||||
|
// Map font keys to Google Fonts URLs. Inter is already loaded by the theme.
|
||||||
|
$google_fonts = [
|
||||||
|
'merriweather' => 'https://fonts.googleapis.com/css2?family=Merriweather:wght@400;700&display=swap',
|
||||||
|
'lora' => 'https://fonts.googleapis.com/css2?family=Lora:wght@400;700&display=swap',
|
||||||
|
'open_sans' => 'https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;600;700&display=swap',
|
||||||
|
'roboto' => 'https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap',
|
||||||
|
];
|
||||||
|
|
||||||
|
if ( isset( $google_fonts[ $font ] ) ) {
|
||||||
|
wp_enqueue_style(
|
||||||
|
'prautoblogger-google-font',
|
||||||
|
$google_fonts[ $font ],
|
||||||
|
[],
|
||||||
|
null // No version for external CDN URLs.
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap bare <table> elements in a scrollable container for mobile.
|
||||||
|
*
|
||||||
|
* Tables wider than the viewport break mobile layouts. This wraps
|
||||||
|
* each <table> not already inside a wrapper in a
|
||||||
|
* <div class="prab-table-wrap"> that enables horizontal scrolling.
|
||||||
|
*
|
||||||
|
* Only runs on singular post pages.
|
||||||
|
*
|
||||||
|
* Side effects: modifies post HTML content via the_content filter.
|
||||||
|
*
|
||||||
|
* @param string $content Post HTML content.
|
||||||
|
* @return string Modified content with tables wrapped.
|
||||||
|
*/
|
||||||
|
public function on_wrap_tables( string $content ): string {
|
||||||
|
if ( ! is_singular( 'post' ) ) {
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( '1' !== get_option( self::OPT_TABLE_BORDERS, '1' ) ) {
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap <table> elements not already inside a .prab-table-wrap.
|
||||||
|
return (string) preg_replace(
|
||||||
|
'/(?<!prab-table-wrap">)\s*(<table[\s>])/i',
|
||||||
|
'<div class="prab-table-wrap">$1',
|
||||||
|
(string) preg_replace(
|
||||||
|
'/<\/table>\s*/i',
|
||||||
|
'</table></div>',
|
||||||
|
$content
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,4 +84,25 @@ class PRAutoBlogger_Article_Idea {
|
|||||||
public function get_target_keywords(): array {
|
public function get_target_keywords(): array {
|
||||||
return $this->target_keywords;
|
return $this->target_keywords;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize to array for storage in options/transients.
|
||||||
|
*
|
||||||
|
* Used by the pipeline queue to persist ideas across chained cron jobs.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed> Constructor-compatible array.
|
||||||
|
*/
|
||||||
|
public function to_array(): array {
|
||||||
|
return [
|
||||||
|
'topic' => $this->topic,
|
||||||
|
'article_type' => $this->article_type,
|
||||||
|
'suggested_title' => $this->suggested_title,
|
||||||
|
'summary' => $this->summary,
|
||||||
|
'score' => $this->score,
|
||||||
|
'analysis_id' => $this->analysis_id,
|
||||||
|
'source_ids' => $this->source_ids,
|
||||||
|
'key_points' => $this->key_points,
|
||||||
|
'target_keywords' => $this->target_keywords,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class PRAutoBlogger_Content_Request {
|
|||||||
private int $max_word_count;
|
private int $max_word_count;
|
||||||
private string $niche_description;
|
private string $niche_description;
|
||||||
private array $topic_exclusions;
|
private array $topic_exclusions;
|
||||||
|
private string $writing_instructions;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
PRAutoBlogger_Article_Idea $idea,
|
PRAutoBlogger_Article_Idea $idea,
|
||||||
@@ -29,15 +30,17 @@ class PRAutoBlogger_Content_Request {
|
|||||||
int $min_word_count,
|
int $min_word_count,
|
||||||
int $max_word_count,
|
int $max_word_count,
|
||||||
string $niche_description,
|
string $niche_description,
|
||||||
array $topic_exclusions = []
|
array $topic_exclusions = [],
|
||||||
|
string $writing_instructions = ''
|
||||||
) {
|
) {
|
||||||
$this->idea = $idea;
|
$this->idea = $idea;
|
||||||
$this->pipeline_mode = $pipeline_mode;
|
$this->pipeline_mode = $pipeline_mode;
|
||||||
$this->tone = $tone;
|
$this->tone = $tone;
|
||||||
$this->min_word_count = $min_word_count;
|
$this->min_word_count = $min_word_count;
|
||||||
$this->max_word_count = $max_word_count;
|
$this->max_word_count = $max_word_count;
|
||||||
$this->niche_description = $niche_description;
|
$this->niche_description = $niche_description;
|
||||||
$this->topic_exclusions = $topic_exclusions;
|
$this->topic_exclusions = $topic_exclusions;
|
||||||
|
$this->writing_instructions = $writing_instructions;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function get_idea(): PRAutoBlogger_Article_Idea {
|
public function get_idea(): PRAutoBlogger_Article_Idea {
|
||||||
@@ -67,4 +70,8 @@ class PRAutoBlogger_Content_Request {
|
|||||||
public function get_topic_exclusions(): array {
|
public function get_topic_exclusions(): array {
|
||||||
return $this->topic_exclusions;
|
return $this->topic_exclusions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function get_writing_instructions(): string {
|
||||||
|
return $this->writing_instructions;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pricing + model-id resolution helpers for the Cloudflare image provider.
|
|
||||||
*
|
|
||||||
* Kept separate from the provider so the provider stays under the 300-line
|
|
||||||
* cap and so cost estimation can be unit-tested without spinning up HTTP
|
|
||||||
* mocks. Mirrors the `class-open-router-pricing.php` split.
|
|
||||||
*
|
|
||||||
* Triggered by: PRAutoBlogger_Cloudflare_Image_Provider (both generate_image()
|
|
||||||
* and estimate_cost() delegate here).
|
|
||||||
* Dependencies: None — pure calculation + option reads.
|
|
||||||
*
|
|
||||||
* @see class-cloudflare-image-provider.php — Caller.
|
|
||||||
* @see class-open-router-pricing.php — Sibling pattern for LLM pricing.
|
|
||||||
*/
|
|
||||||
class PRAutoBlogger_Cloudflare_Image_Pricing {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cloudflare Workers AI FLUX pricing (USD per megapixel), April 2026.
|
|
||||||
* Single source of truth — update here if Cloudflare repricies.
|
|
||||||
*/
|
|
||||||
private const COST_PER_MP_SCHNELL_USD = 0.0011;
|
|
||||||
private const COST_MULTIPLIER_DEV = 4.0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Short-alias → fully-qualified Workers AI model id.
|
|
||||||
*
|
|
||||||
* @var array<string, string>
|
|
||||||
*/
|
|
||||||
private const MODEL_ALIAS_MAP = [
|
|
||||||
'flux-1-schnell' => '@cf/black-forest-labs/flux-1-schnell',
|
|
||||||
'flux-1-dev' => '@cf/black-forest-labs/flux-1-dev',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize a caller's model hint to a fully-qualified Workers AI id.
|
|
||||||
*
|
|
||||||
* Accepts short aliases (`flux-1-schnell`), fully-qualified names
|
|
||||||
* (`@cf/black-forest-labs/...`), or empty. Empty input falls back to
|
|
||||||
* the site option, then to the hardcoded default.
|
|
||||||
*
|
|
||||||
* @param string $hint Caller-supplied model identifier (may be empty).
|
|
||||||
* @return string Fully-qualified Workers AI model id suitable for the URL path.
|
|
||||||
*/
|
|
||||||
public function resolve_model( string $hint ): string {
|
|
||||||
$hint = trim( $hint );
|
|
||||||
if ( '' === $hint ) {
|
|
||||||
$hint = (string) get_option( 'prautoblogger_image_model', PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL );
|
|
||||||
}
|
|
||||||
if ( 0 === strpos( $hint, '@cf/' ) ) {
|
|
||||||
return $hint;
|
|
||||||
}
|
|
||||||
return self::MODEL_ALIAS_MAP[ $hint ] ?? self::MODEL_ALIAS_MAP['flux-1-schnell'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Estimate USD cost for a single image at the given dimensions.
|
|
||||||
*
|
|
||||||
* FLUX is priced per megapixel of output. Schnell is the baseline;
|
|
||||||
* [dev] is a ~4x multiplier. We clamp the MP floor at 0.01 so a
|
|
||||||
* tiny thumbnail doesn't round to zero and bypass the budget check.
|
|
||||||
*
|
|
||||||
* @param int $width Width in pixels.
|
|
||||||
* @param int $height Height in pixels.
|
|
||||||
* @param string $model Fully-qualified Workers AI model id (call resolve_model first).
|
|
||||||
* @return float USD cost rounded to 6 decimals.
|
|
||||||
*/
|
|
||||||
public function estimate_cost( int $width, int $height, string $model ): float {
|
|
||||||
$mp = max( 0.01, ( $width * $height ) / 1_000_000 );
|
|
||||||
$rate = self::COST_PER_MP_SCHNELL_USD;
|
|
||||||
if ( false !== strpos( $model, 'flux-1-dev' ) ) {
|
|
||||||
$rate *= self::COST_MULTIPLIER_DEV;
|
|
||||||
}
|
|
||||||
return round( $mp * $rate, 6 );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cloudflare Workers AI image provider — FLUX.1 family.
|
|
||||||
*
|
|
||||||
* Calls `https://api.cloudflare.com/client/v4/accounts/{id}/ai/run/{model}`
|
|
||||||
* directly. We bypass the Cloudflare AI Gateway for v1 because the gateway
|
|
||||||
* route to Workers AI currently 403s (see decision D-001); the OpenRouter
|
|
||||||
* gateway path is unaffected.
|
|
||||||
*
|
|
||||||
* Triggered by: PRAutoBlogger_Image_Pipeline (commit 1b) during the content
|
|
||||||
* generation run, after editorial review, before publish. Also
|
|
||||||
* triggered by the admin "Test Connection" action.
|
|
||||||
* Dependencies: PRAutoBlogger_Encryption (token decryption), PRAutoBlogger_Logger
|
|
||||||
* (WARN/ERROR lines), PRAutoBlogger_Cloudflare_Image_Pricing
|
|
||||||
* (model resolution + cost estimation).
|
|
||||||
*
|
|
||||||
* @see interface-image-provider.php Interface this class implements.
|
|
||||||
* @see class-cloudflare-image-pricing.php Cost + model id helpers.
|
|
||||||
* @see class-cloudflare-image-support.php Token, account, URL, and log helpers.
|
|
||||||
* @see class-cloudflare-image-validator.php Credential check helper.
|
|
||||||
* @see class-open-router-provider.php Sibling provider; same retry pattern.
|
|
||||||
* @see ARCHITECTURE.md External API Integrations, key decision #16.
|
|
||||||
*/
|
|
||||||
class PRAutoBlogger_Cloudflare_Image_Provider implements PRAutoBlogger_Image_Provider_Interface {
|
|
||||||
|
|
||||||
private const PROVIDER_ID = 'cloudflare_workers_ai';
|
|
||||||
private const MIN_DIMENSION_PX = 64;
|
|
||||||
private const MAX_DIMENSION_PX = 2048;
|
|
||||||
private const DEFAULT_STEPS_SCHNELL = 4;
|
|
||||||
private const RETRY_AFTER_CAP_S = 60;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pricing + model-id resolver. Constructed once per provider instance.
|
|
||||||
*
|
|
||||||
* @var PRAutoBlogger_Cloudflare_Image_Pricing
|
|
||||||
*/
|
|
||||||
private PRAutoBlogger_Cloudflare_Image_Pricing $pricing;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Token, account, URL, and log helpers — pulled out of this class to
|
|
||||||
* keep it under the 300-line cap.
|
|
||||||
*
|
|
||||||
* @var PRAutoBlogger_Cloudflare_Image_Support
|
|
||||||
*/
|
|
||||||
private PRAutoBlogger_Cloudflare_Image_Support $support;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Construct the provider with its default pricing + support helpers.
|
|
||||||
*/
|
|
||||||
public function __construct() {
|
|
||||||
$this->pricing = new PRAutoBlogger_Cloudflare_Image_Pricing();
|
|
||||||
$this->support = new PRAutoBlogger_Cloudflare_Image_Support();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a single image from a prompt at the requested dimensions.
|
|
||||||
*
|
|
||||||
* Retries 5xx / 429 / network errors with exponential backoff bounded
|
|
||||||
* by PRAUTOBLOGGER_MAX_RETRIES. 4xx other than 429 fail loudly on the
|
|
||||||
* first attempt — retrying a bad prompt or a bad token wastes money.
|
|
||||||
*
|
|
||||||
* Side effects: one HTTP request per attempt; one Logger line per attempt.
|
|
||||||
*
|
|
||||||
* {@inheritDoc}
|
|
||||||
*
|
|
||||||
* @throws \InvalidArgumentException If dimensions out of range or prompt empty.
|
|
||||||
* @throws \RuntimeException On auth failure, bad response shape, or retry exhaustion.
|
|
||||||
*/
|
|
||||||
public function generate_image( string $prompt, int $width, int $height, array $options = [] ): array {
|
|
||||||
$this->assert_valid_dimensions( $width, $height );
|
|
||||||
$prompt = trim( $prompt );
|
|
||||||
if ( '' === $prompt ) {
|
|
||||||
throw new \InvalidArgumentException(
|
|
||||||
esc_html__( 'Image prompt cannot be empty.', 'prautoblogger' )
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$account_id = $this->support->get_account_id();
|
|
||||||
$api_token = $this->support->get_api_token();
|
|
||||||
$model = $this->pricing->resolve_model( (string) ( $options['model'] ?? '' ) );
|
|
||||||
|
|
||||||
$body = [
|
|
||||||
'prompt' => $prompt,
|
|
||||||
'width' => $width,
|
|
||||||
'height' => $height,
|
|
||||||
'steps' => (int) ( $options['steps'] ?? self::DEFAULT_STEPS_SCHNELL ),
|
|
||||||
];
|
|
||||||
if ( isset( $options['seed'] ) ) {
|
|
||||||
$body['seed'] = (int) $options['seed'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$url = $this->support->build_endpoint_url( $account_id, $model );
|
|
||||||
$headers = [
|
|
||||||
'Authorization' => 'Bearer ' . $api_token,
|
|
||||||
'Content-Type' => 'application/json',
|
|
||||||
'Accept' => 'image/*',
|
|
||||||
];
|
|
||||||
$started_at = microtime( true );
|
|
||||||
$last_error = '';
|
|
||||||
|
|
||||||
for ( $attempt = 1; $attempt <= PRAUTOBLOGGER_MAX_RETRIES; $attempt++ ) {
|
|
||||||
$response = wp_remote_post(
|
|
||||||
$url,
|
|
||||||
[
|
|
||||||
'timeout' => PRAUTOBLOGGER_API_TIMEOUT_SECONDS,
|
|
||||||
'headers' => $headers,
|
|
||||||
'body' => wp_json_encode( $body ),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
if ( is_wp_error( $response ) ) {
|
|
||||||
$last_error = $response->get_error_message();
|
|
||||||
$this->support->log_retryable_failure( $attempt, 'network', $last_error );
|
|
||||||
$this->sleep_for_attempt( $attempt );
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$status = (int) wp_remote_retrieve_response_code( $response );
|
|
||||||
$raw = (string) wp_remote_retrieve_body( $response );
|
|
||||||
|
|
||||||
if ( 429 === $status || $status >= 500 ) {
|
|
||||||
$last_error = sprintf( 'HTTP %d: %s', $status, substr( $raw, 0, 300 ) );
|
|
||||||
$this->support->log_retryable_failure( $attempt, (string) $status, $last_error );
|
|
||||||
if ( $attempt < PRAUTOBLOGGER_MAX_RETRIES ) {
|
|
||||||
$retry_after = (int) wp_remote_retrieve_header( $response, 'retry-after' );
|
|
||||||
$this->sleep_for_attempt( $attempt, $retry_after );
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( $status >= 400 ) {
|
|
||||||
$this->support->log_client_error( $status, $api_token, $account_id, $raw );
|
|
||||||
throw new \RuntimeException(
|
|
||||||
sprintf(
|
|
||||||
/* translators: %1$d: HTTP status, %2$s: error body. */
|
|
||||||
esc_html__( 'Cloudflare Workers AI error (HTTP %1$d): %2$s', 'prautoblogger' ),
|
|
||||||
$status,
|
|
||||||
esc_html( substr( $raw, 0, 300 ) )
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Success. FLUX on Workers AI returns either raw image bytes
|
|
||||||
// (Content-Type: image/*) or a JSON envelope with base64 under
|
|
||||||
// `result.image`. Handle both without branching the caller.
|
|
||||||
$mime = (string) wp_remote_retrieve_header( $response, 'content-type' );
|
|
||||||
[ $raw, $mime ] = $this->normalize_response_body( $raw, $mime );
|
|
||||||
|
|
||||||
return [
|
|
||||||
'bytes' => $raw,
|
|
||||||
'mime_type' => $mime,
|
|
||||||
'width' => $width,
|
|
||||||
'height' => $height,
|
|
||||||
'model' => $model,
|
|
||||||
'seed' => isset( $body['seed'] ) ? (int) $body['seed'] : null,
|
|
||||||
'cost_usd' => $this->pricing->estimate_cost( $width, $height, $model ),
|
|
||||||
'latency_ms' => (int) ( ( microtime( true ) - $started_at ) * 1000 ),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new \RuntimeException(
|
|
||||||
sprintf(
|
|
||||||
/* translators: %1$d: max retries, %2$s: last error. */
|
|
||||||
esc_html__( 'Cloudflare Workers AI failed after %1$d attempts. Last error: %2$s', 'prautoblogger' ),
|
|
||||||
PRAUTOBLOGGER_MAX_RETRIES,
|
|
||||||
esc_html( $last_error )
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Estimate the USD cost of a single generation call.
|
|
||||||
*
|
|
||||||
* {@inheritDoc}
|
|
||||||
*/
|
|
||||||
public function estimate_cost( int $width, int $height, array $options = [] ): float {
|
|
||||||
$model = $this->pricing->resolve_model( (string) ( $options['model'] ?? '' ) );
|
|
||||||
return $this->pricing->estimate_cost( $width, $height, $model );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return string Machine-readable provider id used in logs and settings.
|
|
||||||
*/
|
|
||||||
public function get_provider_name(): string {
|
|
||||||
return self::PROVIDER_ID;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify credentials are present and the API is reachable.
|
|
||||||
*
|
|
||||||
* Delegates to PRAutoBlogger_Cloudflare_Image_Validator so the HTTP probe
|
|
||||||
* and error-shape code can be tested and sized independently.
|
|
||||||
*
|
|
||||||
* Side effects: one HTTP GET via the validator; one Logger line on failure.
|
|
||||||
*
|
|
||||||
* @return array{status: string, message: string, debug?: string}
|
|
||||||
*/
|
|
||||||
public function validate_credentials_detailed(): array {
|
|
||||||
return ( new PRAutoBlogger_Cloudflare_Image_Validator() )->run();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enforce the interface's dimension contract.
|
|
||||||
*
|
|
||||||
* @param int $width Width in pixels.
|
|
||||||
* @param int $height Height in pixels.
|
|
||||||
* @return void
|
|
||||||
* @throws \InvalidArgumentException If either dimension is outside [MIN, MAX].
|
|
||||||
*/
|
|
||||||
private function assert_valid_dimensions( int $width, int $height ): void {
|
|
||||||
if (
|
|
||||||
$width < self::MIN_DIMENSION_PX || $width > self::MAX_DIMENSION_PX ||
|
|
||||||
$height < self::MIN_DIMENSION_PX || $height > self::MAX_DIMENSION_PX
|
|
||||||
) {
|
|
||||||
throw new \InvalidArgumentException(
|
|
||||||
sprintf(
|
|
||||||
/* translators: %1$d: width, %2$d: height, %3$d: min, %4$d: max. */
|
|
||||||
esc_html__( 'Image dimensions %1$dx%2$d are outside the supported range (%3$d–%4$d px per axis).', 'prautoblogger' ),
|
|
||||||
$width,
|
|
||||||
$height,
|
|
||||||
self::MIN_DIMENSION_PX,
|
|
||||||
self::MAX_DIMENSION_PX
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize a 2xx response into [bytes, mime-type] whether the body
|
|
||||||
* arrives as raw image bytes or as a JSON envelope with base64 image.
|
|
||||||
*
|
|
||||||
* @param string $raw Raw response body.
|
|
||||||
* @param string $mime Response Content-Type header value (may be empty).
|
|
||||||
* @return array{0: string, 1: string} [image bytes, mime type]
|
|
||||||
* @throws \RuntimeException If the body matches neither expected shape.
|
|
||||||
*/
|
|
||||||
private function normalize_response_body( string $raw, string $mime ): array {
|
|
||||||
if ( '' !== $mime && 0 === stripos( $mime, 'image/' ) ) {
|
|
||||||
return [ $raw, $mime ];
|
|
||||||
}
|
|
||||||
$decoded = json_decode( $raw, true );
|
|
||||||
if ( is_array( $decoded ) && isset( $decoded['result']['image'] ) && is_string( $decoded['result']['image'] ) ) {
|
|
||||||
$bytes = (string) base64_decode( $decoded['result']['image'], true );
|
|
||||||
if ( '' !== $bytes ) {
|
|
||||||
return [ $bytes, 'image/png' ];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new \RuntimeException(
|
|
||||||
esc_html__( 'Cloudflare Workers AI returned an unexpected response shape.', 'prautoblogger' )
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sleep between retries. Retry-After header value wins when present,
|
|
||||||
* otherwise exponential backoff from the PRAUTOBLOGGER_* constants.
|
|
||||||
*
|
|
||||||
* @param int $attempt 1-based attempt number that just failed.
|
|
||||||
* @param int $retry_after_secs Retry-After header value (0 if absent).
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
private function sleep_for_attempt( int $attempt, int $retry_after_secs = 0 ): void {
|
|
||||||
$backoff = PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS * (int) pow( 2, $attempt - 1 );
|
|
||||||
$delay = $retry_after_secs > 0 ? min( $retry_after_secs, self::RETRY_AFTER_CAP_S ) : $backoff;
|
|
||||||
if ( $delay > 0 ) {
|
|
||||||
sleep( $delay );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Small support helpers for the Cloudflare image provider — credential
|
|
||||||
* lookups, URL building, and structured log lines.
|
|
||||||
*
|
|
||||||
* Split out of the provider so the provider stays under the 300-line cap
|
|
||||||
* and so these tiny pieces are individually testable. Pure functions plus
|
|
||||||
* one logger forward; no state.
|
|
||||||
*
|
|
||||||
* Triggered by: PRAutoBlogger_Cloudflare_Image_Provider only.
|
|
||||||
* Dependencies: PRAutoBlogger_Encryption (token decryption),
|
|
||||||
* PRAutoBlogger_Logger (WARN/ERROR forwarding).
|
|
||||||
*
|
|
||||||
* @see class-cloudflare-image-provider.php Sole caller.
|
|
||||||
*/
|
|
||||||
class PRAutoBlogger_Cloudflare_Image_Support {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decrypt the Cloudflare API token from the settings option.
|
|
||||||
*
|
|
||||||
* @return string Plaintext token, or empty string if not set / decrypt fails.
|
|
||||||
*/
|
|
||||||
public function get_api_token(): string {
|
|
||||||
$encrypted = (string) get_option( 'prautoblogger_cloudflare_ai_token', '' );
|
|
||||||
if ( '' === $encrypted ) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return (string) PRAutoBlogger_Encryption::decrypt( $encrypted );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the Cloudflare account ID from settings.
|
|
||||||
*
|
|
||||||
* @return string Account UUID, or empty string.
|
|
||||||
*/
|
|
||||||
public function get_account_id(): string {
|
|
||||||
return trim( (string) get_option( 'prautoblogger_cloudflare_account_id', '' ) );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the Workers AI run URL for a given account + model.
|
|
||||||
*
|
|
||||||
* @param string $account_id Cloudflare account UUID.
|
|
||||||
* @param string $model Fully-qualified Workers AI model id.
|
|
||||||
* @return string Fully-formed HTTPS URL.
|
|
||||||
*/
|
|
||||||
public function build_endpoint_url( string $account_id, string $model ): string {
|
|
||||||
return sprintf(
|
|
||||||
'https://api.cloudflare.com/client/v4/accounts/%s/ai/run/%s',
|
|
||||||
rawurlencode( $account_id ),
|
|
||||||
ltrim( $model, '/' )
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emit a WARN line for a retryable failure.
|
|
||||||
*
|
|
||||||
* @param int $attempt 1-based attempt number.
|
|
||||||
* @param string $failure_class Either 'network' or HTTP status code as string.
|
|
||||||
* @param string $detail Short failure detail for the log.
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function log_retryable_failure( int $attempt, string $failure_class, string $detail ): void {
|
|
||||||
PRAutoBlogger_Logger::instance()->warning(
|
|
||||||
sprintf(
|
|
||||||
'Cloudflare image gen %s failure (attempt %d/%d): %s',
|
|
||||||
$failure_class,
|
|
||||||
$attempt,
|
|
||||||
PRAUTOBLOGGER_MAX_RETRIES,
|
|
||||||
$detail
|
|
||||||
),
|
|
||||||
'cloudflare-image'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log an unrecoverable 4xx with triage context. Never logs the full
|
|
||||||
* secret — only a 4-char token prefix and a 6-char account prefix.
|
|
||||||
*
|
|
||||||
* @param int $status HTTP status returned by Cloudflare.
|
|
||||||
* @param string $api_token Plaintext token.
|
|
||||||
* @param string $account_id Account UUID.
|
|
||||||
* @param string $raw Raw response body (truncated to 300 chars in the log).
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function log_client_error( int $status, string $api_token, string $account_id, string $raw ): void {
|
|
||||||
PRAutoBlogger_Logger::instance()->error(
|
|
||||||
sprintf(
|
|
||||||
'Cloudflare image gen HTTP %d (token_prefix=%s, token_len=%d, account=%s): %s',
|
|
||||||
$status,
|
|
||||||
substr( $api_token, 0, 4 ),
|
|
||||||
strlen( $api_token ),
|
|
||||||
substr( $account_id, 0, 6 ),
|
|
||||||
substr( $raw, 0, 300 )
|
|
||||||
),
|
|
||||||
'cloudflare-image'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Credential + connectivity validator for the Cloudflare image provider.
|
|
||||||
*
|
|
||||||
* Split out of the provider to keep each file under the 300-line cap and
|
|
||||||
* to keep the validation path easy to unit-test without spinning up the
|
|
||||||
* retry loop. The provider's `validate_credentials_detailed()` delegates
|
|
||||||
* straight to `run()` here.
|
|
||||||
*
|
|
||||||
* Triggered by: PRAutoBlogger_Cloudflare_Image_Provider::validate_credentials_detailed()
|
|
||||||
* which is called by the admin "Test Connection" action.
|
|
||||||
* Dependencies: PRAutoBlogger_Encryption (token decryption), PRAutoBlogger_Logger
|
|
||||||
* (WARN/ERROR lines), wp_remote_get().
|
|
||||||
*
|
|
||||||
* @see class-cloudflare-image-provider.php Parent class that delegates here.
|
|
||||||
* @see interface-image-provider.php Return-shape contract.
|
|
||||||
*/
|
|
||||||
class PRAutoBlogger_Cloudflare_Image_Validator {
|
|
||||||
|
|
||||||
private const TIMEOUT_SECONDS = 15;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run a non-destructive credential check.
|
|
||||||
*
|
|
||||||
* Lightweight probe: hits the account-scoped AI models listing endpoint.
|
|
||||||
* Never generates a real image — that would cost money on every click.
|
|
||||||
*
|
|
||||||
* Side effects: one HTTP GET; one Logger line on failure.
|
|
||||||
*
|
|
||||||
* @return array{status: string, message: string, debug?: string}
|
|
||||||
*/
|
|
||||||
public function run(): array {
|
|
||||||
$account_id = trim( (string) get_option( 'prautoblogger_cloudflare_account_id', '' ) );
|
|
||||||
if ( '' === $account_id ) {
|
|
||||||
return $this->err(
|
|
||||||
'account_id_empty',
|
|
||||||
__( 'Cloudflare account ID is not set. Go to PRAutoBlogger → Settings → Images.', 'prautoblogger' )
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$encrypted = (string) get_option( 'prautoblogger_cloudflare_ai_token', '' );
|
|
||||||
$api_token = '' === $encrypted ? '' : (string) PRAutoBlogger_Encryption::decrypt( $encrypted );
|
|
||||||
if ( '' === $api_token ) {
|
|
||||||
return $this->err(
|
|
||||||
'token_empty',
|
|
||||||
__( 'Cloudflare API token is not set. Go to PRAutoBlogger → Settings → Images.', 'prautoblogger' )
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$response = wp_remote_get(
|
|
||||||
sprintf(
|
|
||||||
'https://api.cloudflare.com/client/v4/accounts/%s/ai/models/search?per_page=1',
|
|
||||||
rawurlencode( $account_id )
|
|
||||||
),
|
|
||||||
[
|
|
||||||
'timeout' => self::TIMEOUT_SECONDS,
|
|
||||||
'headers' => [
|
|
||||||
'Authorization' => 'Bearer ' . $api_token,
|
|
||||||
'Content-Type' => 'application/json',
|
|
||||||
],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
if ( is_wp_error( $response ) ) {
|
|
||||||
$msg = $response->get_error_message();
|
|
||||||
PRAutoBlogger_Logger::instance()->error(
|
|
||||||
sprintf( 'Cloudflare credential check wp_error: %s', $msg ),
|
|
||||||
'cloudflare-image'
|
|
||||||
);
|
|
||||||
return $this->err(
|
|
||||||
'wp_error:' . $msg,
|
|
||||||
sprintf(
|
|
||||||
/* translators: %s: transport-layer error message. */
|
|
||||||
esc_html__( 'Network error reaching Cloudflare: %s', 'prautoblogger' ),
|
|
||||||
esc_html( $msg )
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$status = (int) wp_remote_retrieve_response_code( $response );
|
|
||||||
if ( 200 === $status ) {
|
|
||||||
return [
|
|
||||||
'status' => 'ok',
|
|
||||||
'message' => __( 'Cloudflare Workers AI connected.', 'prautoblogger' ),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$body = (string) wp_remote_retrieve_body( $response );
|
|
||||||
PRAutoBlogger_Logger::instance()->warning(
|
|
||||||
sprintf( 'Cloudflare credential check HTTP %d: %s', $status, substr( $body, 0, 200 ) ),
|
|
||||||
'cloudflare-image'
|
|
||||||
);
|
|
||||||
return $this->err(
|
|
||||||
sprintf( 'http_%d', $status ),
|
|
||||||
sprintf(
|
|
||||||
/* translators: %1$d: HTTP status, %2$s: response body. */
|
|
||||||
esc_html__( 'Cloudflare returned HTTP %1$d. %2$s', 'prautoblogger' ),
|
|
||||||
$status,
|
|
||||||
esc_html( substr( $body, 0, 200 ) )
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{status: string, message: string, debug: string}
|
|
||||||
*/
|
|
||||||
private function err( string $debug, string $message ): array {
|
|
||||||
return [
|
|
||||||
'status' => 'error',
|
|
||||||
'message' => $message,
|
|
||||||
'debug' => $debug,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
45
includes/providers/class-image-nsfw-blocked.php
Normal file
45
includes/providers/class-image-nsfw-blocked.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception raised when an image provider rejects a prompt as NSFW.
|
||||||
|
*
|
||||||
|
* Image providers (Runware, OpenRouter) return provider-specific error
|
||||||
|
* shapes when their content filters block a prompt. We catch those shapes
|
||||||
|
* in each provider and raise this typed exception so the image pipeline
|
||||||
|
* can distinguish a content-filter rejection from a generic 4xx and
|
||||||
|
* retry once with a sanitized fallback prompt.
|
||||||
|
*
|
||||||
|
* Triggered by: image providers on NSFW-filter rejection.
|
||||||
|
* Dependencies: None.
|
||||||
|
*
|
||||||
|
* @see providers/class-runware-image-provider.php — May raise this.
|
||||||
|
* @see providers/class-open-router-image-provider.php — May raise this.
|
||||||
|
* @see core/class-image-nsfw-retry.php — Catches + rebuilds prompt.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Image_NSFW_Blocked extends \RuntimeException {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The raw upstream response body, kept verbatim for log forensics.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
private string $upstream_body;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $message Human-readable summary.
|
||||||
|
* @param string $upstream_body Raw response body from the provider.
|
||||||
|
* @param \Throwable|null $previous Optional previous exception.
|
||||||
|
*/
|
||||||
|
public function __construct( string $message, string $upstream_body = '', ?\Throwable $previous = null ) {
|
||||||
|
parent::__construct( $message, 0, $previous );
|
||||||
|
$this->upstream_body = $upstream_body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string Raw upstream body (may be empty).
|
||||||
|
*/
|
||||||
|
public function get_upstream_body(): string {
|
||||||
|
return $this->upstream_body;
|
||||||
|
}
|
||||||
|
}
|
||||||
213
includes/providers/class-llm-research-provider.php
Normal file
213
includes/providers/class-llm-research-provider.php
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LLM Deep Research source provider — uses a reasoning-capable model to
|
||||||
|
* identify trending topics, emerging questions, and content gaps in the niche.
|
||||||
|
*
|
||||||
|
* Unlike the Reddit provider (which scrapes real-time discussions), this
|
||||||
|
* provider leverages an LLM's training knowledge to surface topics that
|
||||||
|
* community data might miss: recent research directions, common
|
||||||
|
* misconceptions, safety concerns, and underserved content gaps.
|
||||||
|
*
|
||||||
|
* Findings are stored as Source_Data objects alongside Reddit data so the
|
||||||
|
* Content_Analyzer sees both signals when generating article ideas.
|
||||||
|
*
|
||||||
|
* Triggered by: Source_Collector::collect_from_all_sources() when 'llm_research' is enabled.
|
||||||
|
* Dependencies: PRAutoBlogger_OpenRouter_Provider (LLM calls), PRAutoBlogger_Cost_Tracker.
|
||||||
|
*
|
||||||
|
* @see providers/interface-source-provider.php — Interface this class implements.
|
||||||
|
* @see core/class-source-collector.php — Orchestrates collection from all providers.
|
||||||
|
* @see core/class-content-analyzer.php — Consumes research findings for idea generation.
|
||||||
|
* @see ARCHITECTURE.md — Data flow step 1.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_LLM_Research_Provider implements PRAutoBlogger_Source_Provider_Interface {
|
||||||
|
|
||||||
|
private const SOURCE_TYPE = 'llm_research';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect research findings by sending the configured prompt to an LLM.
|
||||||
|
*
|
||||||
|
* Sends the system prompt + user research prompt to the selected model,
|
||||||
|
* parses the JSON response, and returns each finding as a Source_Data object.
|
||||||
|
* Cost is tracked via the shared Cost_Tracker.
|
||||||
|
*
|
||||||
|
* Side effects: LLM API call via OpenRouter, cost logging.
|
||||||
|
*
|
||||||
|
* @param array{prompt?: string, model?: string, cost_tracker?: PRAutoBlogger_Cost_Tracker} $config Research-specific configuration.
|
||||||
|
* @return PRAutoBlogger_Source_Data[] Array of research findings as source data.
|
||||||
|
* @throws \RuntimeException On API error after retries.
|
||||||
|
*/
|
||||||
|
public function collect_data( array $config ): array {
|
||||||
|
$model = $config['model'] ?? get_option( 'prautoblogger_research_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL );
|
||||||
|
$prompt = $config['prompt'] ?? get_option( 'prautoblogger_research_prompt', '' );
|
||||||
|
$niche = get_option( 'prautoblogger_niche_description', '' );
|
||||||
|
|
||||||
|
if ( '' === trim( $prompt ) ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->warning( 'LLM Research prompt is empty. Skipping.', 'llm-research' );
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$system_prompt = $this->build_system_prompt();
|
||||||
|
$user_prompt = $this->build_user_prompt( $prompt, $niche );
|
||||||
|
|
||||||
|
$llm = new PRAutoBlogger_OpenRouter_Provider();
|
||||||
|
$response = $llm->send_chat_completion(
|
||||||
|
[
|
||||||
|
[ 'role' => 'system', 'content' => $system_prompt ],
|
||||||
|
[ 'role' => 'user', 'content' => $user_prompt ],
|
||||||
|
],
|
||||||
|
$model,
|
||||||
|
[
|
||||||
|
'temperature' => 0.7,
|
||||||
|
'max_tokens' => 8000,
|
||||||
|
'response_format' => [ 'type' => 'json_object' ],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use the pipeline's cost tracker if provided (for run_id tagging),
|
||||||
|
// otherwise create a standalone one (backward compat for manual calls).
|
||||||
|
$cost_tracker = $config['cost_tracker'] ?? new PRAutoBlogger_Cost_Tracker();
|
||||||
|
$cost_tracker->log_api_call(
|
||||||
|
null,
|
||||||
|
'llm_research',
|
||||||
|
$llm->get_provider_name(),
|
||||||
|
$response['model'],
|
||||||
|
$response['prompt_tokens'],
|
||||||
|
$response['completion_tokens']
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->parse_findings( $response['content'], $response['model'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function get_source_type(): string {
|
||||||
|
return self::SOURCE_TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate credentials by delegating to the OpenRouter provider.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function validate_credentials(): bool {
|
||||||
|
return ( new PRAutoBlogger_OpenRouter_Provider() )->validate_credentials();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LLM research has no rate limits beyond the OpenRouter provider's own.
|
||||||
|
*
|
||||||
|
* @return array{remaining: int, limit: int, resets_at: string}
|
||||||
|
*/
|
||||||
|
public function get_rate_limit_status(): array {
|
||||||
|
return [
|
||||||
|
'remaining' => 999,
|
||||||
|
'limit' => 999,
|
||||||
|
'resets_at' => '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the system prompt for the research LLM.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function build_system_prompt(): string {
|
||||||
|
return '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.\n\n"
|
||||||
|
. "Using your training knowledge, scan across:\n"
|
||||||
|
. "- Recent scientific literature and research directions\n"
|
||||||
|
. "- Active community discussions, debates, and evolving consensus\n"
|
||||||
|
. "- Emerging products, protocols, and methodologies gaining traction\n"
|
||||||
|
. "- Common questions, misconceptions, and points of confusion\n"
|
||||||
|
. "- Regulatory changes, safety concerns, and legal shifts\n"
|
||||||
|
. "- Gaps between what practitioners want to know and what content exists\n\n"
|
||||||
|
. "Your findings must be:\n"
|
||||||
|
. "1. SUBSTANTIVE — 2-3 paragraphs of detailed analysis per finding, not a headline\n"
|
||||||
|
. "2. ACTIONABLE — practical enough to seed a full article\n"
|
||||||
|
. "3. REALISTIC — grounded in what is actually discussed or researched; do NOT invent citations\n"
|
||||||
|
. "4. TIMELY — reflect current knowledge, recent shifts, or emerging questions\n\n"
|
||||||
|
. "Respond with valid JSON only (no preamble, no markdown fences):\n"
|
||||||
|
. '{"findings": [{"title": "string", "content": "string (2-3 paragraphs)", '
|
||||||
|
. '"relevance_score": 0-100, "category": "question|trend|comparison|guide|misconception|safety"}]}' . "\n\n"
|
||||||
|
. "Generate 8-12 findings. Prioritize depth over breadth. Avoid generic or surface-level topics.";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the user prompt by injecting the niche into the configured template.
|
||||||
|
*
|
||||||
|
* @param string $prompt_template User-configured research prompt.
|
||||||
|
* @param string $niche Niche description from settings.
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function build_user_prompt( string $prompt_template, string $niche ): string {
|
||||||
|
$niche_label = '' !== $niche ? $niche : 'your domain';
|
||||||
|
return str_replace( '{niche}', $niche_label, $prompt_template );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the LLM's JSON response into Source_Data objects.
|
||||||
|
*
|
||||||
|
* @param string $content Raw LLM response (JSON).
|
||||||
|
* @param string $model Model that produced the response (stored as author).
|
||||||
|
* @return PRAutoBlogger_Source_Data[]
|
||||||
|
*/
|
||||||
|
private function parse_findings( string $content, string $model ): array {
|
||||||
|
$data = PRAutoBlogger_Json_Extractor::decode( $content );
|
||||||
|
|
||||||
|
if ( ! isset( $data['findings'] ) || ! is_array( $data['findings'] ) ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->error(
|
||||||
|
'LLM Research response was not valid JSON or missing findings key. Raw (first 500 chars): '
|
||||||
|
. mb_substr( $content, 0, 500 ),
|
||||||
|
'llm-research'
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = [];
|
||||||
|
$run_date = gmdate( 'Y-m-d' );
|
||||||
|
|
||||||
|
foreach ( $data['findings'] as $index => $finding ) {
|
||||||
|
$title = sanitize_text_field( $finding['title'] ?? '' );
|
||||||
|
$body = sanitize_textarea_field( $finding['content'] ?? '' );
|
||||||
|
$score = min( 100, max( 0, (int) ( $finding['relevance_score'] ?? 50 ) ) );
|
||||||
|
$category = sanitize_text_field( $finding['category'] ?? 'guide' );
|
||||||
|
|
||||||
|
if ( '' === $title || '' === $body ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// source_id uses date + index so the same day's run deduplicates
|
||||||
|
// but a new day produces fresh entries.
|
||||||
|
$source_id = 'llm_' . $run_date . '_' . $index;
|
||||||
|
|
||||||
|
$items[] = new PRAutoBlogger_Source_Data( [
|
||||||
|
'source_type' => self::SOURCE_TYPE,
|
||||||
|
'source_id' => $source_id,
|
||||||
|
'subreddit' => null,
|
||||||
|
'title' => $title,
|
||||||
|
'content' => $body,
|
||||||
|
'author' => $model,
|
||||||
|
'score' => $score,
|
||||||
|
'comment_count' => 0,
|
||||||
|
'permalink' => null,
|
||||||
|
'collected_at' => current_time( 'mysql' ),
|
||||||
|
'metadata' => [
|
||||||
|
'category' => $category,
|
||||||
|
'data_source' => 'llm_research',
|
||||||
|
'model' => $model,
|
||||||
|
],
|
||||||
|
] );
|
||||||
|
}
|
||||||
|
|
||||||
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
|
sprintf( 'LLM Research produced %d findings via %s.', count( $items ), $model ),
|
||||||
|
'llm-research'
|
||||||
|
);
|
||||||
|
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
}
|
||||||
84
includes/providers/class-open-router-config.php
Normal file
84
includes/providers/class-open-router-config.php
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration helpers for OpenRouter provider.
|
||||||
|
*
|
||||||
|
* Resolves admin-configured settings for API endpoint selection
|
||||||
|
* (direct vs. Cloudflare AI Gateway routing) and caching behavior.
|
||||||
|
*
|
||||||
|
* Dependencies: get_option() for WordPress settings lookups.
|
||||||
|
*
|
||||||
|
* @see class-open-router-provider.php — Parent class that uses this config.
|
||||||
|
* @see class-open-router-validator.php — Also uses API base URL resolution.
|
||||||
|
* @see ARCHITECTURE.md — External API Integrations section.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_OpenRouter_Config {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default direct-to-OpenRouter endpoint.
|
||||||
|
*
|
||||||
|
* Used when no Cloudflare AI Gateway URL is configured. The active endpoint
|
||||||
|
* is resolved per-request via get_api_base_url() so admins can flip to an
|
||||||
|
* AI Gateway proxy without code changes.
|
||||||
|
*/
|
||||||
|
private const DEFAULT_API_BASE_URL = 'https://openrouter.ai/api/v1';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the API base URL.
|
||||||
|
*
|
||||||
|
* If an admin has configured a Cloudflare AI Gateway URL in settings,
|
||||||
|
* we route through that instead of hitting OpenRouter directly. The
|
||||||
|
* gateway is a transparent OpenAI/OpenRouter-compatible proxy that
|
||||||
|
* adds caching, cost logging, rate-limiting, and provider fallback
|
||||||
|
* — see ARCHITECTURE.md "External API Integrations".
|
||||||
|
*
|
||||||
|
* Expected gateway URL shape:
|
||||||
|
* https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}/openrouter
|
||||||
|
*
|
||||||
|
* @return string Base URL with no trailing slash.
|
||||||
|
*/
|
||||||
|
public function get_api_base_url(): string {
|
||||||
|
$override = trim( (string) get_option( 'prautoblogger_ai_gateway_base_url', '' ) );
|
||||||
|
if ( '' === $override ) {
|
||||||
|
return self::DEFAULT_API_BASE_URL;
|
||||||
|
}
|
||||||
|
// Only accept https to avoid plaintext-auth regressions.
|
||||||
|
if ( 0 !== stripos( $override, 'https://' ) ) {
|
||||||
|
return self::DEFAULT_API_BASE_URL;
|
||||||
|
}
|
||||||
|
return rtrim( $override, '/' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache TTL (seconds) for AI Gateway response caching.
|
||||||
|
*
|
||||||
|
* Cloudflare honours the `cf-aig-cache-ttl` request header and serves
|
||||||
|
* cached responses for identical payloads within the TTL window. Only
|
||||||
|
* meaningful when a gateway base URL is configured; harmless otherwise.
|
||||||
|
*
|
||||||
|
* @return int Non-negative integer seconds; 0 disables caching.
|
||||||
|
*/
|
||||||
|
public function get_cache_ttl_seconds(): int {
|
||||||
|
$ttl = (int) get_option( 'prautoblogger_ai_gateway_cache_ttl', 0 );
|
||||||
|
return $ttl > 0 ? $ttl : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if routing through Cloudflare AI Gateway vs. direct-to-OpenRouter.
|
||||||
|
*
|
||||||
|
* @return bool True if a gateway URL is configured.
|
||||||
|
*/
|
||||||
|
public function is_via_gateway(): bool {
|
||||||
|
return self::DEFAULT_API_BASE_URL !== $this->get_api_base_url();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default OpenRouter API base URL constant.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public static function get_default_api_base_url(): string {
|
||||||
|
return self::DEFAULT_API_BASE_URL;
|
||||||
|
}
|
||||||
|
}
|
||||||
179
includes/providers/class-open-router-embedding-provider.php
Normal file
179
includes/providers/class-open-router-embedding-provider.php
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches text embeddings from OpenRouter for semantic similarity comparisons.
|
||||||
|
*
|
||||||
|
* Used by the Idea Scorer to detect semantically similar topics that keyword
|
||||||
|
* matching would miss (e.g. "BPC-157 dosing" vs "how much BPC-157 to take").
|
||||||
|
* OpenRouter exposes an OpenAI-compatible /embeddings endpoint.
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger_Idea_Scorer (semantic dedup check).
|
||||||
|
* Dependencies: OpenRouter API key, OpenRouter_Config, OpenRouter_Request_Builder.
|
||||||
|
*
|
||||||
|
* @see core/class-idea-scorer.php — Consumes embeddings for dedup.
|
||||||
|
* @see providers/class-open-router-config.php — API base URL resolution.
|
||||||
|
* @see providers/class-open-router-request-builder.php — Header + cURL auth.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_OpenRouter_Embedding_Provider {
|
||||||
|
|
||||||
|
/** Default embedding model — small, fast, nearly free ($0.02/M tokens). */
|
||||||
|
private const DEFAULT_MODEL = 'sentence-transformers/all-minilm-l12-v2';
|
||||||
|
|
||||||
|
/** Embedding dimension for MiniLM-L12-v2. Used for validation. */
|
||||||
|
private const EXPECTED_DIMENSIONS = 384;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate embeddings for one or more text inputs in a single API call.
|
||||||
|
*
|
||||||
|
* Batches all inputs into one request to minimize HTTP round-trips.
|
||||||
|
* Returns one embedding vector per input, in the same order.
|
||||||
|
*
|
||||||
|
* Side effects: HTTP request to OpenRouter API.
|
||||||
|
*
|
||||||
|
* @param string[] $texts Array of text strings to embed.
|
||||||
|
* @param string $model Optional model override.
|
||||||
|
*
|
||||||
|
* @return float[][] Array of embedding vectors, one per input text.
|
||||||
|
* Each vector is an array of floats (384 dimensions for MiniLM).
|
||||||
|
*
|
||||||
|
* @throws \RuntimeException On API error or malformed response.
|
||||||
|
*/
|
||||||
|
public function get_embeddings( array $texts, string $model = '' ): array {
|
||||||
|
if ( empty( $texts ) ) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$api_key = $this->get_api_key();
|
||||||
|
if ( '' === $api_key ) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
__( 'OpenRouter API key is not configured.', 'prautoblogger' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$model = '' !== $model ? $model : self::DEFAULT_MODEL;
|
||||||
|
|
||||||
|
$config = new PRAutoBlogger_OpenRouter_Config();
|
||||||
|
$base_url = $config->get_api_base_url();
|
||||||
|
$base_host = (string) wp_parse_url( $base_url, PHP_URL_HOST );
|
||||||
|
|
||||||
|
$builder = new PRAutoBlogger_OpenRouter_Request_Builder();
|
||||||
|
$request_headers = $builder->build_headers( $api_key, $config->is_via_gateway(), 0 );
|
||||||
|
$curl_filter = $builder->register_curl_auth_filter( $request_headers, $base_host );
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = wp_remote_post(
|
||||||
|
$base_url . '/embeddings',
|
||||||
|
[
|
||||||
|
'timeout' => 30,
|
||||||
|
'headers' => $request_headers,
|
||||||
|
'body' => wp_json_encode( [
|
||||||
|
'model' => $model,
|
||||||
|
'input' => array_values( $texts ),
|
||||||
|
] ),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( is_wp_error( $response ) ) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
sprintf( 'Embedding request failed: %s', $response->get_error_message() )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = wp_remote_retrieve_response_code( $response );
|
||||||
|
$body = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||||
|
|
||||||
|
if ( $status >= 400 ) {
|
||||||
|
$msg = $body['error']['message'] ?? wp_remote_retrieve_body( $response );
|
||||||
|
throw new \RuntimeException(
|
||||||
|
sprintf( 'Embedding API error (HTTP %d): %s', $status, $msg )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->parse_response( $body, count( $texts ) );
|
||||||
|
} finally {
|
||||||
|
remove_action( 'http_api_curl', $curl_filter, 99 );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute cosine similarity between two embedding vectors.
|
||||||
|
*
|
||||||
|
* Returns a value between -1 and 1. Values above ~0.7 indicate
|
||||||
|
* high semantic similarity for MiniLM embeddings.
|
||||||
|
*
|
||||||
|
* @param float[] $a First embedding vector.
|
||||||
|
* @param float[] $b Second embedding vector.
|
||||||
|
*
|
||||||
|
* @return float Cosine similarity score.
|
||||||
|
*/
|
||||||
|
public static function cosine_similarity( array $a, array $b ): float {
|
||||||
|
$dot = 0.0;
|
||||||
|
$norm_a = 0.0;
|
||||||
|
$norm_b = 0.0;
|
||||||
|
$len = min( count( $a ), count( $b ) );
|
||||||
|
|
||||||
|
for ( $i = 0; $i < $len; $i++ ) {
|
||||||
|
$dot += $a[ $i ] * $b[ $i ];
|
||||||
|
$norm_a += $a[ $i ] * $a[ $i ];
|
||||||
|
$norm_b += $b[ $i ] * $b[ $i ];
|
||||||
|
}
|
||||||
|
|
||||||
|
$denom = sqrt( $norm_a ) * sqrt( $norm_b );
|
||||||
|
if ( $denom < 1e-10 ) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $dot / $denom;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the OpenAI-compatible embeddings response.
|
||||||
|
*
|
||||||
|
* @param array|null $body Decoded JSON response body.
|
||||||
|
* @param int $expected_count Number of embeddings expected.
|
||||||
|
*
|
||||||
|
* @return float[][] Embedding vectors sorted by input order.
|
||||||
|
*
|
||||||
|
* @throws \RuntimeException On malformed response.
|
||||||
|
*/
|
||||||
|
private function parse_response( ?array $body, int $expected_count ): array {
|
||||||
|
if ( ! isset( $body['data'] ) || ! is_array( $body['data'] ) ) {
|
||||||
|
throw new \RuntimeException( 'Embedding response missing "data" field.' );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( count( $body['data'] ) !== $expected_count ) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
sprintf( 'Expected %d embeddings, got %d.', $expected_count, count( $body['data'] ) )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by index to match input order (API may return out of order).
|
||||||
|
usort( $body['data'], static function ( array $a, array $b ): int {
|
||||||
|
return ( $a['index'] ?? 0 ) <=> ( $b['index'] ?? 0 );
|
||||||
|
} );
|
||||||
|
|
||||||
|
$embeddings = [];
|
||||||
|
foreach ( $body['data'] as $item ) {
|
||||||
|
if ( ! isset( $item['embedding'] ) || ! is_array( $item['embedding'] ) ) {
|
||||||
|
throw new \RuntimeException( 'Embedding item missing "embedding" array.' );
|
||||||
|
}
|
||||||
|
$embeddings[] = array_map( 'floatval', $item['embedding'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
return $embeddings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the decrypted API key from options.
|
||||||
|
*
|
||||||
|
* @return string Decrypted API key, or empty string if not set.
|
||||||
|
*/
|
||||||
|
private function get_api_key(): string {
|
||||||
|
$encrypted = get_option( 'prautoblogger_openrouter_api_key', '' );
|
||||||
|
if ( '' === $encrypted ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return PRAutoBlogger_Encryption::decrypt( $encrypted );
|
||||||
|
}
|
||||||
|
}
|
||||||
294
includes/providers/class-open-router-image-batch.php
Normal file
294
includes/providers/class-open-router-image-batch.php
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parallel image generation via curl_multi for OpenRouter.
|
||||||
|
*
|
||||||
|
* Fires multiple chat/completions requests concurrently using PHP's
|
||||||
|
* curl_multi interface, cutting wall-clock time from N×20s to ~20s
|
||||||
|
* regardless of how many images are requested. This solves the Image B
|
||||||
|
* timeout problem on Hostinger where max_execution_time kills the
|
||||||
|
* cron process before a sequential second request can finish.
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger_OpenRouter_Image_Provider::generate_image_batch().
|
||||||
|
* Dependencies: PRAutoBlogger_OpenRouter_Image_Support (key, parsing),
|
||||||
|
* PRAutoBlogger_OpenRouter_Image_Pricing (model, cost),
|
||||||
|
* PRAutoBlogger_OpenRouter_Config (base URL, gateway),
|
||||||
|
* PRAutoBlogger_OpenRouter_Request_Builder (headers).
|
||||||
|
*
|
||||||
|
* @see class-open-router-image-provider.php — Sole caller.
|
||||||
|
* @see class-open-router-image-support.php — Response parsing.
|
||||||
|
* @see ARCHITECTURE.md — Image pipeline data flow.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_OpenRouter_Image_Batch {
|
||||||
|
|
||||||
|
/** @var PRAutoBlogger_OpenRouter_Image_Pricing */
|
||||||
|
private PRAutoBlogger_OpenRouter_Image_Pricing $pricing;
|
||||||
|
|
||||||
|
/** @var PRAutoBlogger_OpenRouter_Image_Support */
|
||||||
|
private PRAutoBlogger_OpenRouter_Image_Support $support;
|
||||||
|
|
||||||
|
/** @var PRAutoBlogger_OpenRouter_Config */
|
||||||
|
private PRAutoBlogger_OpenRouter_Config $config;
|
||||||
|
|
||||||
|
/** @var PRAutoBlogger_OpenRouter_Request_Builder */
|
||||||
|
private PRAutoBlogger_OpenRouter_Request_Builder $request_builder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param PRAutoBlogger_OpenRouter_Image_Pricing $pricing Cost + model resolver.
|
||||||
|
* @param PRAutoBlogger_OpenRouter_Image_Support $support Key + response parsing.
|
||||||
|
* @param PRAutoBlogger_OpenRouter_Config $config API URL resolution.
|
||||||
|
* @param PRAutoBlogger_OpenRouter_Request_Builder $request_builder Header builder.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
PRAutoBlogger_OpenRouter_Image_Pricing $pricing,
|
||||||
|
PRAutoBlogger_OpenRouter_Image_Support $support,
|
||||||
|
PRAutoBlogger_OpenRouter_Config $config,
|
||||||
|
PRAutoBlogger_OpenRouter_Request_Builder $request_builder
|
||||||
|
) {
|
||||||
|
$this->pricing = $pricing;
|
||||||
|
$this->support = $support;
|
||||||
|
$this->config = $config;
|
||||||
|
$this->request_builder = $request_builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute multiple image generation requests concurrently via curl_multi.
|
||||||
|
*
|
||||||
|
* Each request runs in its own cURL handle. All handles execute in
|
||||||
|
* parallel — the total wall-clock time equals the slowest single
|
||||||
|
* request, not the sum of all requests.
|
||||||
|
*
|
||||||
|
* No retry logic here — each request gets one attempt. Retries would
|
||||||
|
* complicate the multi-handle lifecycle and the single-attempt success
|
||||||
|
* rate on OpenRouter is >98%. If a request fails, its key maps to an
|
||||||
|
* `{error: string}` array.
|
||||||
|
*
|
||||||
|
* @param array<string, array{prompt: string, width: int, height: int, options?: array}> $requests Keyed requests.
|
||||||
|
*
|
||||||
|
* @return array<string, array{
|
||||||
|
* bytes: string, mime_type: string, width: int, height: int,
|
||||||
|
* model: string, seed: ?int, cost_usd: float, latency_ms: int,
|
||||||
|
* }|array{error: string}> Results indexed by the same keys as $requests.
|
||||||
|
*/
|
||||||
|
public function execute( array $requests ): array {
|
||||||
|
if ( empty( $requests ) ) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$api_key = $this->support->get_api_key();
|
||||||
|
$base_url = $this->config->get_api_base_url();
|
||||||
|
$url = $base_url . '/chat/completions';
|
||||||
|
$headers = $this->request_builder->build_headers(
|
||||||
|
$api_key,
|
||||||
|
$this->config->is_via_gateway(),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert associative headers to curl-style indexed array.
|
||||||
|
$curl_headers = [];
|
||||||
|
foreach ( $headers as $name => $value ) {
|
||||||
|
$curl_headers[] = $name . ': ' . $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_init
|
||||||
|
$multi_handle = curl_multi_init();
|
||||||
|
$handles = [];
|
||||||
|
$start_times = [];
|
||||||
|
|
||||||
|
foreach ( $requests as $key => $req ) {
|
||||||
|
$model = $this->pricing->resolve_model( (string) ( $req['options']['model'] ?? '' ) );
|
||||||
|
$payload = $this->build_request_body(
|
||||||
|
$req['prompt'],
|
||||||
|
$req['width'],
|
||||||
|
$req['height'],
|
||||||
|
$model
|
||||||
|
);
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_init
|
||||||
|
$ch = curl_init();
|
||||||
|
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_setopt
|
||||||
|
curl_setopt( $ch, CURLOPT_URL, $url );
|
||||||
|
curl_setopt( $ch, CURLOPT_POST, true );
|
||||||
|
curl_setopt( $ch, CURLOPT_POSTFIELDS, wp_json_encode( $payload ) );
|
||||||
|
curl_setopt( $ch, CURLOPT_HTTPHEADER, $curl_headers );
|
||||||
|
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
|
||||||
|
curl_setopt( $ch, CURLOPT_TIMEOUT, PRAUTOBLOGGER_API_TIMEOUT_SECONDS );
|
||||||
|
curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, 10 );
|
||||||
|
// phpcs:enable WordPress.WP.AlternativeFunctions.curl_curl_setopt
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_add_handle
|
||||||
|
curl_multi_add_handle( $multi_handle, $ch );
|
||||||
|
$handles[ $key ] = [ 'handle' => $ch, 'model' => $model, 'req' => $req ];
|
||||||
|
$start_times[ $key ] = microtime( true );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute all handles concurrently.
|
||||||
|
$this->run_multi( $multi_handle );
|
||||||
|
|
||||||
|
// Collect results.
|
||||||
|
$results = [];
|
||||||
|
foreach ( $handles as $key => $entry ) {
|
||||||
|
$results[ $key ] = $this->collect_result(
|
||||||
|
(string) $key,
|
||||||
|
$entry['handle'],
|
||||||
|
$entry['model'],
|
||||||
|
$entry['req'],
|
||||||
|
$start_times[ $key ]
|
||||||
|
);
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_remove_handle
|
||||||
|
curl_multi_remove_handle( $multi_handle, $entry['handle'] );
|
||||||
|
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_close
|
||||||
|
curl_close( $entry['handle'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_close
|
||||||
|
curl_multi_close( $multi_handle );
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drive curl_multi_exec to completion.
|
||||||
|
*
|
||||||
|
* @param \CurlMultiHandle $mh Multi handle.
|
||||||
|
*/
|
||||||
|
private function run_multi( $mh ): void {
|
||||||
|
$running = 0;
|
||||||
|
do {
|
||||||
|
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_exec
|
||||||
|
$status = curl_multi_exec( $mh, $running );
|
||||||
|
if ( $running > 0 ) {
|
||||||
|
// Block until activity on any handle (up to 1s).
|
||||||
|
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_select
|
||||||
|
curl_multi_select( $mh, 1.0 );
|
||||||
|
}
|
||||||
|
} while ( $running > 0 && CURLM_OK === $status );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the result of one cURL handle into image data or error.
|
||||||
|
*
|
||||||
|
* All failure paths emit `Logger::error` at the point they fold the
|
||||||
|
* request into an `['error' => ...]` array — tagged with the request
|
||||||
|
* `$key` so the pipeline can attribute which of the parallel images
|
||||||
|
* failed (image_a vs image_b) without cross-referencing timestamps.
|
||||||
|
*
|
||||||
|
* @param string $key Request key from the batch map (e.g. "image_a").
|
||||||
|
* @param \CurlHandle $ch Individual cURL handle (completed).
|
||||||
|
* @param string $model Resolved model id.
|
||||||
|
* @param array $req Original request array.
|
||||||
|
* @param float $start Microtime when this handle was created.
|
||||||
|
*
|
||||||
|
* @return array Image data array or `{error: string}`.
|
||||||
|
*
|
||||||
|
* Side effects: writes `Logger::error` on any failure path.
|
||||||
|
*/
|
||||||
|
private function collect_result( string $key, $ch, string $model, array $req, float $start ): array {
|
||||||
|
$latency_ms = (int) ( ( microtime( true ) - $start ) * 1000 );
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_errno
|
||||||
|
$curl_errno = curl_errno( $ch );
|
||||||
|
if ( 0 !== $curl_errno ) {
|
||||||
|
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_error
|
||||||
|
$detail = curl_error( $ch );
|
||||||
|
$msg = sprintf(
|
||||||
|
'OpenRouter batch image gen cURL error %d for key "%s": %s',
|
||||||
|
$curl_errno,
|
||||||
|
$key,
|
||||||
|
$detail
|
||||||
|
);
|
||||||
|
PRAutoBlogger_Logger::instance()->error( $msg, 'openrouter-image-batch' );
|
||||||
|
return [ 'error' => $msg ];
|
||||||
|
}
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_getinfo
|
||||||
|
$http_code = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE );
|
||||||
|
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_getcontent
|
||||||
|
$raw = (string) curl_multi_getcontent( $ch );
|
||||||
|
|
||||||
|
if ( $http_code >= 400 ) {
|
||||||
|
$msg = sprintf(
|
||||||
|
'OpenRouter batch image gen HTTP %d for key "%s": %s',
|
||||||
|
$http_code,
|
||||||
|
$key,
|
||||||
|
substr( $raw, 0, 300 )
|
||||||
|
);
|
||||||
|
PRAutoBlogger_Logger::instance()->error( $msg, 'openrouter-image-batch' );
|
||||||
|
return [ 'error' => $msg ];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$image_bytes = $this->support->extract_image_bytes( $raw );
|
||||||
|
} catch ( \Throwable $e ) {
|
||||||
|
$msg = sprintf(
|
||||||
|
'OpenRouter batch image gen parse error for key "%s": %s',
|
||||||
|
$key,
|
||||||
|
$e->getMessage()
|
||||||
|
);
|
||||||
|
PRAutoBlogger_Logger::instance()->error( $msg, 'openrouter-image-batch' );
|
||||||
|
return [ 'error' => $msg ];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'bytes' => $image_bytes,
|
||||||
|
'mime_type' => 'image/png',
|
||||||
|
'width' => $req['width'],
|
||||||
|
'height' => $req['height'],
|
||||||
|
'model' => $model,
|
||||||
|
'seed' => null,
|
||||||
|
'cost_usd' => $this->pricing->estimate_cost( $req['width'], $req['height'], $model ),
|
||||||
|
'latency_ms' => $latency_ms,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the chat/completions payload for one image request.
|
||||||
|
*
|
||||||
|
* Duplicated from the provider class to keep this file self-contained
|
||||||
|
* without adding a public method to the provider's API surface.
|
||||||
|
*
|
||||||
|
* @param string $prompt Prompt text.
|
||||||
|
* @param int $width Width in pixels.
|
||||||
|
* @param int $height Height in pixels.
|
||||||
|
* @param string $model Model id.
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function build_request_body( string $prompt, int $width, int $height, string $model ): array {
|
||||||
|
$target = $height > 0 ? (float) $width / (float) $height : 1.0;
|
||||||
|
|
||||||
|
// Snap to nearest standard aspect ratio.
|
||||||
|
$aspects = [
|
||||||
|
[ 'w' => 1, 'h' => 1, 'r' => 1.0 ],
|
||||||
|
[ 'w' => 3, 'h' => 2, 'r' => 1.5 ],
|
||||||
|
[ 'w' => 2, 'h' => 3, 'r' => 0.6667 ],
|
||||||
|
[ 'w' => 4, 'h' => 3, 'r' => 1.3333 ],
|
||||||
|
[ 'w' => 3, 'h' => 4, 'r' => 0.75 ],
|
||||||
|
[ 'w' => 16, 'h' => 9, 'r' => 1.7778 ],
|
||||||
|
[ 'w' => 9, 'h' => 16, 'r' => 0.5625 ],
|
||||||
|
];
|
||||||
|
$best = $aspects[0];
|
||||||
|
$best_d = abs( $target - $best['r'] );
|
||||||
|
foreach ( $aspects as $c ) {
|
||||||
|
$d = abs( $target - $c['r'] );
|
||||||
|
if ( $d < $best_d ) {
|
||||||
|
$best = $c;
|
||||||
|
$best_d = $d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$aspect = $best['w'] . ':' . $best['h'];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'model' => $model,
|
||||||
|
'messages' => [
|
||||||
|
[
|
||||||
|
'role' => 'user',
|
||||||
|
'content' => 'Generate an image: ' . $prompt,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'modalities' => [ 'image', 'text' ],
|
||||||
|
'image_config' => [ 'aspect_ratio' => $aspect ],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
94
includes/providers/class-open-router-image-pricing.php
Normal file
94
includes/providers/class-open-router-image-pricing.php
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pricing + model-id resolution helpers for the OpenRouter image provider.
|
||||||
|
*
|
||||||
|
* Mirrors the Runware pricing helper pattern. Each model's cost per image
|
||||||
|
* is a flat rate sourced from OpenRouter's pricing page (April 2026).
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger_OpenRouter_Image_Provider (both generate_image()
|
||||||
|
* and estimate_cost() delegate here).
|
||||||
|
* Dependencies: None — pure calculation + option reads.
|
||||||
|
*
|
||||||
|
* @see class-openrouter-image-provider.php — Caller.
|
||||||
|
* @see class-runware-image-pricing.php — Sibling pattern for Runware.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_OpenRouter_Image_Pricing {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flat per-image cost (USD) for each supported model, April 2026.
|
||||||
|
* Update here when OpenRouter reprices or new models launch.
|
||||||
|
*
|
||||||
|
* @var array<string, float>
|
||||||
|
*/
|
||||||
|
private const COST_PER_IMAGE = [
|
||||||
|
'google/gemini-2.5-flash-image' => 0.005,
|
||||||
|
'google/gemini-3.1-flash-image-preview' => 0.008,
|
||||||
|
'google/gemini-3-pro-image-preview' => 0.03,
|
||||||
|
'openai/gpt-5-image-mini' => 0.02,
|
||||||
|
'openai/gpt-5-image' => 0.08,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the model identifier from a hint, falling back to site option,
|
||||||
|
* then to the PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL constant.
|
||||||
|
*
|
||||||
|
* Never silently swaps to a different model — if the user picked something,
|
||||||
|
* we send it to OpenRouter and let the API error if it's invalid. That way
|
||||||
|
* the user sees a clear log message instead of getting billed for a model
|
||||||
|
* they didn't choose.
|
||||||
|
*
|
||||||
|
* @param string $hint Caller-supplied model identifier (may be empty).
|
||||||
|
* @return string OpenRouter model id.
|
||||||
|
*/
|
||||||
|
public function resolve_model( string $hint ): string {
|
||||||
|
$hint = trim( $hint );
|
||||||
|
if ( '' !== $hint ) {
|
||||||
|
return $hint;
|
||||||
|
}
|
||||||
|
|
||||||
|
$saved = trim( (string) get_option( 'prautoblogger_image_model', '' ) );
|
||||||
|
if ( '' !== $saved ) {
|
||||||
|
return $saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: use the constant from prautoblogger.php (admin can change it).
|
||||||
|
return defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL' )
|
||||||
|
? PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL
|
||||||
|
: 'google/gemini-2.5-flash-image';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate USD cost for a single image at the given model.
|
||||||
|
*
|
||||||
|
* OpenRouter image models use flat per-image pricing, not per-megapixel.
|
||||||
|
* Width/height are accepted for interface compatibility but don't affect cost.
|
||||||
|
*
|
||||||
|
* @param int $width Width in pixels (unused for flat pricing).
|
||||||
|
* @param int $height Height in pixels (unused for flat pricing).
|
||||||
|
* @param string $model Resolved OpenRouter model id.
|
||||||
|
* @return float USD cost rounded to 6 decimals.
|
||||||
|
*/
|
||||||
|
public function estimate_cost( int $width, int $height, string $model ): float {
|
||||||
|
if ( isset( self::COST_PER_IMAGE[ $model ] ) ) {
|
||||||
|
return round( self::COST_PER_IMAGE[ $model ], 6 );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown model — log it and use a conservative $0.05 estimate.
|
||||||
|
PRAutoBlogger_Logger::instance()->warning(
|
||||||
|
sprintf( 'No pricing data for image model "%s". Using $0.05 estimate.', $model ),
|
||||||
|
'openrouter-image'
|
||||||
|
);
|
||||||
|
return 0.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the full model cost table for admin display.
|
||||||
|
*
|
||||||
|
* @return array<string, float> Model id => cost per image in USD.
|
||||||
|
*/
|
||||||
|
public static function get_model_costs(): array {
|
||||||
|
return self::COST_PER_IMAGE;
|
||||||
|
}
|
||||||
|
}
|
||||||
300
includes/providers/class-open-router-image-provider.php
Normal file
300
includes/providers/class-open-router-image-provider.php
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenRouter image provider — model-agnostic, works with any image model.
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger_Image_Pipeline during content generation.
|
||||||
|
* Dependencies: Image_Support (key/parsing), Config (URL), Request_Builder (headers),
|
||||||
|
* Image_Pricing (model/cost), Image_Batch (parallel curl_multi), Logger.
|
||||||
|
*
|
||||||
|
* @see interface-image-provider.php — Contract.
|
||||||
|
* @see class-open-router-image-batch.php — Parallel generation via curl_multi.
|
||||||
|
* @see class-open-router-image-support.php — Key, response parsing, retry.
|
||||||
|
* @see ARCHITECTURE.md — External API Integrations table.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_OpenRouter_Image_Provider implements PRAutoBlogger_Image_Provider_Interface {
|
||||||
|
|
||||||
|
private const PROVIDER_ID = 'openrouter';
|
||||||
|
private const MIN_DIMENSION_PX = 64;
|
||||||
|
private const MAX_DIMENSION_PX = 2048;
|
||||||
|
|
||||||
|
/** @var PRAutoBlogger_OpenRouter_Image_Pricing */
|
||||||
|
private PRAutoBlogger_OpenRouter_Image_Pricing $pricing;
|
||||||
|
|
||||||
|
/** @var PRAutoBlogger_OpenRouter_Image_Support */
|
||||||
|
private PRAutoBlogger_OpenRouter_Image_Support $support;
|
||||||
|
|
||||||
|
/** @var PRAutoBlogger_OpenRouter_Config */
|
||||||
|
private PRAutoBlogger_OpenRouter_Config $config;
|
||||||
|
|
||||||
|
/** @var PRAutoBlogger_OpenRouter_Request_Builder */
|
||||||
|
private PRAutoBlogger_OpenRouter_Request_Builder $request_builder;
|
||||||
|
|
||||||
|
public function __construct() {
|
||||||
|
$this->pricing = new PRAutoBlogger_OpenRouter_Image_Pricing();
|
||||||
|
$this->support = new PRAutoBlogger_OpenRouter_Image_Support();
|
||||||
|
$this->config = new PRAutoBlogger_OpenRouter_Config();
|
||||||
|
$this->request_builder = new PRAutoBlogger_OpenRouter_Request_Builder();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*
|
||||||
|
* Retries 5xx/429 with exponential backoff; 4xx (except 429) fail immediately.
|
||||||
|
*/
|
||||||
|
public function generate_image( string $prompt, int $width, int $height, array $options = [] ): array {
|
||||||
|
$this->assert_valid_dimensions( $width, $height );
|
||||||
|
$prompt = trim( $prompt );
|
||||||
|
if ( '' === $prompt ) {
|
||||||
|
throw new \InvalidArgumentException(
|
||||||
|
esc_html__( 'Image prompt cannot be empty.', 'prautoblogger' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$api_key = $this->support->get_api_key();
|
||||||
|
$model = $this->pricing->resolve_model( (string) ( $options['model'] ?? '' ) );
|
||||||
|
$body = $this->build_request_body( $prompt, $width, $height, $model );
|
||||||
|
|
||||||
|
$base_url = $this->config->get_api_base_url();
|
||||||
|
$url = $base_url . '/chat/completions';
|
||||||
|
$headers = $this->request_builder->build_headers(
|
||||||
|
$api_key,
|
||||||
|
$this->config->is_via_gateway(),
|
||||||
|
0 // No caching for image gen — every call should be unique.
|
||||||
|
);
|
||||||
|
|
||||||
|
$curl_filter = $this->request_builder->register_curl_auth_filter(
|
||||||
|
$headers,
|
||||||
|
wp_parse_url( $base_url, PHP_URL_HOST ) ?: ''
|
||||||
|
);
|
||||||
|
|
||||||
|
$started_at = microtime( true );
|
||||||
|
$last_error = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
for ( $attempt = 1; $attempt <= PRAUTOBLOGGER_MAX_RETRIES; $attempt++ ) {
|
||||||
|
$response = wp_remote_post( $url, [
|
||||||
|
'timeout' => PRAUTOBLOGGER_API_TIMEOUT_SECONDS,
|
||||||
|
'headers' => $headers,
|
||||||
|
'body' => wp_json_encode( $body ),
|
||||||
|
] );
|
||||||
|
|
||||||
|
if ( is_wp_error( $response ) ) {
|
||||||
|
$last_error = $response->get_error_message();
|
||||||
|
$this->support->log_retry( $attempt, 'network', $last_error );
|
||||||
|
$this->support->backoff( $attempt );
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = (int) wp_remote_retrieve_response_code( $response );
|
||||||
|
$raw = (string) wp_remote_retrieve_body( $response );
|
||||||
|
|
||||||
|
if ( 429 === $status || $status >= 500 ) {
|
||||||
|
$last_error = sprintf( 'HTTP %d: %s', $status, substr( $raw, 0, 300 ) );
|
||||||
|
$this->support->log_retry( $attempt, (string) $status, $last_error );
|
||||||
|
if ( $attempt < PRAUTOBLOGGER_MAX_RETRIES ) {
|
||||||
|
$retry_after = (int) wp_remote_retrieve_header( $response, 'retry-after' );
|
||||||
|
$this->support->backoff( $attempt, $retry_after );
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( $status >= 400 ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->error(
|
||||||
|
sprintf( 'OpenRouter image gen HTTP %d: %s', $status, substr( $raw, 0, 300 ) ),
|
||||||
|
'openrouter-image'
|
||||||
|
);
|
||||||
|
throw new \RuntimeException(
|
||||||
|
sprintf(
|
||||||
|
esc_html__( 'OpenRouter image error (HTTP %1$d): %2$s', 'prautoblogger' ),
|
||||||
|
$status,
|
||||||
|
esc_html( substr( $raw, 0, 300 ) )
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$image_bytes = $this->support->extract_image_bytes( $raw );
|
||||||
|
|
||||||
|
return [
|
||||||
|
'bytes' => $image_bytes,
|
||||||
|
'mime_type' => 'image/png',
|
||||||
|
'width' => $width,
|
||||||
|
'height' => $height,
|
||||||
|
'model' => $model,
|
||||||
|
'seed' => null,
|
||||||
|
'cost_usd' => $this->pricing->estimate_cost( $width, $height, $model ),
|
||||||
|
'latency_ms' => (int) ( ( microtime( true ) - $started_at ) * 1000 ),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
remove_action( 'http_api_curl', $curl_filter, 99 );
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \RuntimeException(
|
||||||
|
sprintf(
|
||||||
|
esc_html__( 'OpenRouter image gen failed after %1$d attempts. Last error: %2$s', 'prautoblogger' ),
|
||||||
|
PRAUTOBLOGGER_MAX_RETRIES,
|
||||||
|
esc_html( $last_error )
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate multiple images concurrently via curl_multi.
|
||||||
|
*
|
||||||
|
* Fires all requests in parallel — total wall-clock time equals the
|
||||||
|
* slowest single request, not the sum. Solves the Image B timeout
|
||||||
|
* problem where sequential calls exceed Hostinger's max_execution_time.
|
||||||
|
*
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
public function generate_image_batch( array $requests ): array {
|
||||||
|
// Validate all requests upfront before starting any HTTP calls.
|
||||||
|
foreach ( $requests as $key => $req ) {
|
||||||
|
$this->assert_valid_dimensions( $req['width'], $req['height'] );
|
||||||
|
if ( '' === trim( $req['prompt'] ) ) {
|
||||||
|
throw new \InvalidArgumentException(
|
||||||
|
sprintf( 'Image prompt for key "%s" cannot be empty.', esc_html( $key ) )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$batch = new PRAutoBlogger_OpenRouter_Image_Batch(
|
||||||
|
$this->pricing,
|
||||||
|
$this->support,
|
||||||
|
$this->config,
|
||||||
|
$this->request_builder
|
||||||
|
);
|
||||||
|
|
||||||
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
|
sprintf( 'Starting parallel image batch (%d images)', count( $requests ) ),
|
||||||
|
'openrouter-image'
|
||||||
|
);
|
||||||
|
|
||||||
|
return $batch->execute( $requests );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
public function estimate_cost( int $width, int $height, array $options = [] ): float {
|
||||||
|
$model = $this->pricing->resolve_model( (string) ( $options['model'] ?? '' ) );
|
||||||
|
return $this->pricing->estimate_cost( $width, $height, $model );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
public function get_provider_name(): string {
|
||||||
|
return self::PROVIDER_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} — Calls OpenRouter's /auth/key endpoint. */
|
||||||
|
public function validate_credentials_detailed(): array {
|
||||||
|
try {
|
||||||
|
$api_key = $this->support->get_api_key();
|
||||||
|
} catch ( \RuntimeException $e ) {
|
||||||
|
return [ 'status' => 'error', 'message' => $e->getMessage() ];
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = wp_remote_get( 'https://openrouter.ai/api/v1/auth/key', [
|
||||||
|
'timeout' => 15,
|
||||||
|
'headers' => [ 'Authorization' => 'Bearer ' . $api_key ],
|
||||||
|
] );
|
||||||
|
|
||||||
|
if ( is_wp_error( $response ) ) {
|
||||||
|
return [
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => __( 'Could not reach OpenRouter API.', 'prautoblogger' ),
|
||||||
|
'debug' => $response->get_error_message(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = (int) wp_remote_retrieve_response_code( $response );
|
||||||
|
if ( 200 === $status ) {
|
||||||
|
return [ 'status' => 'ok', 'message' => __( 'OpenRouter API key valid.', 'prautoblogger' ) ];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => sprintf( __( 'OpenRouter returned HTTP %d.', 'prautoblogger' ), $status ),
|
||||||
|
'debug' => substr( (string) wp_remote_retrieve_body( $response ), 0, 200 ),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported aspect ratios for OpenRouter image models.
|
||||||
|
*
|
||||||
|
* @var array<int, array{w: int, h: int, ratio: float}>
|
||||||
|
*/
|
||||||
|
private const STANDARD_ASPECTS = [
|
||||||
|
[ 'w' => 1, 'h' => 1, 'ratio' => 1.0 ],
|
||||||
|
[ 'w' => 3, 'h' => 2, 'ratio' => 1.5 ],
|
||||||
|
[ 'w' => 2, 'h' => 3, 'ratio' => 0.6667 ],
|
||||||
|
[ 'w' => 4, 'h' => 3, 'ratio' => 1.3333 ],
|
||||||
|
[ 'w' => 3, 'h' => 4, 'ratio' => 0.75 ],
|
||||||
|
[ 'w' => 16, 'h' => 9, 'ratio' => 1.7778 ],
|
||||||
|
[ 'w' => 9, 'h' => 16, 'ratio' => 0.5625 ],
|
||||||
|
];
|
||||||
|
|
||||||
|
private function build_request_body( string $prompt, int $width, int $height, string $model ): array {
|
||||||
|
$aspect = $this->snap_aspect_ratio( $width, $height );
|
||||||
|
|
||||||
|
return [
|
||||||
|
'model' => $model,
|
||||||
|
'messages' => [
|
||||||
|
[
|
||||||
|
'role' => 'user',
|
||||||
|
'content' => 'Generate an image: ' . $prompt,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
// Use ["image", "text"] — some providers (Gemini) require both
|
||||||
|
// modalities even when we only want the image output.
|
||||||
|
'modalities' => [ 'image', 'text' ],
|
||||||
|
'image_config' => [
|
||||||
|
'aspect_ratio' => $aspect,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snap pixel dimensions to the nearest supported aspect ratio string.
|
||||||
|
*
|
||||||
|
* @param int $width Width in pixels.
|
||||||
|
* @param int $height Height in pixels.
|
||||||
|
* @return string Aspect ratio string like "16:9".
|
||||||
|
*/
|
||||||
|
private function snap_aspect_ratio( int $width, int $height ): string {
|
||||||
|
$target = $height > 0 ? (float) $width / (float) $height : 1.0;
|
||||||
|
$best = self::STANDARD_ASPECTS[0];
|
||||||
|
$best_d = abs( $target - $best['ratio'] );
|
||||||
|
|
||||||
|
foreach ( self::STANDARD_ASPECTS as $candidate ) {
|
||||||
|
$d = abs( $target - $candidate['ratio'] );
|
||||||
|
if ( $d < $best_d ) {
|
||||||
|
$best = $candidate;
|
||||||
|
$best_d = $d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $best['w'] . ':' . $best['h'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforce the interface's dimension contract.
|
||||||
|
*
|
||||||
|
* @param int $width Width in pixels.
|
||||||
|
* @param int $height Height in pixels.
|
||||||
|
* @throws \InvalidArgumentException If outside [MIN, MAX].
|
||||||
|
*/
|
||||||
|
private function assert_valid_dimensions( int $width, int $height ): void {
|
||||||
|
if (
|
||||||
|
$width < self::MIN_DIMENSION_PX || $width > self::MAX_DIMENSION_PX ||
|
||||||
|
$height < self::MIN_DIMENSION_PX || $height > self::MAX_DIMENSION_PX
|
||||||
|
) {
|
||||||
|
throw new \InvalidArgumentException(
|
||||||
|
sprintf(
|
||||||
|
esc_html__( 'Image dimensions %1$dx%2$d outside supported range (%3$d–%4$d px).', 'prautoblogger' ),
|
||||||
|
$width, $height, self::MIN_DIMENSION_PX, self::MAX_DIMENSION_PX
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
154
includes/providers/class-open-router-image-support.php
Normal file
154
includes/providers/class-open-router-image-support.php
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Support helpers for the OpenRouter image provider — API key lookup,
|
||||||
|
* response parsing, retry backoff, and logging.
|
||||||
|
*
|
||||||
|
* Split from the provider to keep both files under the 300-line cap.
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger_OpenRouter_Image_Provider only.
|
||||||
|
* Dependencies: PRAutoBlogger_Encryption, PRAutoBlogger_Logger.
|
||||||
|
*
|
||||||
|
* @see class-openrouter-image-provider.php — Sole caller.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_OpenRouter_Image_Support {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the decrypted OpenRouter API key from settings.
|
||||||
|
*
|
||||||
|
* @return string Plaintext key.
|
||||||
|
* @throws \RuntimeException If key is not configured or decrypt fails.
|
||||||
|
*/
|
||||||
|
public function get_api_key(): string {
|
||||||
|
$stored = (string) get_option( 'prautoblogger_openrouter_api_key', '' );
|
||||||
|
if ( '' === $stored ) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
__( 'OpenRouter API key is not configured. Go to PRAutoBlogger → Settings.', 'prautoblogger' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( PRAutoBlogger_Encryption::is_encrypted( $stored ) ) {
|
||||||
|
$decrypted = PRAutoBlogger_Encryption::decrypt( $stored );
|
||||||
|
if ( '' === $decrypted ) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
__( 'OpenRouter API key could not be decrypted. Re-enter in Settings.', 'prautoblogger' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return $decrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract raw image bytes from OpenRouter's chat/completions response.
|
||||||
|
*
|
||||||
|
* The response contains choices[0].message.images[0].image_url.url
|
||||||
|
* as a data URI: "data:image/png;base64,{bytes}".
|
||||||
|
*
|
||||||
|
* @param string $raw Raw JSON response body.
|
||||||
|
* @return string Decoded image bytes.
|
||||||
|
* @throws \RuntimeException If the response doesn't contain an image.
|
||||||
|
*/
|
||||||
|
public function extract_image_bytes( string $raw ): string {
|
||||||
|
$decoded = json_decode( $raw, true );
|
||||||
|
if ( ! is_array( $decoded ) ) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
esc_html__( 'OpenRouter returned invalid JSON.', 'prautoblogger' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenRouter routers may wrap upstream errors in an HTTP 200 body.
|
||||||
|
if ( isset( $decoded['error']['message'] ) ) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
sprintf(
|
||||||
|
esc_html__( 'OpenRouter upstream image error: %s', 'prautoblogger' ),
|
||||||
|
esc_html( $decoded['error']['message'] )
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$images = $decoded['choices'][0]['message']['images'] ?? [];
|
||||||
|
if ( empty( $images ) ) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
esc_html__( 'OpenRouter response contained no images.', 'prautoblogger' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data_url = $images[0]['image_url']['url'] ?? '';
|
||||||
|
if ( '' === $data_url ) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
esc_html__( 'OpenRouter image URL is empty.', 'prautoblogger' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$comma_pos = strpos( $data_url, ',' );
|
||||||
|
if ( false === $comma_pos ) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
esc_html__( 'OpenRouter image data URI is malformed.', 'prautoblogger' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$b64 = substr( $data_url, $comma_pos + 1 );
|
||||||
|
$bytes = base64_decode( $b64, true );
|
||||||
|
if ( false === $bytes || '' === $bytes ) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
esc_html__( 'OpenRouter image base64 decode failed.', 'prautoblogger' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit a WARN line for a retryable image-gen failure.
|
||||||
|
*
|
||||||
|
* @param int $attempt 1-based attempt number.
|
||||||
|
* @param string $class 'network' or HTTP status code.
|
||||||
|
* @param string $detail Short failure detail.
|
||||||
|
*/
|
||||||
|
public function log_retry( int $attempt, string $class, string $detail ): void {
|
||||||
|
PRAutoBlogger_Logger::instance()->warning(
|
||||||
|
sprintf(
|
||||||
|
'OpenRouter image gen %s failure (attempt %d/%d): %s',
|
||||||
|
$class,
|
||||||
|
$attempt,
|
||||||
|
PRAUTOBLOGGER_MAX_RETRIES,
|
||||||
|
$detail
|
||||||
|
),
|
||||||
|
'openrouter-image'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sleep between retries with exponential backoff.
|
||||||
|
*
|
||||||
|
* @param int $attempt 1-based attempt that just failed.
|
||||||
|
* @param int $retry_after Retry-After header value (0 if absent).
|
||||||
|
*/
|
||||||
|
public function backoff( int $attempt, int $retry_after = 0 ): void {
|
||||||
|
$delay = $retry_after > 0
|
||||||
|
? min( $retry_after, 60 )
|
||||||
|
: PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS * (int) pow( 2, $attempt - 1 );
|
||||||
|
if ( $delay > 0 ) {
|
||||||
|
sleep( $delay );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Greatest common divisor (for aspect ratio calculation).
|
||||||
|
*
|
||||||
|
* @param int $a First number.
|
||||||
|
* @param int $b Second number.
|
||||||
|
* @return int GCD.
|
||||||
|
*/
|
||||||
|
public function gcd( int $a, int $b ): int {
|
||||||
|
while ( 0 !== $b ) {
|
||||||
|
$t = $b;
|
||||||
|
$b = $a % $b;
|
||||||
|
$a = $t;
|
||||||
|
}
|
||||||
|
return max( $a, 1 );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -114,6 +114,11 @@ class PRAutoBlogger_OpenRouter_Pricing {
|
|||||||
* @return float Estimated cost in USD.
|
* @return float Estimated cost in USD.
|
||||||
*/
|
*/
|
||||||
public function estimate_cost( string $model, int $prompt_tokens, int $completion_tokens ): float {
|
public function estimate_cost( string $model, int $prompt_tokens, int $completion_tokens ): float {
|
||||||
|
// OpenRouter free models: the `openrouter/free` router and any `:free` suffix model.
|
||||||
|
if ( 'openrouter/free' === $model || ':free' === substr( $model, -5 ) ) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
$pricing = self::MODEL_PRICING[ $model ] ?? null;
|
$pricing = self::MODEL_PRICING[ $model ] ?? null;
|
||||||
|
|
||||||
// Fall back to cached model list pricing.
|
// Fall back to cached model list pricing.
|
||||||
|
|||||||
@@ -11,60 +11,17 @@ declare(strict_types=1);
|
|||||||
* Triggered by: Content_Analyzer, Content_Generator, Chief_Editor, Metrics_Collector.
|
* Triggered by: Content_Analyzer, Content_Generator, Chief_Editor, Metrics_Collector.
|
||||||
* Dependencies: PRAutoBlogger_Encryption (for API key decryption), wp_remote_post(), PRAutoBlogger_OpenRouter_Pricing.
|
* Dependencies: PRAutoBlogger_Encryption (for API key decryption), wp_remote_post(), PRAutoBlogger_OpenRouter_Pricing.
|
||||||
*
|
*
|
||||||
* @see interface-llm-provider.php — Interface this class implements.
|
* @see interface-llm-provider.php — Interface this class implements.
|
||||||
* @see class-open-router-pricing.php — Pricing and model list lookups.
|
* @see class-open-router-config.php — Config helpers (base URL, cache TTL).
|
||||||
* @see class-cost-tracker.php — Called after every request to log token usage.
|
* @see class-open-router-pricing.php — Pricing and model list lookups.
|
||||||
* @see ARCHITECTURE.md — Data flow diagram showing where this fits.
|
* @see class-open-router-validator.php — Credential validation (delegated).
|
||||||
|
* @see class-open-router-request-builder.php — Request header building + cURL filter.
|
||||||
|
* @see class-open-router-response-parser.php — Response parsing + error classification.
|
||||||
|
* @see class-cost-tracker.php — Called after every request to log token usage.
|
||||||
|
* @see ARCHITECTURE.md — Data flow diagram showing where this fits.
|
||||||
*/
|
*/
|
||||||
class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_Interface {
|
class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_Interface {
|
||||||
|
|
||||||
/**
|
|
||||||
* Default direct-to-OpenRouter endpoint. Used when no Cloudflare AI Gateway
|
|
||||||
* URL is configured. The active endpoint is resolved per-request via
|
|
||||||
* get_api_base_url() so admins can flip to an AI Gateway proxy without code.
|
|
||||||
*/
|
|
||||||
private const DEFAULT_API_BASE_URL = 'https://openrouter.ai/api/v1';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the API base URL.
|
|
||||||
*
|
|
||||||
* If an admin has configured a Cloudflare AI Gateway URL in settings,
|
|
||||||
* we route through that instead of hitting OpenRouter directly. The
|
|
||||||
* gateway is a transparent OpenAI/OpenRouter-compatible proxy that
|
|
||||||
* adds caching, cost logging, rate-limiting, and provider fallback
|
|
||||||
* — see ARCHITECTURE.md "External API Integrations".
|
|
||||||
*
|
|
||||||
* Expected gateway URL shape:
|
|
||||||
* https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}/openrouter
|
|
||||||
*
|
|
||||||
* @return string Base URL with no trailing slash.
|
|
||||||
*/
|
|
||||||
private function get_api_base_url(): string {
|
|
||||||
$override = trim( (string) get_option( 'prautoblogger_ai_gateway_base_url', '' ) );
|
|
||||||
if ( '' === $override ) {
|
|
||||||
return self::DEFAULT_API_BASE_URL;
|
|
||||||
}
|
|
||||||
// Only accept https to avoid plaintext-auth regressions.
|
|
||||||
if ( 0 !== stripos( $override, 'https://' ) ) {
|
|
||||||
return self::DEFAULT_API_BASE_URL;
|
|
||||||
}
|
|
||||||
return rtrim( $override, '/' );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache TTL (seconds) for AI Gateway response caching.
|
|
||||||
*
|
|
||||||
* Cloudflare honours the `cf-aig-cache-ttl` request header and serves
|
|
||||||
* cached responses for identical payloads within the TTL window. Only
|
|
||||||
* meaningful when a gateway base URL is configured; harmless otherwise.
|
|
||||||
*
|
|
||||||
* @return int Non-negative integer seconds; 0 disables caching.
|
|
||||||
*/
|
|
||||||
private function get_cache_ttl_seconds(): int {
|
|
||||||
$ttl = (int) get_option( 'prautoblogger_ai_gateway_cache_ttl', 0 );
|
|
||||||
return $ttl > 0 ? $ttl : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a chat completion request to OpenRouter.
|
* Send a chat completion request to OpenRouter.
|
||||||
*
|
*
|
||||||
@@ -79,7 +36,8 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
|
|||||||
* temperature?: float,
|
* temperature?: float,
|
||||||
* max_tokens?: int,
|
* max_tokens?: int,
|
||||||
* response_format?: array{type: string},
|
* response_format?: array{type: string},
|
||||||
* } $options Optional parameters.
|
* reasoning?: array{enabled: bool, effort?: string},
|
||||||
|
* } $options Optional parameters. Pass 'reasoning' to override the global setting.
|
||||||
*
|
*
|
||||||
* @return array{
|
* @return array{
|
||||||
* content: string,
|
* content: string,
|
||||||
@@ -131,51 +89,31 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
|
|||||||
$body['response_format'] = $options['response_format'];
|
$body['response_format'] = $options['response_format'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$last_error = '';
|
// Reasoning mode: explicit caller override takes priority, then global setting.
|
||||||
|
if ( isset( $options['reasoning'] ) ) {
|
||||||
$base_url = $this->get_api_base_url();
|
$body['reasoning'] = $options['reasoning'];
|
||||||
$base_host = (string) wp_parse_url( $base_url, PHP_URL_HOST );
|
} elseif ( '1' === get_option( 'prautoblogger_reasoning_enabled', '0' ) ) {
|
||||||
$cache_ttl = $this->get_cache_ttl_seconds();
|
$body['reasoning'] = [
|
||||||
$via_gateway = self::DEFAULT_API_BASE_URL !== $base_url;
|
'enabled' => true,
|
||||||
|
'effort' => get_option( 'prautoblogger_reasoning_effort', 'medium' ),
|
||||||
// Build headers once so the wp_remote_post call and the cURL belt-and-
|
];
|
||||||
// suspenders filter stay in lockstep. The cf-aig-* headers are only
|
|
||||||
// meaningful when going through a Cloudflare AI Gateway; they are
|
|
||||||
// ignored by direct OpenRouter calls, so sending them unconditionally
|
|
||||||
// when a gateway is configured is safe and keeps the code branch-free.
|
|
||||||
$request_headers = [
|
|
||||||
'Authorization' => 'Bearer ' . $api_key,
|
|
||||||
'Content-Type' => 'application/json',
|
|
||||||
'HTTP-Referer' => home_url(),
|
|
||||||
'X-Title' => 'PRAutoBlogger WordPress Plugin',
|
|
||||||
];
|
|
||||||
if ( $via_gateway && $cache_ttl > 0 ) {
|
|
||||||
$request_headers['cf-aig-cache-ttl'] = (string) $cache_ttl;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Belt-and-suspenders: inject Authorization header at cURL level.
|
$last_error = '';
|
||||||
// Some hosting environments (Hostinger, certain proxies) strip the
|
|
||||||
// Authorization header from wp_remote_post's 'headers' array before
|
$config = new PRAutoBlogger_OpenRouter_Config();
|
||||||
// the request is sent. The http_api_curl action fires after WordPress
|
$base_url = $config->get_api_base_url();
|
||||||
// configures the cURL handle but before curl_exec — re-setting
|
$base_host = (string) wp_parse_url( $base_url, PHP_URL_HOST );
|
||||||
// CURLOPT_HTTPHEADER here ensures the header reaches the upstream
|
$cache_ttl = $config->get_cache_ttl_seconds();
|
||||||
// (either OpenRouter directly or the Cloudflare AI Gateway proxy).
|
$via_gateway = $config->is_via_gateway();
|
||||||
$curl_auth_filter = function ( $handle, $parsed_args, $url ) use ( $request_headers, $base_host ): void {
|
|
||||||
// Scope to the configured upstream host only — never leak auth
|
$builder = new PRAutoBlogger_OpenRouter_Request_Builder();
|
||||||
// into unrelated outbound requests made elsewhere in WordPress.
|
$request_headers = $builder->build_headers( $api_key, $via_gateway, $cache_ttl );
|
||||||
if ( '' === $base_host || false === strpos( (string) $url, $base_host ) ) {
|
$curl_auth_filter = $builder->register_curl_auth_filter( $request_headers, $base_host );
|
||||||
return;
|
|
||||||
}
|
|
||||||
$curl_headers = [];
|
|
||||||
foreach ( $request_headers as $name => $value ) {
|
|
||||||
$curl_headers[] = $name . ': ' . $value;
|
|
||||||
}
|
|
||||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt
|
|
||||||
curl_setopt( $handle, CURLOPT_HTTPHEADER, $curl_headers );
|
|
||||||
};
|
|
||||||
add_action( 'http_api_curl', $curl_auth_filter, 99, 3 );
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
$parser = new PRAutoBlogger_OpenRouter_Response_Parser();
|
||||||
|
|
||||||
for ( $attempt = 1; $attempt <= PRAUTOBLOGGER_MAX_RETRIES; $attempt++ ) {
|
for ( $attempt = 1; $attempt <= PRAUTOBLOGGER_MAX_RETRIES; $attempt++ ) {
|
||||||
$response = wp_remote_post(
|
$response = wp_remote_post(
|
||||||
$base_url . '/chat/completions',
|
$base_url . '/chat/completions',
|
||||||
@@ -205,7 +143,7 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
|
|||||||
$data = json_decode( $body_raw, true );
|
$data = json_decode( $body_raw, true );
|
||||||
|
|
||||||
// Rate limited (429) or server error (5xx) — retry with backoff.
|
// Rate limited (429) or server error (5xx) — retry with backoff.
|
||||||
if ( 429 === $status_code || $status_code >= 500 ) {
|
if ( $parser->is_retryable( $status_code ) ) {
|
||||||
$last_error = sprintf( 'HTTP %d: %s', $status_code, $body_raw );
|
$last_error = sprintf( 'HTTP %d: %s', $status_code, $body_raw );
|
||||||
PRAutoBlogger_Logger::instance()->warning(
|
PRAutoBlogger_Logger::instance()->warning(
|
||||||
sprintf( 'OpenRouter HTTP %d (attempt %d/%d): %s', $status_code, $attempt, PRAUTOBLOGGER_MAX_RETRIES, substr( $body_raw, 0, 500 ) ),
|
sprintf( 'OpenRouter HTTP %d (attempt %d/%d): %s', $status_code, $attempt, PRAUTOBLOGGER_MAX_RETRIES, substr( $body_raw, 0, 500 ) ),
|
||||||
@@ -225,9 +163,7 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
|
|||||||
|
|
||||||
// Client error — don't retry, fail immediately.
|
// Client error — don't retry, fail immediately.
|
||||||
if ( $status_code >= 400 ) {
|
if ( $status_code >= 400 ) {
|
||||||
$error_msg = isset( $data['error']['message'] )
|
$error_msg = $parser->get_error_message( $data, $status_code );
|
||||||
? $data['error']['message']
|
|
||||||
: 'HTTP ' . $status_code;
|
|
||||||
|
|
||||||
// Log detailed diagnostic info for auth errors to aid debugging.
|
// Log detailed diagnostic info for auth errors to aid debugging.
|
||||||
if ( 401 === $status_code || 403 === $status_code ) {
|
if ( 401 === $status_code || 403 === $status_code ) {
|
||||||
@@ -254,22 +190,7 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Success — parse response.
|
// Success — parse response.
|
||||||
if ( ! isset( $data['choices'][0]['message']['content'] ) ) {
|
return $parser->parse_success( $data, $model );
|
||||||
throw new \RuntimeException(
|
|
||||||
__( 'OpenRouter returned unexpected response format.', 'prautoblogger' )
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$usage = $data['usage'] ?? [];
|
|
||||||
|
|
||||||
return [
|
|
||||||
'content' => $data['choices'][0]['message']['content'],
|
|
||||||
'model' => $data['model'] ?? $model,
|
|
||||||
'prompt_tokens' => (int) ( $usage['prompt_tokens'] ?? 0 ),
|
|
||||||
'completion_tokens' => (int) ( $usage['completion_tokens'] ?? 0 ),
|
|
||||||
'total_tokens' => (int) ( $usage['total_tokens'] ?? 0 ),
|
|
||||||
'finish_reason' => $data['choices'][0]['finish_reason'] ?? 'unknown',
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// All retries exhausted.
|
// All retries exhausted.
|
||||||
@@ -327,9 +248,9 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
|
|||||||
/**
|
/**
|
||||||
* Validate that the OpenRouter API key is configured and working.
|
* Validate that the OpenRouter API key is configured and working.
|
||||||
*
|
*
|
||||||
* Makes a lightweight request to the /auth/key endpoint.
|
* Delegates to PRAutoBlogger_OpenRouter_Validator for the actual checks.
|
||||||
*
|
*
|
||||||
* Side effects: HTTP request to OpenRouter, logs diagnostic info.
|
* Side effects: HTTP request to OpenRouter (via validator), logs diagnostic info.
|
||||||
*
|
*
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
@@ -341,112 +262,16 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
|
|||||||
/**
|
/**
|
||||||
* Validate credentials with detailed diagnostic info.
|
* Validate credentials with detailed diagnostic info.
|
||||||
*
|
*
|
||||||
* Returns an array with status ('ok' or 'error') and a human-readable
|
* Delegates to PRAutoBlogger_OpenRouter_Validator which returns an array
|
||||||
* message explaining what went wrong if validation fails.
|
* with status ('ok' or 'error') and a human-readable message explaining
|
||||||
|
* what went wrong if validation fails.
|
||||||
*
|
*
|
||||||
* Side effects: HTTP request to OpenRouter, logs diagnostic info.
|
* Side effects: HTTP request to OpenRouter (via validator), logs diagnostic info.
|
||||||
*
|
*
|
||||||
* @return array{status: string, message: string, debug?: string}
|
* @return array{status: string, message: string, debug?: string}
|
||||||
*/
|
*/
|
||||||
public function validate_credentials_detailed(): array {
|
public function validate_credentials_detailed(): array {
|
||||||
$encrypted = get_option( 'prautoblogger_openrouter_api_key', '' );
|
return ( new PRAutoBlogger_OpenRouter_Validator() )->run();
|
||||||
if ( '' === $encrypted ) {
|
|
||||||
return [
|
|
||||||
'status' => 'error',
|
|
||||||
'message' => __( 'No API key saved. Enter your OpenRouter key in settings.', 'prautoblogger' ),
|
|
||||||
'debug' => 'option_empty',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$api_key = PRAutoBlogger_Encryption::decrypt( $encrypted );
|
|
||||||
if ( '' === $api_key ) {
|
|
||||||
return [
|
|
||||||
'status' => 'error',
|
|
||||||
'message' => __( 'API key decryption failed. Re-enter your key.', 'prautoblogger' ),
|
|
||||||
'debug' => 'decrypt_failed:encrypted_len=' . strlen( $encrypted ),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanity check: OpenRouter keys typically start with "sk-or-".
|
|
||||||
$key_prefix = substr( $api_key, 0, 6 );
|
|
||||||
$key_len = strlen( $api_key );
|
|
||||||
|
|
||||||
if ( 0 !== strpos( $api_key, 'sk-or-' ) ) {
|
|
||||||
PRAutoBlogger_Logger::instance()->error(
|
|
||||||
sprintf(
|
|
||||||
'API key format invalid (prefix="%s", len=%d). Likely salt changed — re-enter key.',
|
|
||||||
$key_prefix,
|
|
||||||
$key_len
|
|
||||||
),
|
|
||||||
'openrouter'
|
|
||||||
);
|
|
||||||
return [
|
|
||||||
'status' => 'error',
|
|
||||||
'message' => __( 'API key appears corrupted (decryption produced invalid data). Your WordPress auth salt may have changed. Please re-enter your OpenRouter API key in settings.', 'prautoblogger' ),
|
|
||||||
'debug' => sprintf( 'bad_format:prefix=%s,len=%d', $key_prefix, $key_len ),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Belt-and-suspenders: inject Authorization at cURL level (see send_chat_completion).
|
|
||||||
$base_url = $this->get_api_base_url();
|
|
||||||
$base_host = (string) wp_parse_url( $base_url, PHP_URL_HOST );
|
|
||||||
$auth_header_value = 'Bearer ' . $api_key;
|
|
||||||
$curl_auth_filter_cred = function ( $handle, $parsed_args, $url ) use ( $auth_header_value, $base_host ): void {
|
|
||||||
if ( '' === $base_host || false === strpos( (string) $url, $base_host ) ) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt
|
|
||||||
curl_setopt( $handle, CURLOPT_HTTPHEADER, [
|
|
||||||
'Authorization: ' . $auth_header_value,
|
|
||||||
] );
|
|
||||||
};
|
|
||||||
add_action( 'http_api_curl', $curl_auth_filter_cred, 99, 3 );
|
|
||||||
|
|
||||||
$response = wp_remote_get(
|
|
||||||
$base_url . '/auth/key',
|
|
||||||
[
|
|
||||||
'timeout' => 15,
|
|
||||||
'headers' => [
|
|
||||||
'Authorization' => 'Bearer ' . $api_key,
|
|
||||||
],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
remove_action( 'http_api_curl', $curl_auth_filter_cred, 99 );
|
|
||||||
|
|
||||||
if ( is_wp_error( $response ) ) {
|
|
||||||
$err_msg = $response->get_error_message();
|
|
||||||
PRAutoBlogger_Logger::instance()->error(
|
|
||||||
sprintf( 'OpenRouter credential check wp_error: %s (key_prefix=%s, key_len=%d)', $err_msg, $key_prefix, $key_len ),
|
|
||||||
'openrouter'
|
|
||||||
);
|
|
||||||
return [
|
|
||||||
'status' => 'error',
|
|
||||||
'message' => sprintf( __( 'Network error reaching OpenRouter: %s', 'prautoblogger' ), $err_msg ),
|
|
||||||
'debug' => 'wp_error:' . $err_msg,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$status_code = wp_remote_retrieve_response_code( $response );
|
|
||||||
$body_raw = wp_remote_retrieve_body( $response );
|
|
||||||
|
|
||||||
if ( 200 === $status_code ) {
|
|
||||||
return [
|
|
||||||
'status' => 'ok',
|
|
||||||
'message' => __( 'OpenRouter connected.', 'prautoblogger' ),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
PRAutoBlogger_Logger::instance()->warning(
|
|
||||||
sprintf( 'OpenRouter credential check HTTP %d (key_prefix=%s, key_len=%d): %s', $status_code, $key_prefix, $key_len, substr( $body_raw, 0, 300 ) ),
|
|
||||||
'openrouter'
|
|
||||||
);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'status' => 'error',
|
|
||||||
'message' => sprintf( __( 'OpenRouter returned HTTP %d. %s', 'prautoblogger' ), $status_code, substr( $body_raw, 0, 200 ) ),
|
|
||||||
'debug' => sprintf( 'http_%d:key_prefix=%s,key_len=%d', $status_code, $key_prefix, $key_len ),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
79
includes/providers/class-open-router-request-builder.php
Normal file
79
includes/providers/class-open-router-request-builder.php
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request builder for OpenRouter API calls.
|
||||||
|
*
|
||||||
|
* Encapsulates the logic for building HTTP request headers, including
|
||||||
|
* Cloudflare AI Gateway caching directives and belt-and-suspenders
|
||||||
|
* cURL header injection to work around authorization header stripping
|
||||||
|
* in certain hosting environments.
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger_OpenRouter_Provider::send_chat_completion()
|
||||||
|
* Dependencies: add_action(), curl_setopt() (via http_api_curl filter).
|
||||||
|
*
|
||||||
|
* @see class-open-router-provider.php — Parent class that uses this builder.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_OpenRouter_Request_Builder {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build request headers for OpenRouter API call.
|
||||||
|
*
|
||||||
|
* Includes Authorization, Content-Type, HTTP-Referer, and optional
|
||||||
|
* Cloudflare AI Gateway cache control headers.
|
||||||
|
*
|
||||||
|
* @param string $api_key Decrypted OpenRouter API key.
|
||||||
|
* @param bool $via_gateway Whether the request is routed through Cloudflare AI Gateway.
|
||||||
|
* @param int $cache_ttl Cache TTL in seconds (0 disables caching).
|
||||||
|
*
|
||||||
|
* @return array<string, string> HTTP headers ready for wp_remote_post().
|
||||||
|
*/
|
||||||
|
public function build_headers( string $api_key, bool $via_gateway, int $cache_ttl ): array {
|
||||||
|
$headers = [
|
||||||
|
'Authorization' => 'Bearer ' . $api_key,
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
'HTTP-Referer' => home_url(),
|
||||||
|
'X-Title' => 'PRAutoBlogger WordPress Plugin',
|
||||||
|
];
|
||||||
|
|
||||||
|
if ( $via_gateway && $cache_ttl > 0 ) {
|
||||||
|
$headers['cf-aig-cache-ttl'] = (string) $cache_ttl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a cURL filter to inject Authorization header.
|
||||||
|
*
|
||||||
|
* Some hosting environments (Hostinger, certain proxies) strip the
|
||||||
|
* Authorization header from wp_remote_post's 'headers' array before
|
||||||
|
* the request is sent. The http_api_curl action fires after WordPress
|
||||||
|
* configures the cURL handle but before curl_exec — setting
|
||||||
|
* CURLOPT_HTTPHEADER here ensures the header reaches the upstream.
|
||||||
|
*
|
||||||
|
* Side effects: Adds an http_api_curl filter (caller must remove).
|
||||||
|
*
|
||||||
|
* @param array $request_headers Request headers (includes Authorization).
|
||||||
|
* @param string $base_host Upstream host (scopes the filter to avoid leaking auth).
|
||||||
|
*
|
||||||
|
* @return callable The filter function (for later removal via remove_action).
|
||||||
|
*/
|
||||||
|
public function register_curl_auth_filter( array $request_headers, string $base_host ): callable {
|
||||||
|
$curl_auth_filter = function ( $handle, $parsed_args, $url ) use ( $request_headers, $base_host ): void {
|
||||||
|
// Scope to the configured upstream host only — never leak auth
|
||||||
|
// into unrelated outbound requests made elsewhere in WordPress.
|
||||||
|
if ( '' === $base_host || false === strpos( (string) $url, $base_host ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$curl_headers = [];
|
||||||
|
foreach ( $request_headers as $name => $value ) {
|
||||||
|
$curl_headers[] = $name . ': ' . $value;
|
||||||
|
}
|
||||||
|
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt
|
||||||
|
curl_setopt( $handle, CURLOPT_HTTPHEADER, $curl_headers );
|
||||||
|
};
|
||||||
|
add_action( 'http_api_curl', $curl_auth_filter, 99, 3 );
|
||||||
|
return $curl_auth_filter;
|
||||||
|
}
|
||||||
|
}
|
||||||
130
includes/providers/class-open-router-response-parser.php
Normal file
130
includes/providers/class-open-router-response-parser.php
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response parser for OpenRouter API responses.
|
||||||
|
*
|
||||||
|
* Encapsulates the logic for parsing and validating OpenRouter chat completion
|
||||||
|
* responses, including error classification and retry-decision logic.
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger_OpenRouter_Provider::send_chat_completion()
|
||||||
|
* Dependencies: PRAutoBlogger_Logger (for error/warning logging).
|
||||||
|
*
|
||||||
|
* @see class-open-router-provider.php — Parent class that uses this parser.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_OpenRouter_Response_Parser {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if an HTTP status code indicates a retryable error.
|
||||||
|
*
|
||||||
|
* Returns true if the failure is transient (5xx, 429) and the retry loop
|
||||||
|
* should continue with backoff. Returns false for 4xx errors, which should
|
||||||
|
* fail immediately without retry.
|
||||||
|
*
|
||||||
|
* @param int $status_code HTTP status code.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function is_retryable( int $status_code ): bool {
|
||||||
|
return 429 === $status_code || $status_code >= 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a successful OpenRouter response.
|
||||||
|
*
|
||||||
|
* Validates the response contains the required fields and extracts
|
||||||
|
* the completion content and token usage.
|
||||||
|
*
|
||||||
|
* @param array $data Decoded JSON response from OpenRouter.
|
||||||
|
* @param string $model The model that was requested (fallback if not in response).
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* content: string,
|
||||||
|
* model: string,
|
||||||
|
* prompt_tokens: int,
|
||||||
|
* completion_tokens: int,
|
||||||
|
* reasoning_tokens: int,
|
||||||
|
* total_tokens: int,
|
||||||
|
* finish_reason: string,
|
||||||
|
* reasoning_content: string,
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @throws \RuntimeException If response structure is invalid.
|
||||||
|
*/
|
||||||
|
public function parse_success( array $data, string $model ): array {
|
||||||
|
// OpenRouter routers (e.g. openrouter/free) may wrap upstream provider
|
||||||
|
// errors inside an HTTP 200 body with an `error` object. Surface them
|
||||||
|
// as RuntimeExceptions so the retry loop or caller can handle them.
|
||||||
|
if ( isset( $data['error']['message'] ) ) {
|
||||||
|
$upstream_code = (int) ( $data['error']['code'] ?? 0 );
|
||||||
|
throw new \RuntimeException(
|
||||||
|
sprintf(
|
||||||
|
/* translators: %1$d: upstream HTTP code, %2$s: error message */
|
||||||
|
__( 'OpenRouter upstream error (code %1$d): %2$s', 'prautoblogger' ),
|
||||||
|
$upstream_code,
|
||||||
|
$data['error']['message']
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate we have a choices array with at least one message.
|
||||||
|
if ( ! isset( $data['choices'][0]['message'] ) ) {
|
||||||
|
// Log the raw shape so we can diagnose what the model actually returned.
|
||||||
|
PRAutoBlogger_Logger::instance()->error(
|
||||||
|
sprintf(
|
||||||
|
'Unexpected response shape from %s: %s',
|
||||||
|
$data['model'] ?? $model,
|
||||||
|
substr( (string) wp_json_encode( $data ), 0, 500 )
|
||||||
|
),
|
||||||
|
'openrouter'
|
||||||
|
);
|
||||||
|
throw new \RuntimeException(
|
||||||
|
sprintf(
|
||||||
|
/* translators: %s: model name */
|
||||||
|
__( 'OpenRouter model %s returned unexpected response format.', 'prautoblogger' ),
|
||||||
|
$data['model'] ?? $model
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content can be null (some models use tool_calls or refusal instead).
|
||||||
|
// Treat null as empty string — downstream callers handle empty content.
|
||||||
|
$content = $data['choices'][0]['message']['content'] ?? '';
|
||||||
|
if ( null === $content ) {
|
||||||
|
$content = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$usage = $data['usage'] ?? [];
|
||||||
|
|
||||||
|
// Reasoning content lives alongside 'content' in the message object.
|
||||||
|
$reasoning_content = $data['choices'][0]['message']['reasoning'] ?? '';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'content' => (string) $content,
|
||||||
|
'model' => $data['model'] ?? $model,
|
||||||
|
'prompt_tokens' => (int) ( $usage['prompt_tokens'] ?? 0 ),
|
||||||
|
'completion_tokens' => (int) ( $usage['completion_tokens'] ?? 0 ),
|
||||||
|
'reasoning_tokens' => (int) ( $usage['reasoning_tokens'] ?? 0 ),
|
||||||
|
'total_tokens' => (int) ( $usage['total_tokens'] ?? 0 ),
|
||||||
|
'finish_reason' => $data['choices'][0]['finish_reason'] ?? 'unknown',
|
||||||
|
'reasoning_content' => (string) $reasoning_content,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the error message from a failed response.
|
||||||
|
*
|
||||||
|
* Prefers the `error.message` field if present; falls back to the
|
||||||
|
* HTTP status code as a string.
|
||||||
|
*
|
||||||
|
* @param array $data Decoded JSON response (may be empty or malformed).
|
||||||
|
* @param int $status HTTP status code.
|
||||||
|
*
|
||||||
|
* @return string Human-readable error message.
|
||||||
|
*/
|
||||||
|
public function get_error_message( array $data, int $status ): string {
|
||||||
|
return isset( $data['error']['message'] )
|
||||||
|
? $data['error']['message']
|
||||||
|
: 'HTTP ' . $status;
|
||||||
|
}
|
||||||
|
}
|
||||||
152
includes/providers/class-open-router-validator.php
Normal file
152
includes/providers/class-open-router-validator.php
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Credential + connectivity validator for the OpenRouter LLM provider.
|
||||||
|
*
|
||||||
|
* Split out of the provider to keep each file under the 300-line cap and
|
||||||
|
* to keep the validation path easy to unit-test without spinning up the
|
||||||
|
* retry loop. The provider's `validate_credentials_detailed()` delegates
|
||||||
|
* straight to `run()` here.
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger_OpenRouter_Provider::validate_credentials_detailed()
|
||||||
|
* which is called by the admin "Test Connection" action.
|
||||||
|
* Dependencies: PRAutoBlogger_OpenRouter_Config (URL resolution),
|
||||||
|
* PRAutoBlogger_Encryption (API key decryption),
|
||||||
|
* PRAutoBlogger_Logger (WARN/ERROR lines), wp_remote_get().
|
||||||
|
*
|
||||||
|
* @see class-open-router-provider.php Parent class that delegates here.
|
||||||
|
* @see class-open-router-config.php Config helpers for base URL.
|
||||||
|
* @see interface-llm-provider.php Return-shape contract.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_OpenRouter_Validator {
|
||||||
|
|
||||||
|
private const TIMEOUT_SECONDS = 15;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a non-destructive credential check.
|
||||||
|
*
|
||||||
|
* Lightweight probe: hits the /auth/key endpoint. Never makes a real
|
||||||
|
* LLM completion request — that would consume tokens on every click.
|
||||||
|
*
|
||||||
|
* Side effects: one HTTP GET; one Logger line on failure.
|
||||||
|
*
|
||||||
|
* @return array{status: string, message: string, debug?: string}
|
||||||
|
*/
|
||||||
|
public function run(): array {
|
||||||
|
$encrypted = get_option( 'prautoblogger_openrouter_api_key', '' );
|
||||||
|
if ( '' === $encrypted ) {
|
||||||
|
return $this->err(
|
||||||
|
'option_empty',
|
||||||
|
__( 'No API key saved. Enter your OpenRouter key in settings.', 'prautoblogger' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$api_key = PRAutoBlogger_Encryption::decrypt( $encrypted );
|
||||||
|
if ( '' === $api_key ) {
|
||||||
|
return $this->err(
|
||||||
|
'decrypt_failed:encrypted_len=' . strlen( $encrypted ),
|
||||||
|
__( 'API key decryption failed. Re-enter your key.', 'prautoblogger' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanity check: OpenRouter keys typically start with "sk-or-".
|
||||||
|
$key_prefix = substr( $api_key, 0, 6 );
|
||||||
|
$key_len = strlen( $api_key );
|
||||||
|
|
||||||
|
if ( 0 !== strpos( $api_key, 'sk-or-' ) ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->error(
|
||||||
|
sprintf(
|
||||||
|
'API key format invalid (prefix="%s", len=%d). Likely salt changed — re-enter key.',
|
||||||
|
$key_prefix,
|
||||||
|
$key_len
|
||||||
|
),
|
||||||
|
'openrouter'
|
||||||
|
);
|
||||||
|
return $this->err(
|
||||||
|
sprintf( 'bad_format:prefix=%s,len=%d', $key_prefix, $key_len ),
|
||||||
|
__( 'API key appears corrupted (decryption produced invalid data). Your WordPress auth salt may have changed. Please re-enter your OpenRouter API key in settings.', 'prautoblogger' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Belt-and-suspenders: inject Authorization at cURL level (see send_chat_completion).
|
||||||
|
$config = new PRAutoBlogger_OpenRouter_Config();
|
||||||
|
$base_url = $config->get_api_base_url();
|
||||||
|
$base_host = (string) wp_parse_url( $base_url, PHP_URL_HOST );
|
||||||
|
$auth_header_value = 'Bearer ' . $api_key;
|
||||||
|
$curl_auth_filter_cred = function ( $handle, $parsed_args, $url ) use ( $auth_header_value, $base_host ): void {
|
||||||
|
if ( '' === $base_host || false === strpos( (string) $url, $base_host ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt
|
||||||
|
curl_setopt( $handle, CURLOPT_HTTPHEADER, [
|
||||||
|
'Authorization: ' . $auth_header_value,
|
||||||
|
] );
|
||||||
|
};
|
||||||
|
add_action( 'http_api_curl', $curl_auth_filter_cred, 99, 3 );
|
||||||
|
|
||||||
|
$response = wp_remote_get(
|
||||||
|
$base_url . '/auth/key',
|
||||||
|
[
|
||||||
|
'timeout' => self::TIMEOUT_SECONDS,
|
||||||
|
'headers' => [
|
||||||
|
'Authorization' => 'Bearer ' . $api_key,
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
remove_action( 'http_api_curl', $curl_auth_filter_cred, 99 );
|
||||||
|
|
||||||
|
if ( is_wp_error( $response ) ) {
|
||||||
|
$err_msg = $response->get_error_message();
|
||||||
|
PRAutoBlogger_Logger::instance()->error(
|
||||||
|
sprintf( 'OpenRouter credential check wp_error: %s (key_prefix=%s, key_len=%d)', $err_msg, $key_prefix, $key_len ),
|
||||||
|
'openrouter'
|
||||||
|
);
|
||||||
|
return $this->err(
|
||||||
|
'wp_error:' . $err_msg,
|
||||||
|
sprintf(
|
||||||
|
/* translators: %s: transport-layer error message. */
|
||||||
|
esc_html__( 'Network error reaching OpenRouter: %s', 'prautoblogger' ),
|
||||||
|
esc_html( $err_msg )
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$status_code = wp_remote_retrieve_response_code( $response );
|
||||||
|
if ( 200 === $status_code ) {
|
||||||
|
return [
|
||||||
|
'status' => 'ok',
|
||||||
|
'message' => __( 'OpenRouter connected.', 'prautoblogger' ),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$body_raw = wp_remote_retrieve_body( $response );
|
||||||
|
PRAutoBlogger_Logger::instance()->warning(
|
||||||
|
sprintf( 'OpenRouter credential check HTTP %d (key_prefix=%s, key_len=%d): %s', $status_code, $key_prefix, $key_len, substr( $body_raw, 0, 300 ) ),
|
||||||
|
'openrouter'
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->err(
|
||||||
|
sprintf( 'http_%d:key_prefix=%s,key_len=%d', $status_code, $key_prefix, $key_len ),
|
||||||
|
sprintf(
|
||||||
|
/* translators: %1$d: HTTP status, %2$s: response body. */
|
||||||
|
esc_html__( 'OpenRouter returned HTTP %1$d. %2$s', 'prautoblogger' ),
|
||||||
|
$status_code,
|
||||||
|
esc_html( substr( $body_raw, 0, 200 ) )
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{status: string, message: string, debug: string}
|
||||||
|
*/
|
||||||
|
private function err( string $debug, string $message ): array {
|
||||||
|
return [
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => $message,
|
||||||
|
'debug' => $debug,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,10 +128,11 @@ class PRAutoBlogger_Reddit_Provider implements PRAutoBlogger_Source_Provider_Int
|
|||||||
sprintf( 'Reddit r/%s: fetched %d items', $subreddit, $added ),
|
sprintf( 'Reddit r/%s: fetched %d items', $subreddit, $added ),
|
||||||
'reddit'
|
'reddit'
|
||||||
);
|
);
|
||||||
} catch ( \Exception $e ) {
|
} catch ( \Throwable $e ) {
|
||||||
PRAutoBlogger_Logger::instance()->error(
|
PRAutoBlogger_Logger::instance()->error(
|
||||||
sprintf(
|
sprintf(
|
||||||
'Reddit: Failed to collect from r/%s: %s',
|
'Reddit: %s collecting from r/%s: %s',
|
||||||
|
get_class( $e ),
|
||||||
$subreddit,
|
$subreddit,
|
||||||
$e->getMessage()
|
$e->getMessage()
|
||||||
),
|
),
|
||||||
|
|||||||
294
includes/providers/class-runware-image-batch.php
Normal file
294
includes/providers/class-runware-image-batch.php
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_init
|
||||||
|
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_setopt
|
||||||
|
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_close
|
||||||
|
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_errno
|
||||||
|
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_error
|
||||||
|
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_getinfo
|
||||||
|
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_init
|
||||||
|
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_add_handle
|
||||||
|
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_exec
|
||||||
|
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_select
|
||||||
|
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_getcontent
|
||||||
|
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_remove_handle
|
||||||
|
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_close
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parallel image generation via curl_multi for Runware.
|
||||||
|
*
|
||||||
|
* Fires multiple POST /v1 inference calls concurrently, then fetches
|
||||||
|
* the returned signed image URLs in a second parallel round. Cuts
|
||||||
|
* wall-clock time from N×2s to ~2s regardless of batch size — matches
|
||||||
|
* the Image A/B parallelism pattern already in use for OpenRouter.
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger_Runware_Image_Provider::generate_image_batch().
|
||||||
|
* Dependencies: PRAutoBlogger_Runware_Image_Support, PRAutoBlogger_Runware_Image_Pricing,
|
||||||
|
* PRAutoBlogger_Logger.
|
||||||
|
*
|
||||||
|
* @see class-runware-image-provider.php — Sole caller.
|
||||||
|
* @see class-runware-image-support.php — Response parsing + image URL fetch.
|
||||||
|
* @see class-open-router-image-batch.php — Sibling pattern for OpenRouter.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Runware_Image_Batch {
|
||||||
|
|
||||||
|
/** @var PRAutoBlogger_Runware_Image_Pricing */
|
||||||
|
private PRAutoBlogger_Runware_Image_Pricing $pricing;
|
||||||
|
|
||||||
|
/** @var PRAutoBlogger_Runware_Image_Support */
|
||||||
|
private PRAutoBlogger_Runware_Image_Support $support;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
PRAutoBlogger_Runware_Image_Pricing $pricing,
|
||||||
|
PRAutoBlogger_Runware_Image_Support $support
|
||||||
|
) {
|
||||||
|
$this->pricing = $pricing;
|
||||||
|
$this->support = $support;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute multiple image generation requests concurrently via curl_multi.
|
||||||
|
*
|
||||||
|
* Two parallel rounds: (1) POST /v1 returns signed imageURL per request;
|
||||||
|
* (2) GET each signed imageURL returns PNG bytes. Each round runs
|
||||||
|
* concurrently. No retry — single-attempt success rate on schnell >99%.
|
||||||
|
*
|
||||||
|
* @param array<string, array{prompt: string, width: int, height: int, options?: array}> $requests Keyed requests.
|
||||||
|
* @return array<string, array> Image data or `{error}` keyed identically.
|
||||||
|
*/
|
||||||
|
public function execute( array $requests ): array {
|
||||||
|
if ( empty( $requests ) ) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$api_key = $this->support->get_api_key();
|
||||||
|
$round1 = $this->round_one_inference( $api_key, $requests );
|
||||||
|
$results = [];
|
||||||
|
$to_download = [];
|
||||||
|
|
||||||
|
foreach ( $round1 as $key => $entry ) {
|
||||||
|
if ( isset( $entry['error'] ) ) {
|
||||||
|
$results[ $key ] = [ 'error' => $entry['error'] ];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$to_download[ $key ] = $entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( empty( $to_download ) ) {
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bytes_map = $this->round_two_download( $to_download );
|
||||||
|
foreach ( $to_download as $key => $entry ) {
|
||||||
|
if ( isset( $bytes_map[ $key ]['error'] ) ) {
|
||||||
|
$results[ $key ] = [ 'error' => $bytes_map[ $key ]['error'] ];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$results[ $key ] = [
|
||||||
|
'bytes' => $bytes_map[ $key ]['bytes'],
|
||||||
|
'mime_type' => 'image/png',
|
||||||
|
'width' => $entry['width'],
|
||||||
|
'height' => $entry['height'],
|
||||||
|
'model' => $entry['model'],
|
||||||
|
'seed' => $entry['seed'],
|
||||||
|
'cost_usd' => $this->pricing->estimate_cost( $entry['width'], $entry['height'], $entry['model'] ),
|
||||||
|
'latency_ms' => (int) ( ( microtime( true ) - $entry['started_at'] ) * 1000 ),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Round 1: fire all inference POSTs in parallel.
|
||||||
|
*
|
||||||
|
* @param string $api_key Plaintext API key.
|
||||||
|
* @param array $requests Batch requests.
|
||||||
|
* @return array<string, array> Map of key => {error} | {image_url, width, height, model, seed, started_at}.
|
||||||
|
*/
|
||||||
|
private function round_one_inference( string $api_key, array $requests ): array {
|
||||||
|
$headers = [ 'Content-Type: application/json' ];
|
||||||
|
$mh = curl_multi_init();
|
||||||
|
$handles = [];
|
||||||
|
$start_times = [];
|
||||||
|
|
||||||
|
foreach ( $requests as $key => $req ) {
|
||||||
|
$model = $this->pricing->resolve_model( (string) ( $req['options']['model'] ?? '' ) );
|
||||||
|
$snap_w = $this->support->snap_dimension( (int) $req['width'] );
|
||||||
|
$snap_h = $this->support->snap_dimension( (int) $req['height'] );
|
||||||
|
$seed = $this->support->normalize_seed( isset( $req['options']['seed'] ) ? (int) $req['options']['seed'] : null );
|
||||||
|
$steps = isset( $req['options']['steps'] ) ? (int) $req['options']['steps'] : $this->pricing->default_steps( $model );
|
||||||
|
|
||||||
|
$ch = curl_init();
|
||||||
|
curl_setopt( $ch, CURLOPT_URL, PRAutoBlogger_Runware_Image_Support::API_URL );
|
||||||
|
curl_setopt( $ch, CURLOPT_POST, true );
|
||||||
|
curl_setopt( $ch, CURLOPT_POSTFIELDS, wp_json_encode(
|
||||||
|
$this->build_payload( $api_key, (string) $req['prompt'], $snap_w, $snap_h, $model, $seed, $steps )
|
||||||
|
) );
|
||||||
|
curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers );
|
||||||
|
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
|
||||||
|
curl_setopt( $ch, CURLOPT_TIMEOUT, PRAUTOBLOGGER_API_TIMEOUT_SECONDS );
|
||||||
|
curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, 10 );
|
||||||
|
|
||||||
|
curl_multi_add_handle( $mh, $ch );
|
||||||
|
$handles[ $key ] = [
|
||||||
|
'handle' => $ch,
|
||||||
|
'model' => $model,
|
||||||
|
'width' => $snap_w,
|
||||||
|
'height' => $snap_h,
|
||||||
|
'seed' => $seed,
|
||||||
|
];
|
||||||
|
$start_times[ $key ] = microtime( true );
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->run_multi( $mh );
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ( $handles as $key => $entry ) {
|
||||||
|
$out[ $key ] = $this->collect_inference_result( (string) $key, $entry, $start_times[ $key ] );
|
||||||
|
curl_multi_remove_handle( $mh, $entry['handle'] );
|
||||||
|
curl_close( $entry['handle'] );
|
||||||
|
}
|
||||||
|
curl_multi_close( $mh );
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Round 2: fetch all signed image URLs in parallel.
|
||||||
|
*
|
||||||
|
* @param array<string, array> $inferences Map from round one.
|
||||||
|
* @return array<string, array{bytes?: string, error?: string}>
|
||||||
|
*/
|
||||||
|
private function round_two_download( array $inferences ): array {
|
||||||
|
$mh = curl_multi_init();
|
||||||
|
$handles = [];
|
||||||
|
|
||||||
|
foreach ( $inferences as $key => $entry ) {
|
||||||
|
$ch = curl_init();
|
||||||
|
curl_setopt( $ch, CURLOPT_URL, $entry['image_url'] );
|
||||||
|
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
|
||||||
|
curl_setopt( $ch, CURLOPT_TIMEOUT, 30 );
|
||||||
|
curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, 10 );
|
||||||
|
curl_multi_add_handle( $mh, $ch );
|
||||||
|
$handles[ $key ] = $ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->run_multi( $mh );
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ( $handles as $key => $ch ) {
|
||||||
|
$errno = curl_errno( $ch );
|
||||||
|
if ( 0 !== $errno ) {
|
||||||
|
$msg = sprintf( 'Runware image download cURL error %d for "%s": %s', $errno, $key, curl_error( $ch ) );
|
||||||
|
PRAutoBlogger_Logger::instance()->error( $msg, 'runware-image-batch' );
|
||||||
|
$out[ $key ] = [ 'error' => $msg ];
|
||||||
|
} else {
|
||||||
|
$code = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE );
|
||||||
|
$body = (string) curl_multi_getcontent( $ch );
|
||||||
|
if ( 200 !== $code || '' === $body ) {
|
||||||
|
$msg = sprintf( 'Runware image download HTTP %d for "%s" (%d bytes)', $code, $key, strlen( $body ) );
|
||||||
|
PRAutoBlogger_Logger::instance()->error( $msg, 'runware-image-batch' );
|
||||||
|
$out[ $key ] = [ 'error' => $msg ];
|
||||||
|
} else {
|
||||||
|
$out[ $key ] = [ 'bytes' => $body ];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
curl_multi_remove_handle( $mh, $ch );
|
||||||
|
curl_close( $ch );
|
||||||
|
}
|
||||||
|
curl_multi_close( $mh );
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drive curl_multi_exec to completion.
|
||||||
|
*
|
||||||
|
* @param \CurlMultiHandle $mh Multi handle.
|
||||||
|
*/
|
||||||
|
private function run_multi( $mh ): void {
|
||||||
|
$running = 0;
|
||||||
|
do {
|
||||||
|
$status = curl_multi_exec( $mh, $running );
|
||||||
|
if ( $running > 0 ) {
|
||||||
|
curl_multi_select( $mh, 1.0 );
|
||||||
|
}
|
||||||
|
} while ( $running > 0 && CURLM_OK === $status );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the auth+inference task-array body for one image request.
|
||||||
|
*
|
||||||
|
* @param string $api_key Plaintext API key.
|
||||||
|
* @param string $prompt Prompt text.
|
||||||
|
* @param int $width Snapped width.
|
||||||
|
* @param int $height Snapped height.
|
||||||
|
* @param string $model Resolved model id.
|
||||||
|
* @param int $seed Positive seed.
|
||||||
|
* @param int $steps Inference steps.
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function build_payload( string $api_key, string $prompt, int $width, int $height, string $model, int $seed, int $steps ): array {
|
||||||
|
return [
|
||||||
|
[ 'taskType' => 'authentication', 'apiKey' => $api_key ],
|
||||||
|
[
|
||||||
|
'taskType' => 'imageInference',
|
||||||
|
'taskUUID' => wp_generate_uuid4(),
|
||||||
|
'positivePrompt' => $prompt,
|
||||||
|
'width' => $width,
|
||||||
|
'height' => $height,
|
||||||
|
'model' => $model,
|
||||||
|
'steps' => $steps,
|
||||||
|
'numberResults' => 1,
|
||||||
|
'outputFormat' => 'PNG',
|
||||||
|
'outputType' => 'URL',
|
||||||
|
'seed' => $seed,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse one round-1 handle into either an error or an imageURL entry.
|
||||||
|
*
|
||||||
|
* @param string $key Request key.
|
||||||
|
* @param array $entry Handle entry (handle + model + dims + seed).
|
||||||
|
* @param float $start Microtime when this handle was queued.
|
||||||
|
* @return array Either {error} or {image_url, width, height, model, seed, started_at}.
|
||||||
|
*/
|
||||||
|
private function collect_inference_result( string $key, array $entry, float $start ): array {
|
||||||
|
$ch = $entry['handle'];
|
||||||
|
$errno = curl_errno( $ch );
|
||||||
|
if ( 0 !== $errno ) {
|
||||||
|
$msg = sprintf( 'Runware batch inference cURL error %d for "%s": %s', $errno, $key, curl_error( $ch ) );
|
||||||
|
PRAutoBlogger_Logger::instance()->error( $msg, 'runware-image-batch' );
|
||||||
|
return [ 'error' => $msg ];
|
||||||
|
}
|
||||||
|
|
||||||
|
$http_code = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE );
|
||||||
|
$raw = (string) curl_multi_getcontent( $ch );
|
||||||
|
if ( $http_code >= 400 ) {
|
||||||
|
$msg = sprintf( 'Runware batch inference HTTP %d for "%s": %s', $http_code, $key, substr( $raw, 0, 300 ) );
|
||||||
|
PRAutoBlogger_Logger::instance()->error( $msg, 'runware-image-batch' );
|
||||||
|
return [ 'error' => $msg ];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$image_url = $this->support->extract_image_url( $raw );
|
||||||
|
} catch ( \Throwable $e ) {
|
||||||
|
$msg = sprintf( 'Runware batch inference parse error for "%s": %s', $key, $e->getMessage() );
|
||||||
|
PRAutoBlogger_Logger::instance()->error( $msg, 'runware-image-batch' );
|
||||||
|
return [ 'error' => $msg ];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'image_url' => $image_url,
|
||||||
|
'width' => (int) $entry['width'],
|
||||||
|
'height' => (int) $entry['height'],
|
||||||
|
'model' => (string) $entry['model'],
|
||||||
|
'seed' => (int) $entry['seed'],
|
||||||
|
'started_at' => $start,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
118
includes/providers/class-runware-image-pricing.php
Normal file
118
includes/providers/class-runware-image-pricing.php
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pricing + model-id resolution helpers for the Runware image provider.
|
||||||
|
*
|
||||||
|
* Each Runware FLUX model has a flat per-image cost sourced from
|
||||||
|
* runware.ai/pricing (April 2026). Unlike OpenRouter models, Runware
|
||||||
|
* prices scale slightly with steps — the quoted rate here assumes the
|
||||||
|
* provider's default step count (4 for schnell, 28 for dev).
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger_Runware_Image_Provider, PRAutoBlogger_Runware_Image_Batch.
|
||||||
|
* Dependencies: None — pure calculation + option reads.
|
||||||
|
*
|
||||||
|
* @see class-runware-image-provider.php — Primary caller.
|
||||||
|
* @see class-open-router-image-pricing.php — Sibling pattern for OpenRouter.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Runware_Image_Pricing {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flat per-image cost (USD) for each supported Runware model, April 2026.
|
||||||
|
* Update here when Runware reprices or new models launch.
|
||||||
|
*
|
||||||
|
* Runware model IDs use `family:variant@version` form. The two FLUX
|
||||||
|
* variants we support are the CEO-sanctioned defaults.
|
||||||
|
*
|
||||||
|
* @var array<string, float>
|
||||||
|
*/
|
||||||
|
private const COST_PER_IMAGE = [
|
||||||
|
// FLUX.1 schnell — 4-step distilled. Our new default (2026-04-21).
|
||||||
|
'runware:100@1' => 0.0006,
|
||||||
|
// FLUX.1 dev — 28-step full model. Higher fidelity, ~30x cost.
|
||||||
|
'runware:101@1' => 0.02,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default number of inference steps per model. Runware bills per image
|
||||||
|
* at the model's standard step count; custom step counts may alter price.
|
||||||
|
*
|
||||||
|
* @var array<string, int>
|
||||||
|
*/
|
||||||
|
private const DEFAULT_STEPS = [
|
||||||
|
'runware:100@1' => 4,
|
||||||
|
'runware:101@1' => 28,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the model identifier from a hint, falling back to site option,
|
||||||
|
* then to the PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL constant.
|
||||||
|
*
|
||||||
|
* If the saved/default model is not a Runware id (e.g. still points at
|
||||||
|
* a Gemini slug), fall back to schnell — the provider was selected
|
||||||
|
* explicitly, so the user wants a Runware model.
|
||||||
|
*
|
||||||
|
* @param string $hint Caller-supplied model identifier (may be empty).
|
||||||
|
* @return string Runware model id.
|
||||||
|
*/
|
||||||
|
public function resolve_model( string $hint ): string {
|
||||||
|
$candidate = trim( $hint );
|
||||||
|
if ( '' === $candidate ) {
|
||||||
|
$candidate = trim( (string) get_option( 'prautoblogger_image_model', '' ) );
|
||||||
|
}
|
||||||
|
if ( '' === $candidate && defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL' ) ) {
|
||||||
|
$candidate = (string) PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the candidate isn't a known Runware id, fall back to schnell.
|
||||||
|
if ( '' === $candidate || ! isset( self::COST_PER_IMAGE[ $candidate ] ) ) {
|
||||||
|
return 'runware:100@1';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate USD cost for a single image at the given model.
|
||||||
|
*
|
||||||
|
* Runware FLUX pricing is flat per image at the model's standard step
|
||||||
|
* count. Width/height are accepted for interface compatibility but do
|
||||||
|
* not affect cost for the variants we support.
|
||||||
|
*
|
||||||
|
* @param int $width Width in pixels (unused for flat pricing).
|
||||||
|
* @param int $height Height in pixels (unused for flat pricing).
|
||||||
|
* @param string $model Resolved Runware model id.
|
||||||
|
* @return float USD cost rounded to 6 decimals.
|
||||||
|
*/
|
||||||
|
public function estimate_cost( int $width, int $height, string $model ): float {
|
||||||
|
if ( isset( self::COST_PER_IMAGE[ $model ] ) ) {
|
||||||
|
return round( self::COST_PER_IMAGE[ $model ], 6 );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown model — log it and use a conservative $0.01 estimate.
|
||||||
|
PRAutoBlogger_Logger::instance()->warning(
|
||||||
|
sprintf( 'No Runware pricing data for model "%s". Using $0.01 estimate.', $model ),
|
||||||
|
'runware-image'
|
||||||
|
);
|
||||||
|
return 0.01;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default step count for a Runware model.
|
||||||
|
*
|
||||||
|
* @param string $model Resolved Runware model id.
|
||||||
|
* @return int Default steps.
|
||||||
|
*/
|
||||||
|
public function default_steps( string $model ): int {
|
||||||
|
return self::DEFAULT_STEPS[ $model ] ?? 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the full model cost table for admin display.
|
||||||
|
*
|
||||||
|
* @return array<string, float> Model id => cost per image in USD.
|
||||||
|
*/
|
||||||
|
public static function get_model_costs(): array {
|
||||||
|
return self::COST_PER_IMAGE;
|
||||||
|
}
|
||||||
|
}
|
||||||
294
includes/providers/class-runware-image-provider.php
Normal file
294
includes/providers/class-runware-image-provider.php
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runware image provider — FLUX.1 schnell / dev via runware.ai.
|
||||||
|
*
|
||||||
|
* Default image backend as of 2026-04-21: schnell (runware:100@1) at
|
||||||
|
* ~$0.0006/image, ~2s latency. 65× cheaper than Gemini Nano Banana with
|
||||||
|
* quality that reads as "stylistic looseness" for our comic aesthetic.
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger_Image_Pipeline during content generation.
|
||||||
|
* Dependencies: Runware_Image_Support (key/parsing/backoff),
|
||||||
|
* Runware_Image_Pricing (model/cost),
|
||||||
|
* Runware_Image_Batch (parallel curl_multi), Logger.
|
||||||
|
*
|
||||||
|
* @see interface-image-provider.php — Contract.
|
||||||
|
* @see class-runware-image-batch.php — Parallel generation via curl_multi.
|
||||||
|
* @see class-runware-image-support.php — Key, response parsing, retry.
|
||||||
|
* @see class-runware-image-pricing.php — Model + cost table.
|
||||||
|
* @see class-open-router-image-provider.php — Sibling pattern for OpenRouter.
|
||||||
|
* @see ARCHITECTURE.md — External API Integrations table.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Runware_Image_Provider implements PRAutoBlogger_Image_Provider_Interface {
|
||||||
|
|
||||||
|
private const PROVIDER_ID = 'runware';
|
||||||
|
private const MIN_DIMENSION_PX = 64;
|
||||||
|
private const MAX_DIMENSION_PX = 2048;
|
||||||
|
|
||||||
|
/** @var PRAutoBlogger_Runware_Image_Pricing */
|
||||||
|
private PRAutoBlogger_Runware_Image_Pricing $pricing;
|
||||||
|
|
||||||
|
/** @var PRAutoBlogger_Runware_Image_Support */
|
||||||
|
private PRAutoBlogger_Runware_Image_Support $support;
|
||||||
|
|
||||||
|
public function __construct() {
|
||||||
|
$this->pricing = new PRAutoBlogger_Runware_Image_Pricing();
|
||||||
|
$this->support = new PRAutoBlogger_Runware_Image_Support();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*
|
||||||
|
* Two-step HTTP flow: POST /v1 returns a signed imageURL, then GET that
|
||||||
|
* URL for the bytes. Retries 5xx/429 on the POST with exponential
|
||||||
|
* backoff; 4xx (except 429) fail immediately. The image download GET
|
||||||
|
* is not retried — signed URLs are short-lived.
|
||||||
|
*/
|
||||||
|
public function generate_image( string $prompt, int $width, int $height, array $options = [] ): array {
|
||||||
|
$this->assert_valid_dimensions( $width, $height );
|
||||||
|
$prompt = trim( $prompt );
|
||||||
|
if ( '' === $prompt ) {
|
||||||
|
throw new \InvalidArgumentException(
|
||||||
|
esc_html__( 'Image prompt cannot be empty.', 'prautoblogger' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$api_key = $this->support->get_api_key();
|
||||||
|
$model = $this->pricing->resolve_model( (string) ( $options['model'] ?? '' ) );
|
||||||
|
$snap_w = $this->support->snap_dimension( $width );
|
||||||
|
$snap_h = $this->support->snap_dimension( $height );
|
||||||
|
$seed = $this->support->normalize_seed( isset( $options['seed'] ) ? (int) $options['seed'] : null );
|
||||||
|
$steps = isset( $options['steps'] ) ? (int) $options['steps'] : $this->pricing->default_steps( $model );
|
||||||
|
$body = $this->build_request_body( $api_key, $prompt, $snap_w, $snap_h, $model, $seed, $steps );
|
||||||
|
$headers = [ 'Content-Type' => 'application/json' ];
|
||||||
|
$started_at = microtime( true );
|
||||||
|
$last_error = '';
|
||||||
|
|
||||||
|
for ( $attempt = 1; $attempt <= PRAUTOBLOGGER_MAX_RETRIES; $attempt++ ) {
|
||||||
|
$response = wp_remote_post( PRAutoBlogger_Runware_Image_Support::API_URL, [
|
||||||
|
'timeout' => PRAUTOBLOGGER_API_TIMEOUT_SECONDS,
|
||||||
|
'headers' => $headers,
|
||||||
|
'body' => wp_json_encode( $body ),
|
||||||
|
] );
|
||||||
|
|
||||||
|
if ( is_wp_error( $response ) ) {
|
||||||
|
$last_error = $response->get_error_message();
|
||||||
|
$this->support->log_retry( $attempt, 'network', $last_error );
|
||||||
|
$this->support->backoff( $attempt );
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = (int) wp_remote_retrieve_response_code( $response );
|
||||||
|
$raw = (string) wp_remote_retrieve_body( $response );
|
||||||
|
|
||||||
|
if ( 429 === $status || $status >= 500 ) {
|
||||||
|
$last_error = sprintf( 'HTTP %d: %s', $status, substr( $raw, 0, 300 ) );
|
||||||
|
$this->support->log_retry( $attempt, (string) $status, $last_error );
|
||||||
|
if ( $attempt < PRAUTOBLOGGER_MAX_RETRIES ) {
|
||||||
|
$retry_after = (int) wp_remote_retrieve_header( $response, 'retry-after' );
|
||||||
|
$this->support->backoff( $attempt, $retry_after );
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( $status >= 400 ) {
|
||||||
|
PRAutoBlogger_Logger::instance()->error(
|
||||||
|
sprintf( 'Runware image gen HTTP %d: %s', $status, substr( $raw, 0, 300 ) ),
|
||||||
|
'runware-image'
|
||||||
|
);
|
||||||
|
throw new \RuntimeException(
|
||||||
|
sprintf(
|
||||||
|
esc_html__( 'Runware image error (HTTP %1$d): %2$s', 'prautoblogger' ),
|
||||||
|
$status,
|
||||||
|
esc_html( substr( $raw, 0, 300 ) )
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$image_url = $this->support->extract_image_url( $raw );
|
||||||
|
$image_bytes = $this->support->download_image_bytes( $image_url );
|
||||||
|
|
||||||
|
return [
|
||||||
|
'bytes' => $image_bytes,
|
||||||
|
'mime_type' => 'image/png',
|
||||||
|
'width' => $snap_w,
|
||||||
|
'height' => $snap_h,
|
||||||
|
'model' => $model,
|
||||||
|
'seed' => $seed,
|
||||||
|
'cost_usd' => $this->pricing->estimate_cost( $snap_w, $snap_h, $model ),
|
||||||
|
'latency_ms' => (int) ( ( microtime( true ) - $started_at ) * 1000 ),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \RuntimeException(
|
||||||
|
sprintf(
|
||||||
|
esc_html__( 'Runware image gen failed after %1$d attempts. Last error: %2$s', 'prautoblogger' ),
|
||||||
|
PRAUTOBLOGGER_MAX_RETRIES,
|
||||||
|
esc_html( $last_error )
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate multiple images concurrently via curl_multi.
|
||||||
|
*
|
||||||
|
* Delegates to PRAutoBlogger_Runware_Image_Batch, which fires all
|
||||||
|
* inference POSTs in parallel, then fetches the signed image URLs
|
||||||
|
* in parallel as well. Wall-clock time ≈ slowest single image.
|
||||||
|
*
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
public function generate_image_batch( array $requests ): array {
|
||||||
|
foreach ( $requests as $key => $req ) {
|
||||||
|
$this->assert_valid_dimensions( $req['width'], $req['height'] );
|
||||||
|
if ( '' === trim( $req['prompt'] ) ) {
|
||||||
|
throw new \InvalidArgumentException(
|
||||||
|
sprintf( 'Image prompt for key "%s" cannot be empty.', esc_html( $key ) )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$batch = new PRAutoBlogger_Runware_Image_Batch( $this->pricing, $this->support );
|
||||||
|
|
||||||
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
|
sprintf( 'Starting parallel Runware image batch (%d images)', count( $requests ) ),
|
||||||
|
'runware-image'
|
||||||
|
);
|
||||||
|
|
||||||
|
return $batch->execute( $requests );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
public function estimate_cost( int $width, int $height, array $options = [] ): float {
|
||||||
|
$model = $this->pricing->resolve_model( (string) ( $options['model'] ?? '' ) );
|
||||||
|
return $this->pricing->estimate_cost( $width, $height, $model );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
public function get_provider_name(): string {
|
||||||
|
return self::PROVIDER_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*
|
||||||
|
* Runware has no dedicated "validate key" endpoint, so we send a minimal
|
||||||
|
* authentication task and look for `taskType=authentication` in the
|
||||||
|
* response data with no accompanying errors.
|
||||||
|
*/
|
||||||
|
public function validate_credentials_detailed(): array {
|
||||||
|
try {
|
||||||
|
$api_key = $this->support->get_api_key();
|
||||||
|
} catch ( \RuntimeException $e ) {
|
||||||
|
return [ 'status' => 'error', 'message' => $e->getMessage() ];
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = [
|
||||||
|
[ 'taskType' => 'authentication', 'apiKey' => $api_key ],
|
||||||
|
];
|
||||||
|
|
||||||
|
$response = wp_remote_post( PRAutoBlogger_Runware_Image_Support::API_URL, [
|
||||||
|
'timeout' => 15,
|
||||||
|
'headers' => [ 'Content-Type' => 'application/json' ],
|
||||||
|
'body' => wp_json_encode( $body ),
|
||||||
|
] );
|
||||||
|
|
||||||
|
if ( is_wp_error( $response ) ) {
|
||||||
|
return [
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => __( 'Could not reach Runware API.', 'prautoblogger' ),
|
||||||
|
'debug' => $response->get_error_message(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = (int) wp_remote_retrieve_response_code( $response );
|
||||||
|
$raw = (string) wp_remote_retrieve_body( $response );
|
||||||
|
|
||||||
|
if ( 200 !== $status ) {
|
||||||
|
return [
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => sprintf( __( 'Runware returned HTTP %d.', 'prautoblogger' ), $status ),
|
||||||
|
'debug' => substr( $raw, 0, 200 ),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode( $raw, true );
|
||||||
|
if ( is_array( $decoded ) && isset( $decoded['errors'] ) && ! empty( $decoded['errors'] ) ) {
|
||||||
|
$first = $decoded['errors'][0] ?? [];
|
||||||
|
$msg = is_array( $first ) ? (string) ( $first['message'] ?? '' ) : (string) $first;
|
||||||
|
return [
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => __( 'Runware rejected the API key.', 'prautoblogger' ),
|
||||||
|
'debug' => substr( $msg, 0, 200 ),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [ 'status' => 'ok', 'message' => __( 'Runware API key valid.', 'prautoblogger' ) ];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the JSON task-array body for a single imageInference request.
|
||||||
|
*
|
||||||
|
* Runware's single endpoint consumes [auth, inference] in one POST.
|
||||||
|
*
|
||||||
|
* @param string $api_key Plaintext API key.
|
||||||
|
* @param string $prompt Full prompt text.
|
||||||
|
* @param int $width Snapped width.
|
||||||
|
* @param int $height Snapped height.
|
||||||
|
* @param string $model Resolved model id.
|
||||||
|
* @param int $seed Positive seed.
|
||||||
|
* @param int $steps Inference steps.
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function build_request_body(
|
||||||
|
string $api_key,
|
||||||
|
string $prompt,
|
||||||
|
int $width,
|
||||||
|
int $height,
|
||||||
|
string $model,
|
||||||
|
int $seed,
|
||||||
|
int $steps
|
||||||
|
): array {
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'taskType' => 'authentication',
|
||||||
|
'apiKey' => $api_key,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'taskType' => 'imageInference',
|
||||||
|
'taskUUID' => wp_generate_uuid4(),
|
||||||
|
'positivePrompt' => $prompt,
|
||||||
|
'width' => $width,
|
||||||
|
'height' => $height,
|
||||||
|
'model' => $model,
|
||||||
|
'steps' => $steps,
|
||||||
|
'numberResults' => 1,
|
||||||
|
'outputFormat' => 'PNG',
|
||||||
|
'outputType' => 'URL',
|
||||||
|
'seed' => $seed,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforce the interface's dimension contract.
|
||||||
|
*
|
||||||
|
* @param int $width Width in pixels.
|
||||||
|
* @param int $height Height in pixels.
|
||||||
|
* @throws \InvalidArgumentException If outside [MIN, MAX].
|
||||||
|
*/
|
||||||
|
private function assert_valid_dimensions( int $width, int $height ): void {
|
||||||
|
if (
|
||||||
|
$width < self::MIN_DIMENSION_PX || $width > self::MAX_DIMENSION_PX ||
|
||||||
|
$height < self::MIN_DIMENSION_PX || $height > self::MAX_DIMENSION_PX
|
||||||
|
) {
|
||||||
|
throw new \InvalidArgumentException(
|
||||||
|
sprintf(
|
||||||
|
esc_html__( 'Image dimensions %1$dx%2$d outside supported range (%3$d–%4$d px).', 'prautoblogger' ),
|
||||||
|
$width, $height, self::MIN_DIMENSION_PX, self::MAX_DIMENSION_PX
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
219
includes/providers/class-runware-image-support.php
Normal file
219
includes/providers/class-runware-image-support.php
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Support helpers for the Runware image provider — API key lookup,
|
||||||
|
* response parsing (URL fetch), retry backoff, and logging.
|
||||||
|
*
|
||||||
|
* Split from the provider to keep both files under the 300-line cap.
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger_Runware_Image_Provider, PRAutoBlogger_Runware_Image_Batch.
|
||||||
|
* Dependencies: PRAutoBlogger_Encryption, PRAutoBlogger_Logger.
|
||||||
|
*
|
||||||
|
* @see class-runware-image-provider.php — Primary caller.
|
||||||
|
* @see class-runware-image-batch.php — Parallel caller.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Runware_Image_Support {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runware task-based single endpoint. Unlike OpenRouter /chat/completions,
|
||||||
|
* Runware accepts a JSON array of tasks (auth + inference) at this URL.
|
||||||
|
*/
|
||||||
|
public const API_URL = 'https://api.runware.ai/v1';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the decrypted Runware API key from settings.
|
||||||
|
*
|
||||||
|
* @return string Plaintext key.
|
||||||
|
* @throws \RuntimeException If key is not configured or decrypt fails.
|
||||||
|
*/
|
||||||
|
public function get_api_key(): string {
|
||||||
|
$stored = (string) get_option( 'prautoblogger_runware_api_key', '' );
|
||||||
|
if ( '' === $stored ) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
__( 'Runware API key is not configured. Go to PRAutoBlogger → Settings → Images.', 'prautoblogger' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( PRAutoBlogger_Encryption::is_encrypted( $stored ) ) {
|
||||||
|
$decrypted = PRAutoBlogger_Encryption::decrypt( $stored );
|
||||||
|
if ( '' === $decrypted ) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
__( 'Runware API key could not be decrypted. Re-enter in Settings.', 'prautoblogger' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return $decrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the image URL from Runware's JSON response body.
|
||||||
|
*
|
||||||
|
* The response is `{"data": [{"taskType":"imageInference","imageURL":"..."}]}`.
|
||||||
|
* We scan `data[]` for the first imageInference entry with a non-empty URL.
|
||||||
|
*
|
||||||
|
* @param string $raw Raw JSON response body from POST /v1.
|
||||||
|
* @return string The imageURL to fetch bytes from.
|
||||||
|
* @throws \RuntimeException If the response is malformed or contains no image.
|
||||||
|
*/
|
||||||
|
public function extract_image_url( string $raw ): string {
|
||||||
|
$decoded = json_decode( $raw, true );
|
||||||
|
if ( ! is_array( $decoded ) ) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
esc_html__( 'Runware returned invalid JSON.', 'prautoblogger' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runware wraps errors in an `errors` array.
|
||||||
|
if ( isset( $decoded['errors'] ) && is_array( $decoded['errors'] ) && ! empty( $decoded['errors'] ) ) {
|
||||||
|
$first = $decoded['errors'][0] ?? [];
|
||||||
|
$msg = is_array( $first )
|
||||||
|
? (string) ( $first['message'] ?? wp_json_encode( $first ) )
|
||||||
|
: (string) $first;
|
||||||
|
throw new \RuntimeException(
|
||||||
|
sprintf(
|
||||||
|
esc_html__( 'Runware upstream error: %s', 'prautoblogger' ),
|
||||||
|
esc_html( substr( $msg, 0, 300 ) )
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $decoded['data'] ?? [];
|
||||||
|
if ( ! is_array( $data ) || empty( $data ) ) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
esc_html__( 'Runware response contained no data.', 'prautoblogger' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ( $data as $entry ) {
|
||||||
|
if ( ! is_array( $entry ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ( 'imageInference' === ( $entry['taskType'] ?? '' ) ) {
|
||||||
|
$url = (string) ( $entry['imageURL'] ?? '' );
|
||||||
|
if ( '' !== $url ) {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \RuntimeException(
|
||||||
|
esc_html__( 'Runware response contained no imageInference imageURL.', 'prautoblogger' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download the image bytes from a signed Runware delivery URL.
|
||||||
|
*
|
||||||
|
* Runware returns the image URL separately and bills the inference call;
|
||||||
|
* downloading is a regular GET against `im.runware.ai`.
|
||||||
|
*
|
||||||
|
* @param string $image_url Signed image URL from extract_image_url().
|
||||||
|
* @return string Raw PNG bytes.
|
||||||
|
* @throws \RuntimeException On HTTP error or empty body.
|
||||||
|
*/
|
||||||
|
public function download_image_bytes( string $image_url ): string {
|
||||||
|
$response = wp_remote_get( $image_url, [
|
||||||
|
'timeout' => 30,
|
||||||
|
] );
|
||||||
|
|
||||||
|
if ( is_wp_error( $response ) ) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
sprintf(
|
||||||
|
esc_html__( 'Runware image download failed: %s', 'prautoblogger' ),
|
||||||
|
esc_html( $response->get_error_message() )
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = (int) wp_remote_retrieve_response_code( $response );
|
||||||
|
if ( 200 !== $status ) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
sprintf(
|
||||||
|
esc_html__( 'Runware image download HTTP %d.', 'prautoblogger' ),
|
||||||
|
$status
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$bytes = (string) wp_remote_retrieve_body( $response );
|
||||||
|
if ( '' === $bytes ) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
esc_html__( 'Runware image download returned empty body.', 'prautoblogger' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snap a target pixel dimension to Runware's FLUX constraints.
|
||||||
|
*
|
||||||
|
* FLUX.1 (schnell + dev) requires dimensions divisible by 64, in the
|
||||||
|
* range [512, 2048]. We round to the nearest multiple of 64 and clamp.
|
||||||
|
*
|
||||||
|
* @param int $px Caller-requested pixel dimension.
|
||||||
|
* @return int Snapped dimension.
|
||||||
|
*/
|
||||||
|
public function snap_dimension( int $px ): int {
|
||||||
|
$snapped = (int) round( $px / 64 ) * 64;
|
||||||
|
if ( $snapped < 512 ) {
|
||||||
|
return 512;
|
||||||
|
}
|
||||||
|
if ( $snapped > 2048 ) {
|
||||||
|
return 2048;
|
||||||
|
}
|
||||||
|
return $snapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a caller seed to Runware's constraint (must be > 0).
|
||||||
|
*
|
||||||
|
* @param int|null $seed Caller-supplied seed or null for "random".
|
||||||
|
* @return int A positive integer seed.
|
||||||
|
*/
|
||||||
|
public function normalize_seed( ?int $seed ): int {
|
||||||
|
if ( null === $seed || $seed < 1 ) {
|
||||||
|
$derived = (int) ( ( microtime( true ) * 1000 ) % 2147483647 );
|
||||||
|
return $derived > 0 ? $derived : 1;
|
||||||
|
}
|
||||||
|
return $seed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit a WARN line for a retryable image-gen failure.
|
||||||
|
*
|
||||||
|
* @param int $attempt 1-based attempt number.
|
||||||
|
* @param string $class 'network' or HTTP status code string.
|
||||||
|
* @param string $detail Short failure detail.
|
||||||
|
*/
|
||||||
|
public function log_retry( int $attempt, string $class, string $detail ): void {
|
||||||
|
PRAutoBlogger_Logger::instance()->warning(
|
||||||
|
sprintf(
|
||||||
|
'Runware image gen %s failure (attempt %d/%d): %s',
|
||||||
|
$class,
|
||||||
|
$attempt,
|
||||||
|
PRAUTOBLOGGER_MAX_RETRIES,
|
||||||
|
$detail
|
||||||
|
),
|
||||||
|
'runware-image'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sleep between retries with exponential backoff.
|
||||||
|
*
|
||||||
|
* @param int $attempt 1-based attempt that just failed.
|
||||||
|
* @param int $retry_after Retry-After header value (0 if absent).
|
||||||
|
*/
|
||||||
|
public function backoff( int $attempt, int $retry_after = 0 ): void {
|
||||||
|
$delay = $retry_after > 0
|
||||||
|
? min( $retry_after, 60 )
|
||||||
|
: PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS * (int) pow( 2, $attempt - 1 );
|
||||||
|
if ( $delay > 0 ) {
|
||||||
|
sleep( $delay );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Contract for any image generation provider (Cloudflare Workers AI, DALL-E, etc.).
|
* Contract for any image generation provider (Runware FLUX, OpenRouter Gemini, etc.).
|
||||||
*
|
*
|
||||||
* Consumers depend on this interface, never on a concrete provider — swapping
|
* Consumers depend on this interface, never on a concrete provider — swapping
|
||||||
* providers means writing one new class, not refactoring the pipeline. This
|
* providers means writing one new class, not refactoring the pipeline. This
|
||||||
@@ -13,7 +13,8 @@ declare(strict_types=1);
|
|||||||
* content generation run, after editorial review, before publish.
|
* content generation run, after editorial review, before publish.
|
||||||
* Dependencies: None at interface level — implementations wrap a specific HTTP API.
|
* Dependencies: None at interface level — implementations wrap a specific HTTP API.
|
||||||
*
|
*
|
||||||
* @see class-cloudflare-image-provider.php — Default implementation (FLUX.1 via Workers AI).
|
* @see class-runware-image-provider.php — Runware FLUX implementation (default).
|
||||||
|
* @see class-open-router-image-provider.php — OpenRouter image implementation.
|
||||||
* @see interface-llm-provider.php — Parallel pattern for LLM providers.
|
* @see interface-llm-provider.php — Parallel pattern for LLM providers.
|
||||||
* @see ARCHITECTURE.md — External API Integrations table.
|
* @see ARCHITECTURE.md — External API Integrations table.
|
||||||
*/
|
*/
|
||||||
@@ -79,10 +80,41 @@ interface PRAutoBlogger_Image_Provider_Interface {
|
|||||||
/**
|
/**
|
||||||
* Machine-readable provider identifier used in logs and settings.
|
* Machine-readable provider identifier used in logs and settings.
|
||||||
*
|
*
|
||||||
* @return string Lowercase identifier, e.g. 'cloudflare_workers_ai'.
|
* @return string Lowercase identifier, e.g. 'runware' or 'openrouter'.
|
||||||
*/
|
*/
|
||||||
public function get_provider_name(): string;
|
public function get_provider_name(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate multiple images in parallel (or sequentially as fallback).
|
||||||
|
*
|
||||||
|
* Each entry in $requests describes one image to generate. The return array
|
||||||
|
* is indexed identically — callers can zip input and output by key.
|
||||||
|
*
|
||||||
|
* Implementations that support concurrent HTTP (e.g. curl_multi) SHOULD
|
||||||
|
* override the default sequential behaviour for a wall-clock speedup.
|
||||||
|
*
|
||||||
|
* Side effects: HTTP requests (possibly concurrent), Logger lines per image.
|
||||||
|
*
|
||||||
|
* @param array<string, array{
|
||||||
|
* prompt: string,
|
||||||
|
* width: int,
|
||||||
|
* height: int,
|
||||||
|
* options?: array{seed?: int, steps?: int, model?: string},
|
||||||
|
* }> $requests Keyed image generation requests.
|
||||||
|
*
|
||||||
|
* @return array<string, array{
|
||||||
|
* bytes: string,
|
||||||
|
* mime_type: string,
|
||||||
|
* width: int,
|
||||||
|
* height: int,
|
||||||
|
* model: string,
|
||||||
|
* seed: ?int,
|
||||||
|
* cost_usd: float,
|
||||||
|
* latency_ms: int,
|
||||||
|
* }|array{error: string}> Each key maps to either image data or an error.
|
||||||
|
*/
|
||||||
|
public function generate_image_batch( array $requests ): array;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify the provider credentials are present and the API is reachable.
|
* Verify the provider credentials are present and the API is reachable.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ declare(strict_types=1);
|
|||||||
* Triggered by: PRAutoBlogger_OpenRouter_Model_Registry::refresh().
|
* Triggered by: PRAutoBlogger_OpenRouter_Model_Registry::refresh().
|
||||||
* Dependencies: None — pure data transformation, no I/O.
|
* Dependencies: None — pure data transformation, no I/O.
|
||||||
*
|
*
|
||||||
* @see services/class-openrouter-model-registry.php — Sole consumer.
|
* @see services/class-open-router-model-registry.php — Sole consumer.
|
||||||
* @see interface-model-registry.php — Defines the normalized record shape.
|
* @see interface-model-registry.php — Defines the normalized record shape.
|
||||||
* @see ARCHITECTURE.md — Capability vocabulary.
|
* @see ARCHITECTURE.md — Capability vocabulary.
|
||||||
*/
|
*/
|
||||||
@@ -17,7 +17,7 @@ declare(strict_types=1);
|
|||||||
* PRAutoBlogger_OpenRouter_Model_Normalizer.
|
* PRAutoBlogger_OpenRouter_Model_Normalizer.
|
||||||
*
|
*
|
||||||
* @see interface-model-registry.php — Interface.
|
* @see interface-model-registry.php — Interface.
|
||||||
* @see class-openrouter-model-normalizer.php — Normalization logic.
|
* @see class-open-router-model-normalizer.php — Normalization logic.
|
||||||
* @see class-prautoblogger.php — Hooks the refresh action.
|
* @see class-prautoblogger.php — Hooks the refresh action.
|
||||||
* @see ARCHITECTURE.md — AI Model Registries section.
|
* @see ARCHITECTURE.md — AI Model Registries section.
|
||||||
*/
|
*/
|
||||||
@@ -4,10 +4,10 @@ declare(strict_types=1);
|
|||||||
/**
|
/**
|
||||||
* Contract for a provider-specific AI model registry.
|
* Contract for a provider-specific AI model registry.
|
||||||
*
|
*
|
||||||
* Phase 1 (v1): single implementation for OpenRouter (`class-openrouter-model-registry.php`).
|
* Phase 1 (v1): single implementation for OpenRouter (`class-open-router-model-registry.php`).
|
||||||
* Phase 3: a second implementation wraps Cloudflare Workers AI FLUX model data. A
|
* Phase 3: a second implementation wraps Runware model data. A higher-level
|
||||||
* higher-level dispatcher iterates registered registries, calls each with the same
|
* dispatcher iterates registered registries, calls each with the same capability,
|
||||||
* capability, and unions the results — the per-registry interface stays unchanged.
|
* and unions the results — the per-registry interface stays unchanged.
|
||||||
*
|
*
|
||||||
* Implementations MUST:
|
* Implementations MUST:
|
||||||
* - Store the normalized registry as a WP option + transient cache layer.
|
* - Store the normalized registry as a WP option + transient cache layer.
|
||||||
@@ -19,7 +19,7 @@ declare(strict_types=1);
|
|||||||
* (commit 3), daily cron via the main plugin loader.
|
* (commit 3), daily cron via the main plugin loader.
|
||||||
* Dependencies: None at interface level — implementations wrap an HTTP API.
|
* Dependencies: None at interface level — implementations wrap an HTTP API.
|
||||||
*
|
*
|
||||||
* @see services/class-openrouter-model-registry.php — Default implementation.
|
* @see services/class-open-router-model-registry.php — Default implementation.
|
||||||
* @see admin/fields/class-openrouter-model-field.php — Field renderer that queries this interface.
|
* @see admin/fields/class-openrouter-model-field.php — Field renderer that queries this interface.
|
||||||
* @see ARCHITECTURE.md — AI Model Registries section.
|
* @see ARCHITECTURE.md — AI Model Registries section.
|
||||||
*/
|
*/
|
||||||
@@ -86,7 +86,7 @@ interface PRAutoBlogger_Model_Registry_Interface {
|
|||||||
public function get_fetched_at(): int;
|
public function get_fetched_at(): int;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Machine-readable provider identifier (e.g. 'openrouter', 'cloudflare_workers_ai').
|
* Machine-readable provider identifier (e.g. 'openrouter', 'runware').
|
||||||
*
|
*
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ declare(strict_types=1);
|
|||||||
* Plugin Name: PRAutoBlogger
|
* Plugin Name: PRAutoBlogger
|
||||||
* Plugin URI: https://peptiderepo.com/prautoblogger
|
* Plugin URI: https://peptiderepo.com/prautoblogger
|
||||||
* Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics.
|
* Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics.
|
||||||
* Version: 0.2.3
|
* Version: 0.10.0
|
||||||
* Requires at least: 6.0
|
* Requires at least: 6.0
|
||||||
* Requires PHP: 7.4
|
* Requires PHP: 7.4
|
||||||
* Author: PeptideRepo
|
* Author: PeptideRepo
|
||||||
@@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||||||
| and limits without magic strings.
|
| and limits without magic strings.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
define( 'PRAUTOBLOGGER_VERSION', '0.2.3' );
|
define( 'PRAUTOBLOGGER_VERSION', '0.10.0' );
|
||||||
define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' );
|
define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' );
|
||||||
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
|
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
|
||||||
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||||
@@ -45,7 +45,7 @@ define( 'PRAUTOBLOGGER_PLUGIN_BASENAME', plugin_basename( __FILE__ ) );
|
|||||||
// API and retry limits — named constants instead of magic numbers.
|
// API and retry limits — named constants instead of magic numbers.
|
||||||
define( 'PRAUTOBLOGGER_MAX_RETRIES', 3 );
|
define( 'PRAUTOBLOGGER_MAX_RETRIES', 3 );
|
||||||
define( 'PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS', 2 );
|
define( 'PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS', 2 );
|
||||||
define( 'PRAUTOBLOGGER_API_TIMEOUT_SECONDS', 120 );
|
define( 'PRAUTOBLOGGER_API_TIMEOUT_SECONDS', 180 );
|
||||||
define( 'PRAUTOBLOGGER_CACHE_TTL_SECONDS', 3600 );
|
define( 'PRAUTOBLOGGER_CACHE_TTL_SECONDS', 3600 );
|
||||||
|
|
||||||
// Default models for OpenRouter (user can override in settings).
|
// Default models for OpenRouter (user can override in settings).
|
||||||
@@ -53,17 +53,22 @@ define( 'PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL', 'google/gemini-2.5-flash-lite' )
|
|||||||
define( 'PRAUTOBLOGGER_DEFAULT_WRITING_MODEL', 'google/gemini-2.5-flash-lite' );
|
define( 'PRAUTOBLOGGER_DEFAULT_WRITING_MODEL', 'google/gemini-2.5-flash-lite' );
|
||||||
define( 'PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL', 'google/gemini-2.5-flash-lite' );
|
define( 'PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL', 'google/gemini-2.5-flash-lite' );
|
||||||
|
|
||||||
// Image generation defaults (Cloudflare Workers AI, FLUX family).
|
// Image generation defaults.
|
||||||
// Short alias; the Cloudflare image pricing helper expands it to the full
|
// Default provider is Runware; OpenRouter (Gemini) remains as the alternate.
|
||||||
// `@cf/black-forest-labs/...` model id when building the Workers AI URL.
|
// Default model is FLUX.1 schnell via Runware (~$0.0006/image, ~2s). CEO
|
||||||
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL', 'flux-1-schnell' );
|
// decision 2026-04-21 after comic A/B: schnell's looser fidelity reads as
|
||||||
|
// editorial-cartoon style and is 65× cheaper than Nano Banana. Users can
|
||||||
|
// override via Settings → Images → Image Model.
|
||||||
|
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER', 'runware' );
|
||||||
|
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL', 'runware:100@1' );
|
||||||
|
|
||||||
// CEO-approved image style suffix (decision locked 2026-04-15). Users may
|
// Default style suffix appended to every image-gen prompt. Updated
|
||||||
// override via Settings → Images → Style Suffix; this constant is the
|
// 2026-04-18 v4: single-panel newspaper comic style. Caption is now
|
||||||
// fallback the setting reads on first install.
|
// inserted as HTML text below the image, NOT rendered inside it.
|
||||||
|
// Users may override via Settings → Images → Style Suffix.
|
||||||
define(
|
define(
|
||||||
'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX',
|
'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX',
|
||||||
'Style: a screengrab from a 1995 late-night cable TV infomercial, oversaturated warm colors, VHS tape noise and scan lines, a bright yellow starburst graphic in the top right corner reading NEW, cheap studio lighting, dated 4:3 television framing, deadpan earnest expression, faint lens distortion, tacky low-budget commercial aesthetic.'
|
'Style: a single-panel newspaper comic in classic black ink on white paper, simple clean linework like The Far Side or B.C., slightly yellowed newsprint texture, hand-drawn crosshatching for shading, bold panel border, warm and humorous tone, visible in a newspaper comics section. IMPORTANT: Do not render any text, captions, speech bubbles, or words inside the image.'
|
||||||
);
|
);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
179
templates/admin/ideas-browser.php
Normal file
179
templates/admin/ideas-browser.php
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Ideas browser template — lists analysis results (article ideas) from the pipeline.
|
||||||
|
*
|
||||||
|
* Variables available from PRAutoBlogger_Ideas_Browser::render_page():
|
||||||
|
* $rows (array[]) — analysis result rows from the database.
|
||||||
|
* $total (int) — total row count (before pagination).
|
||||||
|
* $total_pages (int) — total number of pages.
|
||||||
|
* $type_filter (string) — active type filter (empty = all).
|
||||||
|
* $type_counts (array) — map of analysis_type => count for filter badges.
|
||||||
|
* $paged (int) — current page number.
|
||||||
|
*
|
||||||
|
* @see admin/class-ideas-browser.php — Renders this template.
|
||||||
|
*/
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$base_url = admin_url( 'admin.php?page=prautoblogger-ideas' );
|
||||||
|
?>
|
||||||
|
<style>
|
||||||
|
.ab-spin { animation: ab-spin 1s linear infinite; display: inline-block; }
|
||||||
|
@keyframes ab-spin { 100% { transform: rotate(360deg); } }
|
||||||
|
</style>
|
||||||
|
<div class="wrap prautoblogger-ideas-browser">
|
||||||
|
<h1><?php esc_html_e( 'PRAutoBlogger — Ideas', 'prautoblogger' ); ?></h1>
|
||||||
|
<p class="description">
|
||||||
|
<?php
|
||||||
|
printf(
|
||||||
|
/* translators: %d: total idea count */
|
||||||
|
esc_html__( '%d ideas collected from analysis runs. Newest first.', 'prautoblogger' ),
|
||||||
|
$total
|
||||||
|
);
|
||||||
|
?>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<?php if ( ! empty( $type_counts ) ) : ?>
|
||||||
|
<div class="prautoblogger-ideas-filters" style="margin: 12px 0;">
|
||||||
|
<a href="<?php echo esc_url( $base_url ); ?>"
|
||||||
|
class="button <?php echo '' === $type_filter ? 'button-primary' : ''; ?>"
|
||||||
|
style="margin-right: 4px;">
|
||||||
|
<?php printf( '%s (%d)', esc_html__( 'All', 'prautoblogger' ), $total ); ?>
|
||||||
|
</a>
|
||||||
|
<?php foreach ( $type_counts as $type_name => $count ) : ?>
|
||||||
|
<a href="<?php echo esc_url( add_query_arg( 'type', $type_name, $base_url ) ); ?>"
|
||||||
|
class="button <?php echo $type_filter === $type_name ? 'button-primary' : ''; ?>"
|
||||||
|
style="margin-right: 4px;">
|
||||||
|
<?php echo esc_html( ucfirst( $type_name ) ) . ' (' . (int) $count . ')'; ?>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ( empty( $rows ) ) : ?>
|
||||||
|
<div class="prautoblogger-empty-queue">
|
||||||
|
<p><?php esc_html_e( 'No ideas found. Ideas will appear here after the next generation run.', 'prautoblogger' ); ?></p>
|
||||||
|
</div>
|
||||||
|
<?php else : ?>
|
||||||
|
<table class="widefat striped prautoblogger-ideas-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:28%"><?php esc_html_e( 'Title / Topic', 'prautoblogger' ); ?></th>
|
||||||
|
<th style="width:7%"><?php esc_html_e( 'Type', 'prautoblogger' ); ?></th>
|
||||||
|
<th style="width:6%"><?php esc_html_e( 'Score', 'prautoblogger' ); ?></th>
|
||||||
|
<th style="width:5%"><?php esc_html_e( 'Freq', 'prautoblogger' ); ?></th>
|
||||||
|
<th style="width:30%"><?php esc_html_e( 'Key Points / Keywords', 'prautoblogger' ); ?></th>
|
||||||
|
<th style="width:10%"><?php esc_html_e( 'Analyzed', 'prautoblogger' ); ?></th>
|
||||||
|
<th style="width:14%"><?php esc_html_e( 'Generate', 'prautoblogger' ); ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ( $rows as $row ) : ?>
|
||||||
|
<?php
|
||||||
|
$meta = json_decode( $row['metadata_json'] ?? '{}', true ) ?: [];
|
||||||
|
$suggested = $meta['suggested_title'] ?? '';
|
||||||
|
$key_points = $meta['key_points'] ?? [];
|
||||||
|
$keywords = $meta['target_keywords'] ?? [];
|
||||||
|
$score_pct = round( (float) $row['relevance_score'] * 100 );
|
||||||
|
$type_label = ucfirst( $row['analysis_type'] ?? 'unknown' );
|
||||||
|
$type_class = 'prab-type-' . sanitize_html_class( $row['analysis_type'] );
|
||||||
|
$idea_id = (int) $row['id'];
|
||||||
|
|
||||||
|
// Check if this idea is already mid-generation.
|
||||||
|
$idea_status = get_transient( 'prab_idea_gen_' . $idea_id );
|
||||||
|
$gen_state = is_array( $idea_status ) ? ( $idea_status['status'] ?? 'idle' ) : 'idle';
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong><?php echo esc_html( '' !== $suggested ? $suggested : $row['topic'] ); ?></strong>
|
||||||
|
<?php if ( '' !== $suggested && $suggested !== $row['topic'] ) : ?>
|
||||||
|
<div style="color:#666; font-size:12px; margin-top:2px;">
|
||||||
|
<?php echo esc_html( $row['topic'] ); ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ( ! empty( $row['summary'] ) ) : ?>
|
||||||
|
<div style="color:#555; font-size:12px; margin-top:4px; line-height:1.4;">
|
||||||
|
<?php echo esc_html( $row['summary'] ); ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="<?php echo esc_attr( $type_class ); ?>" style="padding:2px 8px; border-radius:3px; font-size:12px; background:#f0f0f1;">
|
||||||
|
<?php echo esc_html( $type_label ); ?>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<?php $bar_color = $score_pct >= 70 ? '#00a32a' : ( $score_pct >= 40 ? '#dba617' : '#d63638' ); ?>
|
||||||
|
<div style="display:flex; align-items:center; gap:6px;">
|
||||||
|
<div style="width:40px; height:6px; background:#ddd; border-radius:3px; overflow:hidden;">
|
||||||
|
<div style="width:<?php echo (int) $score_pct; ?>%; height:100%; background:<?php echo esc_attr( $bar_color ); ?>;"></div>
|
||||||
|
</div>
|
||||||
|
<span style="font-size:12px;"><?php echo (int) $score_pct; ?>%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style="text-align:center;"><?php echo (int) $row['frequency']; ?></td>
|
||||||
|
<td>
|
||||||
|
<?php if ( ! empty( $key_points ) ) : ?>
|
||||||
|
<ul style="margin:0; padding-left:16px; font-size:12px; line-height:1.5;">
|
||||||
|
<?php foreach ( array_slice( $key_points, 0, 4 ) as $point ) : ?>
|
||||||
|
<li><?php echo esc_html( $point ); ?></li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if ( count( $key_points ) > 4 ) : ?>
|
||||||
|
<li style="color:#666;">+<?php echo count( $key_points ) - 4; ?> more</li>
|
||||||
|
<?php endif; ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ( ! empty( $keywords ) ) : ?>
|
||||||
|
<div style="margin-top:4px;">
|
||||||
|
<?php foreach ( array_slice( $keywords, 0, 5 ) as $kw ) : ?>
|
||||||
|
<span style="display:inline-block; padding:1px 6px; margin:1px 2px; background:#e7f5fe; border-radius:3px; font-size:11px; color:#0073aa;">
|
||||||
|
<?php echo esc_html( $kw ); ?>
|
||||||
|
</span>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td style="font-size:12px; color:#666;">
|
||||||
|
<?php echo esc_html( wp_date( 'M j, g:i A', strtotime( $row['analyzed_at'] ) ) ); ?>
|
||||||
|
</td>
|
||||||
|
<td class="prab-idea-gen-cell"
|
||||||
|
data-idea-id="<?php echo $idea_id; ?>"
|
||||||
|
data-status="<?php echo esc_attr( $gen_state ); ?>">
|
||||||
|
<?php if ( 'complete' === $gen_state && ! empty( $idea_status['post_id'] ) ) : ?>
|
||||||
|
<span class="dashicons dashicons-yes-alt" style="color:#00a32a;"></span>
|
||||||
|
<a href="<?php echo esc_url( admin_url( 'post.php?post=' . $idea_status['post_id'] . '&action=edit' ) ); ?>">Edit</a>
|
||||||
|
| <a href="<?php echo esc_url( get_permalink( $idea_status['post_id'] ) ); ?>" target="_blank">View</a>
|
||||||
|
<?php elseif ( 'running' === $gen_state ) : ?>
|
||||||
|
<span class="dashicons dashicons-update ab-spin"></span>
|
||||||
|
<span class="prab-idea-stage"><?php echo esc_html( $idea_status['stage'] ?? 'Generating…' ); ?></span>
|
||||||
|
<?php else : ?>
|
||||||
|
<button type="button"
|
||||||
|
class="button button-small prab-gen-idea-btn"
|
||||||
|
data-idea-id="<?php echo $idea_id; ?>">
|
||||||
|
<?php esc_html_e( 'Generate', 'prautoblogger' ); ?>
|
||||||
|
</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<?php if ( $total_pages > 1 ) : ?>
|
||||||
|
<div class="prautoblogger-pagination" style="margin-top: 12px;">
|
||||||
|
<?php
|
||||||
|
echo wp_kses_post( paginate_links( [
|
||||||
|
'base' => add_query_arg( 'paged', '%#%' ),
|
||||||
|
'format' => '',
|
||||||
|
'current' => $paged,
|
||||||
|
'total' => $total_pages,
|
||||||
|
'prev_text' => '«',
|
||||||
|
'next_text' => '»',
|
||||||
|
] ) );
|
||||||
|
?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
@@ -55,6 +55,15 @@ if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL' ) ) {
|
|||||||
define( 'PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL', 'google/gemini-2.5-flash-lite' );
|
define( 'PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL', 'google/gemini-2.5-flash-lite' );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default image model slug used by OpenRouter/Runware pricing fallbacks and
|
||||||
|
// settings UI. Source of truth: prautoblogger.php line 63.
|
||||||
|
// Historically this was defined implicitly by CloudflareImageProviderTest::setUp()
|
||||||
|
// — that test was deleted in v0.10.0 (cloudflare-workers-ai-removal), so the
|
||||||
|
// define now lives here where it belongs.
|
||||||
|
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL' ) ) {
|
||||||
|
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL', 'runware:100@1' );
|
||||||
|
}
|
||||||
|
|
||||||
// Default image style suffix used by ImagePromptBuilder and settings UI.
|
// Default image style suffix used by ImagePromptBuilder and settings UI.
|
||||||
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX' ) ) {
|
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX' ) ) {
|
||||||
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX', 'Style: test infomercial style.' );
|
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX', 'Style: test infomercial style.' );
|
||||||
|
|||||||
133
tests/unit/Core/ActivatorMigrationTest.php
Normal file
133
tests/unit/Core/ActivatorMigrationTest.php
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Tests for the v0.8.2 one-shot migration that reschedules the daily
|
||||||
|
* generation cron in the site's configured timezone. Exercises both
|
||||||
|
* the "first run" path and the "already migrated, no-op" path.
|
||||||
|
*
|
||||||
|
* See thread scheduled-gen-timezone/01-cto-handoff.md.
|
||||||
|
*
|
||||||
|
* @package PRAutoBlogger\Tests\Core
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace PRAutoBlogger\Tests\Core;
|
||||||
|
|
||||||
|
use PRAutoBlogger\Tests\BaseTestCase;
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
|
||||||
|
class ActivatorMigrationTest extends BaseTestCase {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track cron scheduling side effects so we can assert on them.
|
||||||
|
*
|
||||||
|
* @var array<string, array{scheduled_at: int|null, recurrence: string|null, cleared: bool}>
|
||||||
|
*/
|
||||||
|
private array $cron_state = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track the v0.8.2 migration flag across get_option/update_option calls.
|
||||||
|
*
|
||||||
|
* @var array<string, mixed>
|
||||||
|
*/
|
||||||
|
private array $options = [];
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->cron_state = [
|
||||||
|
'prautoblogger_daily_generation' => [
|
||||||
|
'scheduled_at' => null,
|
||||||
|
'recurrence' => null,
|
||||||
|
'cleared' => false,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Start with a pre-existing cron simulating the v<0.8.2 UTC-interpreted
|
||||||
|
// schedule, so the migration has something to clear.
|
||||||
|
$this->cron_state['prautoblogger_daily_generation']['scheduled_at'] = strtotime( 'tomorrow 03:00 UTC' );
|
||||||
|
$this->cron_state['prautoblogger_daily_generation']['recurrence'] = 'daily';
|
||||||
|
|
||||||
|
// Baseline options.
|
||||||
|
$this->options = [
|
||||||
|
'prautoblogger_schedule_time' => '06:00',
|
||||||
|
'prautoblogger_log_level' => 'info',
|
||||||
|
];
|
||||||
|
|
||||||
|
Functions\when( 'get_option' )->alias(
|
||||||
|
fn( $key, $default = false ) => $this->options[ $key ] ?? $default
|
||||||
|
);
|
||||||
|
Functions\when( 'update_option' )->alias(
|
||||||
|
function ( $key, $value ) {
|
||||||
|
$this->options[ $key ] = $value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Functions\when( 'wp_next_scheduled' )->alias(
|
||||||
|
fn( $hook ) => $this->cron_state[ $hook ]['scheduled_at'] ?? false
|
||||||
|
);
|
||||||
|
Functions\when( 'wp_clear_scheduled_hook' )->alias(
|
||||||
|
function ( $hook ) {
|
||||||
|
if ( isset( $this->cron_state[ $hook ] ) ) {
|
||||||
|
$this->cron_state[ $hook ]['scheduled_at'] = null;
|
||||||
|
$this->cron_state[ $hook ]['cleared'] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Functions\when( 'wp_schedule_event' )->alias(
|
||||||
|
function ( $timestamp, $recurrence, $hook ) {
|
||||||
|
$this->cron_state[ $hook ]['scheduled_at'] = (int) $timestamp;
|
||||||
|
$this->cron_state[ $hook ]['recurrence'] = (string) $recurrence;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Functions\when( 'wp_timezone' )->alias(
|
||||||
|
static fn() => new \DateTimeZone( 'Asia/Singapore' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration clears the UTC-scheduled event and reschedules in SGT.
|
||||||
|
* Flag is set so the next pass is a no-op.
|
||||||
|
*/
|
||||||
|
public function test_db_version_migration_reschedules_existing_cron(): void {
|
||||||
|
$before_timestamp = $this->cron_state['prautoblogger_daily_generation']['scheduled_at'];
|
||||||
|
$this->assertNotNull( $before_timestamp, 'Precondition: stale cron was pre-seeded.' );
|
||||||
|
|
||||||
|
\PRAutoBlogger_Activator::reschedule_daily_in_site_timezone_v082();
|
||||||
|
|
||||||
|
// Hook was cleared and then re-scheduled in one pass.
|
||||||
|
$this->assertTrue( $this->cron_state['prautoblogger_daily_generation']['cleared'] );
|
||||||
|
$after = $this->cron_state['prautoblogger_daily_generation']['scheduled_at'];
|
||||||
|
$this->assertNotNull( $after );
|
||||||
|
$this->assertNotSame( $before_timestamp, $after, 'Migration should produce a different timestamp.' );
|
||||||
|
|
||||||
|
// Re-scheduled timestamp maps to 06:00 SGT (22:00 UTC prev day).
|
||||||
|
$utc = ( new \DateTimeImmutable( '@' . $after ) )->setTimezone( new \DateTimeZone( 'UTC' ) );
|
||||||
|
$this->assertSame( '22:00', $utc->format( 'H:i' ) );
|
||||||
|
|
||||||
|
// Flag was set.
|
||||||
|
$this->assertSame( '1', $this->options['prautoblogger_migrated_schedule_tz_v082'] ?? null );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Running the migration twice is idempotent — the second pass must
|
||||||
|
* return immediately without clearing or rescheduling.
|
||||||
|
*/
|
||||||
|
public function test_db_version_migration_idempotent(): void {
|
||||||
|
\PRAutoBlogger_Activator::reschedule_daily_in_site_timezone_v082();
|
||||||
|
|
||||||
|
// Reset the cleared marker so we can detect a second clear.
|
||||||
|
$this->cron_state['prautoblogger_daily_generation']['cleared'] = false;
|
||||||
|
$timestamp_after_first = $this->cron_state['prautoblogger_daily_generation']['scheduled_at'];
|
||||||
|
|
||||||
|
\PRAutoBlogger_Activator::reschedule_daily_in_site_timezone_v082();
|
||||||
|
|
||||||
|
// No second clear, no re-schedule.
|
||||||
|
$this->assertFalse( $this->cron_state['prautoblogger_daily_generation']['cleared'] );
|
||||||
|
$this->assertSame(
|
||||||
|
$timestamp_after_first,
|
||||||
|
$this->cron_state['prautoblogger_daily_generation']['scheduled_at']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
163
tests/unit/Core/ActivatorRemoveCloudflareTest.php
Normal file
163
tests/unit/Core/ActivatorRemoveCloudflareTest.php
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Tests for the v0.10.0 one-shot migration that removes Cloudflare
|
||||||
|
* Workers AI as an image provider — exercises the cloudflare-user
|
||||||
|
* path, the non-cloudflare-user path, idempotency, and the critical
|
||||||
|
* scope-boundary invariant that `prautoblogger_ai_gateway_base_url`
|
||||||
|
* (shared with the OpenRouter AI Gateway route) is left intact.
|
||||||
|
*
|
||||||
|
* See thread 2026-04-cloudflare-workers-ai-removal/01-cto-handoff.md.
|
||||||
|
*
|
||||||
|
* @package PRAutoBlogger\Tests\Core
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace PRAutoBlogger\Tests\Core;
|
||||||
|
|
||||||
|
use PRAutoBlogger\Tests\BaseTestCase;
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
|
||||||
|
class ActivatorRemoveCloudflareTest extends BaseTestCase {
|
||||||
|
|
||||||
|
/** @var array<string, mixed> */
|
||||||
|
private array $options = [];
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->options = [];
|
||||||
|
|
||||||
|
Functions\when( 'get_option' )->alias(
|
||||||
|
fn( $key, $default = false ) => $this->options[ $key ] ?? $default
|
||||||
|
);
|
||||||
|
Functions\when( 'update_option' )->alias(
|
||||||
|
function ( $key, $value ) {
|
||||||
|
$this->options[ $key ] = $value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Functions\when( 'delete_option' )->alias(
|
||||||
|
function ( $key ) {
|
||||||
|
unset( $this->options[ $key ] );
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cloudflare user: flipped to Runware schnell, CF credentials removed,
|
||||||
|
* gateway URL preserved, flag set.
|
||||||
|
*/
|
||||||
|
public function test_cloudflare_user_migrated_to_runware(): void {
|
||||||
|
$this->options = [
|
||||||
|
'prautoblogger_image_provider' => 'cloudflare',
|
||||||
|
'prautoblogger_image_model' => 'flux-1-schnell',
|
||||||
|
'prautoblogger_cloudflare_ai_token' => 'enc:SECRET',
|
||||||
|
'prautoblogger_cloudflare_account_id' => 'acct-123',
|
||||||
|
'prautoblogger_cf_image_via_gateway' => '1',
|
||||||
|
// Shared with OpenRouter AI Gateway — MUST be preserved.
|
||||||
|
'prautoblogger_ai_gateway_base_url' => 'https://gateway.ai.cloudflare.com/v1/acct/gw/openrouter',
|
||||||
|
];
|
||||||
|
|
||||||
|
\PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
|
||||||
|
|
||||||
|
$this->assertSame( 'runware', $this->options['prautoblogger_image_provider'] );
|
||||||
|
$this->assertSame( 'runware:100@1', $this->options['prautoblogger_image_model'] );
|
||||||
|
|
||||||
|
$this->assertArrayNotHasKey( 'prautoblogger_cloudflare_ai_token', $this->options );
|
||||||
|
$this->assertArrayNotHasKey( 'prautoblogger_cloudflare_account_id', $this->options );
|
||||||
|
$this->assertArrayNotHasKey( 'prautoblogger_cf_image_via_gateway', $this->options );
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
'https://gateway.ai.cloudflare.com/v1/acct/gw/openrouter',
|
||||||
|
$this->options['prautoblogger_ai_gateway_base_url'],
|
||||||
|
'AI Gateway URL (shared with OpenRouter route) must NOT be touched.'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame( '1', $this->options['prautoblogger_migrated_remove_cloudflare_v0100'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-Cloudflare user (e.g. already on OpenRouter Gemini): model +
|
||||||
|
* provider untouched; CF credentials still swept; flag set.
|
||||||
|
*/
|
||||||
|
public function test_non_cloudflare_user_preserved(): void {
|
||||||
|
$this->options = [
|
||||||
|
'prautoblogger_image_provider' => 'openrouter',
|
||||||
|
'prautoblogger_image_model' => 'google/gemini-2.5-flash-image',
|
||||||
|
// Hypothetical orphans from an earlier experiment.
|
||||||
|
'prautoblogger_cloudflare_ai_token' => 'enc:OLD',
|
||||||
|
'prautoblogger_cloudflare_account_id' => 'acct-999',
|
||||||
|
'prautoblogger_ai_gateway_base_url' => 'https://gateway.ai.cloudflare.com/v1/acct/gw/openrouter',
|
||||||
|
];
|
||||||
|
|
||||||
|
\PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
|
||||||
|
|
||||||
|
$this->assertSame( 'openrouter', $this->options['prautoblogger_image_provider'] );
|
||||||
|
$this->assertSame( 'google/gemini-2.5-flash-image', $this->options['prautoblogger_image_model'] );
|
||||||
|
|
||||||
|
$this->assertArrayNotHasKey( 'prautoblogger_cloudflare_ai_token', $this->options );
|
||||||
|
$this->assertArrayNotHasKey( 'prautoblogger_cloudflare_account_id', $this->options );
|
||||||
|
$this->assertSame(
|
||||||
|
'https://gateway.ai.cloudflare.com/v1/acct/gw/openrouter',
|
||||||
|
$this->options['prautoblogger_ai_gateway_base_url']
|
||||||
|
);
|
||||||
|
$this->assertSame( '1', $this->options['prautoblogger_migrated_remove_cloudflare_v0100'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runware user: provider + model untouched; flag set.
|
||||||
|
*/
|
||||||
|
public function test_runware_user_preserved(): void {
|
||||||
|
$this->options = [
|
||||||
|
'prautoblogger_image_provider' => 'runware',
|
||||||
|
'prautoblogger_image_model' => 'runware:101@1',
|
||||||
|
];
|
||||||
|
|
||||||
|
\PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
|
||||||
|
|
||||||
|
$this->assertSame( 'runware', $this->options['prautoblogger_image_provider'] );
|
||||||
|
$this->assertSame( 'runware:101@1', $this->options['prautoblogger_image_model'] );
|
||||||
|
$this->assertSame( '1', $this->options['prautoblogger_migrated_remove_cloudflare_v0100'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy slug detection — the pre-registry `flux-1-schnell` model id
|
||||||
|
* still gets migrated even when the provider option is empty.
|
||||||
|
*/
|
||||||
|
public function test_legacy_slug_triggers_migration(): void {
|
||||||
|
$this->options = [
|
||||||
|
'prautoblogger_image_provider' => '', // Unset before v0.9.0 landed.
|
||||||
|
'prautoblogger_image_model' => 'flux-1-schnell',
|
||||||
|
];
|
||||||
|
|
||||||
|
\PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
|
||||||
|
|
||||||
|
$this->assertSame( 'runware', $this->options['prautoblogger_image_provider'] );
|
||||||
|
$this->assertSame( 'runware:100@1', $this->options['prautoblogger_image_model'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Running the migration twice is idempotent — the second pass must
|
||||||
|
* return immediately and must not clobber a user change made after
|
||||||
|
* the first migration run.
|
||||||
|
*/
|
||||||
|
public function test_idempotent_second_run_is_noop(): void {
|
||||||
|
$this->options = [
|
||||||
|
'prautoblogger_image_provider' => 'cloudflare',
|
||||||
|
'prautoblogger_image_model' => 'flux-1-schnell',
|
||||||
|
];
|
||||||
|
|
||||||
|
\PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
|
||||||
|
$this->assertSame( 'runware', $this->options['prautoblogger_image_provider'] );
|
||||||
|
|
||||||
|
// Simulate admin manually switching to OpenRouter after migration.
|
||||||
|
$this->options['prautoblogger_image_provider'] = 'openrouter';
|
||||||
|
$this->options['prautoblogger_image_model'] = 'google/gemini-3.1-flash-image-preview';
|
||||||
|
|
||||||
|
\PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
|
||||||
|
|
||||||
|
// Second run must NOT overwrite the admin's manual choice.
|
||||||
|
$this->assertSame( 'openrouter', $this->options['prautoblogger_image_provider'] );
|
||||||
|
$this->assertSame( 'google/gemini-3.1-flash-image-preview', $this->options['prautoblogger_image_model'] );
|
||||||
|
}
|
||||||
|
}
|
||||||
91
tests/unit/Core/ActivatorScheduleTest.php
Normal file
91
tests/unit/Core/ActivatorScheduleTest.php
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Tests for `PRAutoBlogger_Activator::next_daily_generation_timestamp()` —
|
||||||
|
* the v0.8.2 timezone-aware replacement for `strtotime("tomorrow HH:MM")`
|
||||||
|
* which evaluated in UTC regardless of the WordPress site timezone.
|
||||||
|
*
|
||||||
|
* See thread scheduled-gen-timezone/01-cto-handoff.md for the bug report.
|
||||||
|
*
|
||||||
|
* @package PRAutoBlogger\Tests\Core
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace PRAutoBlogger\Tests\Core;
|
||||||
|
|
||||||
|
use PRAutoBlogger\Tests\BaseTestCase;
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
|
||||||
|
class ActivatorScheduleTest extends BaseTestCase {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stub `wp_timezone()` to return the given tz id, and seed
|
||||||
|
* `prautoblogger_schedule_time` with the given value.
|
||||||
|
*/
|
||||||
|
private function with_site_tz_and_time( string $tz_id, string $time_str ): void {
|
||||||
|
$this->stub_get_option( [
|
||||||
|
'prautoblogger_schedule_time' => $time_str,
|
||||||
|
] );
|
||||||
|
Functions\when( 'wp_timezone' )->alias(
|
||||||
|
static fn() => new \DateTimeZone( $tz_id )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On an Asia/Singapore site (UTC+8), `schedule_time = 06:00` must
|
||||||
|
* resolve to 22:00 UTC the previous day — the prod bug from thread
|
||||||
|
* scheduled-gen-timezone seq 1.
|
||||||
|
*/
|
||||||
|
public function test_schedule_cron_uses_site_timezone(): void {
|
||||||
|
$this->with_site_tz_and_time( 'Asia/Singapore', '06:00' );
|
||||||
|
|
||||||
|
$timestamp = \PRAutoBlogger_Activator::next_daily_generation_timestamp();
|
||||||
|
|
||||||
|
$this->assertGreaterThan( 0, $timestamp );
|
||||||
|
$utc = ( new \DateTimeImmutable( '@' . $timestamp ) )->setTimezone( new \DateTimeZone( 'UTC' ) );
|
||||||
|
$this->assertSame( '22:00', $utc->format( 'H:i' ), 'Expected 06:00 SGT == 22:00 UTC previous day' );
|
||||||
|
|
||||||
|
// And it really is "tomorrow" in Singapore — date part should not be today SGT.
|
||||||
|
$sgt = ( new \DateTimeImmutable( '@' . $timestamp ) )->setTimezone( new \DateTimeZone( 'Asia/Singapore' ) );
|
||||||
|
$tomorrow_sgt = ( new \DateTimeImmutable( 'tomorrow', new \DateTimeZone( 'Asia/Singapore' ) ) )->format( 'Y-m-d' );
|
||||||
|
$this->assertSame( $tomorrow_sgt, $sgt->format( 'Y-m-d' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UTC site, `06:00` input → 06:00 UTC. Behaviour unchanged for
|
||||||
|
* the always-UTC case; regression guard.
|
||||||
|
*/
|
||||||
|
public function test_schedule_cron_handles_utc_site(): void {
|
||||||
|
$this->with_site_tz_and_time( 'UTC', '06:00' );
|
||||||
|
|
||||||
|
$timestamp = \PRAutoBlogger_Activator::next_daily_generation_timestamp();
|
||||||
|
|
||||||
|
$utc = ( new \DateTimeImmutable( '@' . $timestamp ) )->setTimezone( new \DateTimeZone( 'UTC' ) );
|
||||||
|
$this->assertSame( '06:00', $utc->format( 'H:i' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nepal runs UTC+5:45. 06:00 local → 00:15 UTC. Guards against any
|
||||||
|
* integer-hour-offset assumptions in the conversion logic.
|
||||||
|
*/
|
||||||
|
public function test_schedule_cron_handles_fractional_offset(): void {
|
||||||
|
$this->with_site_tz_and_time( 'Asia/Kathmandu', '06:00' );
|
||||||
|
|
||||||
|
$timestamp = \PRAutoBlogger_Activator::next_daily_generation_timestamp();
|
||||||
|
|
||||||
|
$utc = ( new \DateTimeImmutable( '@' . $timestamp ) )->setTimezone( new \DateTimeZone( 'UTC' ) );
|
||||||
|
$this->assertSame( '00:15', $utc->format( 'H:i' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Malformed `schedule_time` (e.g. blank string saved accidentally)
|
||||||
|
* must not throw — `absint()` clamps to 0:0 and the helper returns
|
||||||
|
* a valid timestamp. Regression guard against the "bricked schedule
|
||||||
|
* on bad input" failure mode.
|
||||||
|
*/
|
||||||
|
public function test_schedule_cron_survives_malformed_time_input(): void {
|
||||||
|
$this->with_site_tz_and_time( 'UTC', '' );
|
||||||
|
|
||||||
|
$timestamp = \PRAutoBlogger_Activator::next_daily_generation_timestamp();
|
||||||
|
|
||||||
|
$this->assertGreaterThan( 0, $timestamp );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,9 @@ class IdeaScorerTest extends BaseTestCase {
|
|||||||
$this->stub_get_option( [
|
$this->stub_get_option( [
|
||||||
'prautoblogger_topic_exclusions' => '[]',
|
'prautoblogger_topic_exclusions' => '[]',
|
||||||
] );
|
] );
|
||||||
|
|
||||||
|
// has_similar_post() → fetch_existing_titles() calls get_the_title().
|
||||||
|
Functions\when( 'get_the_title' )->justReturn( 'Existing Post Title' );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -141,4 +144,27 @@ class IdeaScorerTest extends BaseTestCase {
|
|||||||
|
|
||||||
$this->assertIsArray( $result );
|
$this->assertIsArray( $result );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that topics sharing only common niche words are NOT deduped.
|
||||||
|
*
|
||||||
|
* Regression test for the WP_Query 's' bug: WordPress search matched
|
||||||
|
* ANY word, so "BPC-157 dosage" deduped against "BPC-157 storage"
|
||||||
|
* even though they're completely different articles.
|
||||||
|
*/
|
||||||
|
public function test_dedup_does_not_reject_different_topics_with_shared_words(): void {
|
||||||
|
// The default WP_Query stub returns 0 posts, so nothing gets deduped.
|
||||||
|
// This test verifies that score_and_rank passes ideas through when
|
||||||
|
// no similar posts exist (the common case after the fix).
|
||||||
|
$fixture = $this->get_analysis_result_fixture();
|
||||||
|
$fixture['topic'] = 'BPC-157 dosage guide for subcutaneous injection';
|
||||||
|
|
||||||
|
$scorer = new \PRAutoBlogger_Idea_Scorer();
|
||||||
|
$result = $scorer->score_and_rank(
|
||||||
|
[ new \PRAutoBlogger_Analysis_Result( $fixture ) ],
|
||||||
|
5
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertCount( 1, $result, 'Idea should not be deduped when no similar post exists.' );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,8 +35,16 @@ class ImagePipelineTest extends BaseTestCase {
|
|||||||
return trim( strip_tags( $str ) );
|
return trim( strip_tags( $str ) );
|
||||||
} );
|
} );
|
||||||
Functions\when( 'update_post_meta' )->justReturn( true );
|
Functions\when( 'update_post_meta' )->justReturn( true );
|
||||||
|
Functions\when( 'set_post_thumbnail' )->justReturn( true );
|
||||||
|
Functions\when( 'get_post_meta' )->justReturn( '' );
|
||||||
|
Functions\when( 'get_post' )->justReturn( (object) [ 'ID' => 1, 'post_content' => '<p>Test content</p>' ] );
|
||||||
|
Functions\when( 'wp_update_post' )->justReturn( 1 );
|
||||||
|
Functions\when( 'wp_get_attachment_url' )->justReturn( 'https://example.com/img.png' );
|
||||||
Functions\when( 'sanitize_title' )->returnArg();
|
Functions\when( 'sanitize_title' )->returnArg();
|
||||||
Functions\when( 'esc_html__' )->returnArg();
|
Functions\when( 'esc_html__' )->returnArg();
|
||||||
|
Functions\when( 'esc_url' )->returnArg();
|
||||||
|
Functions\when( 'esc_attr' )->returnArg();
|
||||||
|
Functions\when( 'esc_html' )->returnArg();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,19 +71,25 @@ class ImagePipelineTest extends BaseTestCase {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Test generate_and_attach_images() returns cost and error array structure.
|
* Test generate_and_attach_images() returns cost and error array structure.
|
||||||
|
*
|
||||||
|
* Pipeline now calls generate_image_batch() with keyed requests.
|
||||||
*/
|
*/
|
||||||
public function test_generate_and_attach_images_returns_correct_structure(): void {
|
public function test_generate_and_attach_images_returns_correct_structure(): void {
|
||||||
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
|
$image_result = [
|
||||||
$provider->method( 'estimate_cost' )->willReturn( 0.05 );
|
|
||||||
$provider->method( 'generate_image' )->willReturn( [
|
|
||||||
'bytes' => 'fake_image_data',
|
'bytes' => 'fake_image_data',
|
||||||
'mime_type' => 'image/png',
|
'mime_type' => 'image/png',
|
||||||
'width' => 1200,
|
'width' => 1200,
|
||||||
'height' => 630,
|
'height' => 630,
|
||||||
'model' => 'flux-1-schnell',
|
'model' => 'test-model',
|
||||||
'seed' => 123,
|
'seed' => 123,
|
||||||
'cost_usd' => 0.05,
|
'cost_usd' => 0.05,
|
||||||
'latency_ms' => 2000,
|
'latency_ms' => 2000,
|
||||||
|
];
|
||||||
|
|
||||||
|
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
|
||||||
|
$provider->method( 'estimate_cost' )->willReturn( 0.05 );
|
||||||
|
$provider->method( 'generate_image_batch' )->willReturn( [
|
||||||
|
'image_a' => $image_result,
|
||||||
] );
|
] );
|
||||||
|
|
||||||
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
|
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
|
||||||
@@ -83,7 +97,6 @@ class ImagePipelineTest extends BaseTestCase {
|
|||||||
|
|
||||||
$pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker );
|
$pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker );
|
||||||
|
|
||||||
// Mock file writing for sideload.
|
|
||||||
Functions\when( 'get_temp_dir' )->justReturn( '/tmp/' );
|
Functions\when( 'get_temp_dir' )->justReturn( '/tmp/' );
|
||||||
Functions\when( 'media_handle_sideload' )->justReturn( 42 );
|
Functions\when( 'media_handle_sideload' )->justReturn( 42 );
|
||||||
|
|
||||||
@@ -96,19 +109,24 @@ class ImagePipelineTest extends BaseTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test that cost is accumulated when both images are generated.
|
* Test that cost is accumulated when both images are generated via batch.
|
||||||
*/
|
*/
|
||||||
public function test_cost_is_accumulated_for_both_images(): void {
|
public function test_cost_is_accumulated_for_both_images(): void {
|
||||||
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
|
$image_result = [
|
||||||
$provider->method( 'estimate_cost' )->willReturn( 0.05 );
|
|
||||||
$provider->method( 'generate_image' )->willReturn( [
|
|
||||||
'bytes' => 'fake_image_data',
|
'bytes' => 'fake_image_data',
|
||||||
'mime_type' => 'image/png',
|
'mime_type' => 'image/png',
|
||||||
'width' => 1200,
|
'width' => 1200,
|
||||||
'height' => 630,
|
'height' => 630,
|
||||||
'model' => 'flux-1-schnell',
|
'model' => 'test-model',
|
||||||
'cost_usd' => 0.05,
|
'cost_usd' => 0.05,
|
||||||
'latency_ms' => 2000,
|
'latency_ms' => 2000,
|
||||||
|
];
|
||||||
|
|
||||||
|
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
|
||||||
|
$provider->method( 'estimate_cost' )->willReturn( 0.05 );
|
||||||
|
$provider->method( 'generate_image_batch' )->willReturn( [
|
||||||
|
'image_a' => $image_result,
|
||||||
|
'image_b' => $image_result,
|
||||||
] );
|
] );
|
||||||
|
|
||||||
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
|
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
|
||||||
@@ -126,17 +144,60 @@ class ImagePipelineTest extends BaseTestCase {
|
|||||||
[ 'title' => 'Source' ]
|
[ 'title' => 'Source' ]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Cost should be accumulated from both images.
|
// Cost should be accumulated from both images ($0.05 × 2 = $0.10).
|
||||||
$this->assertGreaterThan( 0.05, $result['cost_usd'] );
|
$this->assertEqualsWithDelta( 0.10, $result['cost_usd'], 0.001 );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test graceful handling when image generation fails.
|
* Test that Image B is NOT included in the batch when source_data is null.
|
||||||
|
*
|
||||||
|
* Regression test: the pipeline must only send 'image_a' in the batch
|
||||||
|
* request when no source data is provided.
|
||||||
*/
|
*/
|
||||||
public function test_graceful_failure_when_image_generation_fails(): void {
|
public function test_image_b_skipped_when_source_data_is_null(): void {
|
||||||
|
$image_result = [
|
||||||
|
'bytes' => 'fake_image_data',
|
||||||
|
'mime_type' => 'image/png',
|
||||||
|
'width' => 1200,
|
||||||
|
'height' => 630,
|
||||||
|
'model' => 'test-model',
|
||||||
|
'cost_usd' => 0.05,
|
||||||
|
'latency_ms' => 2000,
|
||||||
|
];
|
||||||
|
|
||||||
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
|
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
|
||||||
$provider->method( 'estimate_cost' )->willReturn( 0.05 );
|
$provider->method( 'estimate_cost' )->willReturn( 0.05 );
|
||||||
$provider->method( 'generate_image' )->willThrowException( new \Exception( 'API timeout' ) );
|
// Verify batch only contains 'image_a'.
|
||||||
|
$provider->expects( $this->once() )
|
||||||
|
->method( 'generate_image_batch' )
|
||||||
|
->with( $this->callback( function ( $requests ) {
|
||||||
|
return array_key_exists( 'image_a', $requests )
|
||||||
|
&& ! array_key_exists( 'image_b', $requests );
|
||||||
|
} ) )
|
||||||
|
->willReturn( [ 'image_a' => $image_result ] );
|
||||||
|
|
||||||
|
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
|
||||||
|
$cost_tracker->method( 'would_exceed_budget' )->willReturn( false );
|
||||||
|
$cost_tracker->expects( $this->exactly( 1 ) )->method( 'log_image_generation' );
|
||||||
|
|
||||||
|
$pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker );
|
||||||
|
|
||||||
|
Functions\when( 'get_temp_dir' )->justReturn( '/tmp/' );
|
||||||
|
Functions\when( 'media_handle_sideload' )->justReturn( 42 );
|
||||||
|
|
||||||
|
$result = $pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test' ], null );
|
||||||
|
|
||||||
|
$this->assertArrayHasKey( 'image_a_id', $result );
|
||||||
|
$this->assertArrayNotHasKey( 'image_b_id', $result );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test graceful handling when batch generation throws.
|
||||||
|
*/
|
||||||
|
public function test_graceful_failure_when_batch_generation_throws(): void {
|
||||||
|
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
|
||||||
|
$provider->method( 'estimate_cost' )->willReturn( 0.05 );
|
||||||
|
$provider->method( 'generate_image_batch' )->willThrowException( new \Exception( 'API timeout' ) );
|
||||||
|
|
||||||
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
|
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
|
||||||
$cost_tracker->method( 'would_exceed_budget' )->willReturn( false );
|
$cost_tracker->method( 'would_exceed_budget' )->willReturn( false );
|
||||||
@@ -145,9 +206,185 @@ class ImagePipelineTest extends BaseTestCase {
|
|||||||
|
|
||||||
$result = $pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test' ] );
|
$result = $pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test' ] );
|
||||||
|
|
||||||
// Should record error but return valid structure.
|
|
||||||
$this->assertIsArray( $result );
|
$this->assertIsArray( $result );
|
||||||
$this->assertNotEmpty( $result['errors'] );
|
$this->assertNotEmpty( $result['errors'] );
|
||||||
$this->assertStringContainsString( 'API timeout', implode( ' ', $result['errors'] ) );
|
$this->assertStringContainsString( 'API timeout', implode( ' ', $result['errors'] ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that a Logger::warning row is persisted when image_data carries an error.
|
||||||
|
*
|
||||||
|
* Covers Issue 2 from the image-mime-bug thread: previously the pipeline
|
||||||
|
* pushed the error into $result['errors'] silently, with no WARNING-level
|
||||||
|
* event_log row visible to ops. This regression-guards that a WARNING row
|
||||||
|
* now fires whether or not the outer catch in the caller triggers.
|
||||||
|
*/
|
||||||
|
public function test_partial_batch_failure_emits_warning_log_row(): void {
|
||||||
|
// Seed Logger's log_level so warnings actually get written.
|
||||||
|
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
|
||||||
|
static $options = [
|
||||||
|
'prautoblogger_image_enabled' => '1',
|
||||||
|
'prautoblogger_log_level' => 'info',
|
||||||
|
];
|
||||||
|
return $options[ $key ] ?? $default;
|
||||||
|
} );
|
||||||
|
|
||||||
|
$mock_wpdb = $this->create_mock_wpdb();
|
||||||
|
$captured_rows = [];
|
||||||
|
$mock_wpdb->method( 'insert' )->willReturnCallback(
|
||||||
|
function ( $table, $row ) use ( &$captured_rows ) {
|
||||||
|
$captured_rows[] = $row;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
$GLOBALS['wpdb'] = $mock_wpdb;
|
||||||
|
|
||||||
|
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
|
||||||
|
$provider->method( 'estimate_cost' )->willReturn( 0.05 );
|
||||||
|
$provider->method( 'generate_image_batch' )->willReturn( [
|
||||||
|
'image_a' => [ 'error' => 'HTTP 400: flux-1-schnell is not a valid model ID' ],
|
||||||
|
] );
|
||||||
|
|
||||||
|
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
|
||||||
|
$cost_tracker->method( 'would_exceed_budget' )->willReturn( false );
|
||||||
|
|
||||||
|
$pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker );
|
||||||
|
$pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test' ], null );
|
||||||
|
|
||||||
|
$warning_rows = array_filter(
|
||||||
|
$captured_rows,
|
||||||
|
static fn( $row ) => ( $row['level'] ?? '' ) === 'warning'
|
||||||
|
&& ( $row['context'] ?? '' ) === 'image_pipeline'
|
||||||
|
);
|
||||||
|
$this->assertNotEmpty(
|
||||||
|
$warning_rows,
|
||||||
|
'Expected at least one image_pipeline WARNING row when image_data has an error key.'
|
||||||
|
);
|
||||||
|
|
||||||
|
unset( $GLOBALS['wpdb'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NSFW-blocked slots should be retried once with a sanitized fallback
|
||||||
|
* prompt. Retry success replaces the error entry with a real image.
|
||||||
|
*/
|
||||||
|
public function test_nsfw_blocked_slot_is_retried_with_fallback_prompt(): void {
|
||||||
|
// NSFW retry is default on; explicit for clarity.
|
||||||
|
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
|
||||||
|
static $options = [
|
||||||
|
'prautoblogger_image_enabled' => '1',
|
||||||
|
'prautoblogger_image_nsfw_retry' => '1',
|
||||||
|
'prautoblogger_image_style_suffix' => 'Style: test suffix',
|
||||||
|
'prautoblogger_log_level' => 'info',
|
||||||
|
];
|
||||||
|
return $options[ $key ] ?? $default;
|
||||||
|
} );
|
||||||
|
|
||||||
|
$success_result = [
|
||||||
|
'bytes' => 'fake_image_bytes',
|
||||||
|
'mime_type' => 'image/png',
|
||||||
|
'width' => 1200,
|
||||||
|
'height' => 632,
|
||||||
|
'model' => 'flux-1-schnell',
|
||||||
|
'cost_usd' => 0.000834,
|
||||||
|
'latency_ms' => 1500,
|
||||||
|
];
|
||||||
|
|
||||||
|
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
|
||||||
|
$provider->method( 'estimate_cost' )->willReturn( 0.001 );
|
||||||
|
// First batch: NSFW. Second batch (retry for image_a): success.
|
||||||
|
$provider->expects( $this->exactly( 2 ) )
|
||||||
|
->method( 'generate_image_batch' )
|
||||||
|
->willReturnOnConsecutiveCalls(
|
||||||
|
[ 'image_a' => [ 'error' => 'NSFW', 'error_type' => 'nsfw_blocked' ] ],
|
||||||
|
[ 'image_a' => $success_result ]
|
||||||
|
);
|
||||||
|
|
||||||
|
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
|
||||||
|
$cost_tracker->method( 'would_exceed_budget' )->willReturn( false );
|
||||||
|
|
||||||
|
Functions\when( 'get_temp_dir' )->justReturn( '/tmp/' );
|
||||||
|
Functions\when( 'media_handle_sideload' )->justReturn( 42 );
|
||||||
|
|
||||||
|
$pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker );
|
||||||
|
$result = $pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test Article' ] );
|
||||||
|
|
||||||
|
// Retry succeeded — image_a_id set, no errors.
|
||||||
|
$this->assertArrayHasKey( 'image_a_id', $result );
|
||||||
|
$this->assertEmpty( $result['errors'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When `prautoblogger_image_nsfw_retry` is off, blocked slots stay
|
||||||
|
* blocked — no second HTTP call, pipeline publishes without that
|
||||||
|
* image. Failsafe for when the filter gets trigger-happy.
|
||||||
|
*/
|
||||||
|
public function test_nsfw_retry_skipped_when_setting_disabled(): void {
|
||||||
|
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
|
||||||
|
static $options = [
|
||||||
|
'prautoblogger_image_enabled' => '1',
|
||||||
|
'prautoblogger_image_nsfw_retry' => '0',
|
||||||
|
];
|
||||||
|
return $options[ $key ] ?? $default;
|
||||||
|
} );
|
||||||
|
|
||||||
|
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
|
||||||
|
$provider->method( 'estimate_cost' )->willReturn( 0.001 );
|
||||||
|
$provider->expects( $this->exactly( 1 ) )
|
||||||
|
->method( 'generate_image_batch' )
|
||||||
|
->willReturn( [
|
||||||
|
'image_a' => [ 'error' => 'NSFW', 'error_type' => 'nsfw_blocked' ],
|
||||||
|
] );
|
||||||
|
|
||||||
|
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
|
||||||
|
$cost_tracker->method( 'would_exceed_budget' )->willReturn( false );
|
||||||
|
|
||||||
|
$pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker );
|
||||||
|
$result = $pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test Article' ] );
|
||||||
|
|
||||||
|
$this->assertArrayNotHasKey( 'image_a_id', $result );
|
||||||
|
$this->assertNotEmpty( $result['errors'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test graceful handling when one image in the batch returns an error.
|
||||||
|
*/
|
||||||
|
public function test_partial_batch_failure_handled_gracefully(): void {
|
||||||
|
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
|
||||||
|
$provider->method( 'estimate_cost' )->willReturn( 0.05 );
|
||||||
|
$provider->method( 'generate_image_batch' )->willReturn( [
|
||||||
|
'image_a' => [
|
||||||
|
'bytes' => 'fake_image_data',
|
||||||
|
'mime_type' => 'image/png',
|
||||||
|
'width' => 1200,
|
||||||
|
'height' => 630,
|
||||||
|
'model' => 'test-model',
|
||||||
|
'cost_usd' => 0.05,
|
||||||
|
'latency_ms' => 2000,
|
||||||
|
],
|
||||||
|
'image_b' => [ 'error' => 'HTTP 429: rate limited' ],
|
||||||
|
] );
|
||||||
|
|
||||||
|
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
|
||||||
|
$cost_tracker->method( 'would_exceed_budget' )->willReturn( false );
|
||||||
|
// Only Image A gets logged — Image B failed.
|
||||||
|
$cost_tracker->expects( $this->exactly( 1 ) )->method( 'log_image_generation' );
|
||||||
|
|
||||||
|
$pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker );
|
||||||
|
|
||||||
|
Functions\when( 'get_temp_dir' )->justReturn( '/tmp/' );
|
||||||
|
Functions\when( 'media_handle_sideload' )->justReturn( 42 );
|
||||||
|
|
||||||
|
$result = $pipeline->generate_and_attach_images(
|
||||||
|
1,
|
||||||
|
[ 'post_title' => 'Test' ],
|
||||||
|
[ 'title' => 'Source' ]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Image A succeeds, Image B records an error.
|
||||||
|
$this->assertArrayHasKey( 'image_a_id', $result );
|
||||||
|
$this->assertArrayNotHasKey( 'image_b_id', $result );
|
||||||
|
$this->assertNotEmpty( $result['errors'] );
|
||||||
|
$this->assertStringContainsString( '429', implode( ' ', $result['errors'] ) );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,32 @@ class ImagePromptBuilderTest extends BaseTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test build_article_prompt() generates a prompt from article title and content.
|
* Inject a mock OpenRouter provider into the Prompt_Builder via
|
||||||
|
* reflection so the rewrite path can be exercised without a real HTTP
|
||||||
|
* call. Used by the system-prompt override tests below.
|
||||||
|
*/
|
||||||
|
private function inject_llm(
|
||||||
|
\PRAutoBlogger_Image_Prompt_Builder $builder,
|
||||||
|
$mock_llm
|
||||||
|
): void {
|
||||||
|
$ref = new \ReflectionClass( \PRAutoBlogger_Image_Prompt_Builder::class );
|
||||||
|
$prop = $ref->getProperty( 'llm' );
|
||||||
|
$prop->setAccessible( true );
|
||||||
|
$prop->setValue( $builder, $mock_llm );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test build_article_prompt() generates a prompt from article title.
|
||||||
|
*
|
||||||
|
* `build_article_prompt()` has returned `array{prompt: string, caption: string}`
|
||||||
|
* since v0.4.x. Destructure on receipt so the assertions run against the
|
||||||
|
* scene string, not the envelope.
|
||||||
|
*
|
||||||
|
* Note: this test does NOT assert on the article content substring. With no
|
||||||
|
* OpenRouter API key in the unit-test env, `rewrite_via_llm()` catches and
|
||||||
|
* falls back to `synthesize_visual_concepts_fallback()`, which reliably
|
||||||
|
* includes only the title — not the content. Content-based assertions
|
||||||
|
* belong in a test that mocks the LLM (see `test_rewrite_uses_setting_when_present`).
|
||||||
*/
|
*/
|
||||||
public function test_build_article_prompt_from_content(): void {
|
public function test_build_article_prompt_from_content(): void {
|
||||||
$builder = new \PRAutoBlogger_Image_Prompt_Builder();
|
$builder = new \PRAutoBlogger_Image_Prompt_Builder();
|
||||||
@@ -40,13 +65,13 @@ class ImagePromptBuilderTest extends BaseTestCase {
|
|||||||
'post_content' => '<p>Dragons are fascinating creatures. Learn the secrets.</p>',
|
'post_content' => '<p>Dragons are fascinating creatures. Learn the secrets.</p>',
|
||||||
];
|
];
|
||||||
|
|
||||||
$prompt = $builder->build_article_prompt( $article_data );
|
[ 'prompt' => $scene, 'caption' => $caption ] = $builder->build_article_prompt( $article_data );
|
||||||
|
|
||||||
$this->assertStringContainsString( 'How to Train Your Dragon', $prompt );
|
$this->assertStringContainsString( 'How to Train Your Dragon', $scene );
|
||||||
$this->assertStringContainsString( 'Dragons are fascinating', $prompt );
|
|
||||||
// Style suffix should be appended.
|
// Style suffix should be appended.
|
||||||
$this->assertNotEmpty( $prompt );
|
$this->assertNotEmpty( $scene );
|
||||||
$this->assertGreaterThan( 50, strlen( $prompt ) );
|
$this->assertGreaterThan( 50, strlen( $scene ) );
|
||||||
|
$this->assertIsString( $caption );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,10 +84,11 @@ class ImagePromptBuilderTest extends BaseTestCase {
|
|||||||
'post_title' => 'Minimal Article',
|
'post_title' => 'Minimal Article',
|
||||||
];
|
];
|
||||||
|
|
||||||
$prompt = $builder->build_article_prompt( $article_data );
|
[ 'prompt' => $scene, 'caption' => $caption ] = $builder->build_article_prompt( $article_data );
|
||||||
|
|
||||||
$this->assertStringContainsString( 'Minimal Article', $prompt );
|
$this->assertStringContainsString( 'Minimal Article', $scene );
|
||||||
$this->assertNotEmpty( $prompt );
|
$this->assertNotEmpty( $scene );
|
||||||
|
$this->assertIsString( $caption );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -79,11 +105,12 @@ class ImagePromptBuilderTest extends BaseTestCase {
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
$prompt = $builder->build_source_prompt( $source_data );
|
[ 'prompt' => $scene, 'caption' => $caption ] = $builder->build_source_prompt( $source_data );
|
||||||
|
|
||||||
$this->assertStringContainsString( 'Robot', $prompt );
|
$this->assertStringContainsString( 'Robot', $scene );
|
||||||
$this->assertStringContainsString( 'Dancing', $prompt );
|
$this->assertStringContainsString( 'Dancing', $scene );
|
||||||
$this->assertNotEmpty( $prompt );
|
$this->assertNotEmpty( $scene );
|
||||||
|
$this->assertIsString( $caption );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -96,14 +123,18 @@ class ImagePromptBuilderTest extends BaseTestCase {
|
|||||||
'title' => 'Standalone Title',
|
'title' => 'Standalone Title',
|
||||||
];
|
];
|
||||||
|
|
||||||
$prompt = $builder->build_source_prompt( $source_data );
|
[ 'prompt' => $scene, 'caption' => $caption ] = $builder->build_source_prompt( $source_data );
|
||||||
|
|
||||||
$this->assertStringContainsString( 'Standalone Title', $prompt );
|
$this->assertStringContainsString( 'Standalone Title', $scene );
|
||||||
$this->assertNotEmpty( $prompt );
|
$this->assertNotEmpty( $scene );
|
||||||
|
$this->assertIsString( $caption );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test that prompts don't exceed reasonable length.
|
* Test that prompts don't exceed reasonable length.
|
||||||
|
*
|
||||||
|
* The 500-char cap applies to the scene + style suffix (the `prompt`
|
||||||
|
* field), not the full array envelope.
|
||||||
*/
|
*/
|
||||||
public function test_prompts_are_reasonably_sized(): void {
|
public function test_prompts_are_reasonably_sized(): void {
|
||||||
$builder = new \PRAutoBlogger_Image_Prompt_Builder();
|
$builder = new \PRAutoBlogger_Image_Prompt_Builder();
|
||||||
@@ -114,9 +145,110 @@ class ImagePromptBuilderTest extends BaseTestCase {
|
|||||||
'post_content' => $long_content,
|
'post_content' => $long_content,
|
||||||
];
|
];
|
||||||
|
|
||||||
$prompt = $builder->build_article_prompt( $article_data );
|
[ 'prompt' => $scene ] = $builder->build_article_prompt( $article_data );
|
||||||
|
|
||||||
// Prompt should be under 500 chars (concept + style suffix).
|
// Scene + style suffix should be under 500 chars.
|
||||||
$this->assertLessThan( 500, strlen( $prompt ) );
|
$this->assertLessThan( 500, strlen( $scene ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When `prautoblogger_image_prompt_instructions` is non-empty, the
|
||||||
|
* rewriter LLM must receive that content as its system message —
|
||||||
|
* NOT the hardcoded REWRITER_SYSTEM_PROMPT constant.
|
||||||
|
*/
|
||||||
|
public function test_rewrite_uses_setting_when_present(): void {
|
||||||
|
$custom_system = 'CUSTOM-OVERRIDE: tell the LLM a very different thing.';
|
||||||
|
|
||||||
|
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) use ( $custom_system ) {
|
||||||
|
if ( 'prautoblogger_image_prompt_instructions' === $key ) {
|
||||||
|
return $custom_system;
|
||||||
|
}
|
||||||
|
if ( 'prautoblogger_image_style_suffix' === $key ) {
|
||||||
|
return 'Style: x';
|
||||||
|
}
|
||||||
|
return $default;
|
||||||
|
} );
|
||||||
|
|
||||||
|
$captured_messages = [];
|
||||||
|
$mock_llm = $this->createMock( \PRAutoBlogger_OpenRouter_Provider::class );
|
||||||
|
$mock_llm->method( 'send_chat_completion' )->willReturnCallback(
|
||||||
|
function ( $messages ) use ( &$captured_messages ) {
|
||||||
|
$captured_messages = $messages;
|
||||||
|
return [
|
||||||
|
'content' => "A scene.\n\n\"A caption.\"",
|
||||||
|
'prompt_tokens' => 10,
|
||||||
|
'completion_tokens' => 10,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
);
|
||||||
|
$mock_llm->method( 'estimate_cost' )->willReturn( 0.0 );
|
||||||
|
|
||||||
|
// Stub $wpdb so Cost_Tracker::log_api_call inside rewrite_via_llm
|
||||||
|
// can insert without blowing up.
|
||||||
|
$mock_wpdb = $this->create_mock_wpdb();
|
||||||
|
$mock_wpdb->insert_id = 1;
|
||||||
|
$mock_wpdb->method( 'insert' )->willReturn( 1 );
|
||||||
|
$GLOBALS['wpdb'] = $mock_wpdb;
|
||||||
|
|
||||||
|
$builder = new \PRAutoBlogger_Image_Prompt_Builder();
|
||||||
|
$this->inject_llm( $builder, $mock_llm );
|
||||||
|
|
||||||
|
$builder->build_article_prompt( [ 'post_title' => 'Test', 'post_content' => '<p>Body.</p>' ] );
|
||||||
|
|
||||||
|
unset( $GLOBALS['wpdb'] );
|
||||||
|
|
||||||
|
$this->assertNotEmpty( $captured_messages, 'Rewriter LLM was not called.' );
|
||||||
|
$this->assertSame( 'system', $captured_messages[0]['role'] ?? '' );
|
||||||
|
$this->assertSame( $custom_system, $captured_messages[0]['content'] ?? '' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the option is empty (or whitespace-only), the rewriter must
|
||||||
|
* fall back to REWRITER_SYSTEM_PROMPT — belt-and-braces so a blank
|
||||||
|
* save from the admin UI cannot brick image generation.
|
||||||
|
*/
|
||||||
|
public function test_rewrite_falls_back_to_default_when_setting_empty(): void {
|
||||||
|
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
|
||||||
|
if ( 'prautoblogger_image_prompt_instructions' === $key ) {
|
||||||
|
return ' '; // whitespace — should trigger fallback.
|
||||||
|
}
|
||||||
|
if ( 'prautoblogger_image_style_suffix' === $key ) {
|
||||||
|
return 'Style: x';
|
||||||
|
}
|
||||||
|
return $default;
|
||||||
|
} );
|
||||||
|
|
||||||
|
$captured_messages = [];
|
||||||
|
$mock_llm = $this->createMock( \PRAutoBlogger_OpenRouter_Provider::class );
|
||||||
|
$mock_llm->method( 'send_chat_completion' )->willReturnCallback(
|
||||||
|
function ( $messages ) use ( &$captured_messages ) {
|
||||||
|
$captured_messages = $messages;
|
||||||
|
return [
|
||||||
|
'content' => "A scene.\n\n\"A caption.\"",
|
||||||
|
'prompt_tokens' => 10,
|
||||||
|
'completion_tokens' => 10,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
);
|
||||||
|
$mock_llm->method( 'estimate_cost' )->willReturn( 0.0 );
|
||||||
|
|
||||||
|
// Stub $wpdb so Cost_Tracker::log_api_call inside rewrite_via_llm
|
||||||
|
// can insert without blowing up.
|
||||||
|
$mock_wpdb = $this->create_mock_wpdb();
|
||||||
|
$mock_wpdb->insert_id = 1;
|
||||||
|
$mock_wpdb->method( 'insert' )->willReturn( 1 );
|
||||||
|
$GLOBALS['wpdb'] = $mock_wpdb;
|
||||||
|
|
||||||
|
$builder = new \PRAutoBlogger_Image_Prompt_Builder();
|
||||||
|
$this->inject_llm( $builder, $mock_llm );
|
||||||
|
|
||||||
|
$builder->build_article_prompt( [ 'post_title' => 'Test', 'post_content' => '<p>Body.</p>' ] );
|
||||||
|
|
||||||
|
unset( $GLOBALS['wpdb'] );
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
\PRAutoBlogger_Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT,
|
||||||
|
$captured_messages[0]['content'] ?? ''
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
286
tests/unit/Core/ResearchReaperTest.php
Normal file
286
tests/unit/Core/ResearchReaperTest.php
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Tests for PRAutoBlogger_Research_Reaper.
|
||||||
|
*
|
||||||
|
* Exercises the five scenarios spec'd in convo thread
|
||||||
|
* research-cost-reaper/01-cto-handoff.md: meta-based reap, grace-period
|
||||||
|
* skip, 7-day stale delete, gen_log fallback when meta is missing, and
|
||||||
|
* idempotency on repeat runs.
|
||||||
|
*
|
||||||
|
* All DB work is mocked via a $wpdb stub — no real database round-trips.
|
||||||
|
*
|
||||||
|
* @package PRAutoBlogger\Tests\Core
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace PRAutoBlogger\Tests\Core;
|
||||||
|
|
||||||
|
use PRAutoBlogger\Tests\BaseTestCase;
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
|
||||||
|
class ResearchReaperTest extends BaseTestCase {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb.
|
||||||
|
*/
|
||||||
|
private $wpdb;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captured INSERT rows (from amortize_research_costs delegating to $wpdb->insert).
|
||||||
|
*
|
||||||
|
* @var array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private array $inserted_rows = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captured DELETE calls: [$table, $where-array].
|
||||||
|
*
|
||||||
|
* @var array<int, array{0: string, 1: array<string, mixed>}>
|
||||||
|
*/
|
||||||
|
private array $deleted_rows = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixture: the rows get_results should hand back for the orphan scan.
|
||||||
|
*
|
||||||
|
* @var array<int, object>
|
||||||
|
*/
|
||||||
|
private array $orphan_fixtures = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixture: post IDs the reaper's fallback gen_log query should return.
|
||||||
|
*
|
||||||
|
* @var int[]
|
||||||
|
*/
|
||||||
|
private array $gen_log_post_ids = [];
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->inserted_rows = [];
|
||||||
|
$this->deleted_rows = [];
|
||||||
|
$this->orphan_fixtures = [];
|
||||||
|
$this->gen_log_post_ids = [];
|
||||||
|
|
||||||
|
// Bespoke $wpdb mock — BaseTestCase::create_mock_wpdb() doesn't
|
||||||
|
// stub `delete` or `get_col`, both of which the reaper + amortize
|
||||||
|
// call into. Wide-method mock keeps the test scope self-contained.
|
||||||
|
$this->wpdb = $this->getMockBuilder( \stdClass::class )
|
||||||
|
->addMethods( [ 'prepare', 'get_var', 'get_results', 'get_row', 'get_col', 'insert', 'delete', 'update', 'query' ] )
|
||||||
|
->getMock();
|
||||||
|
$this->wpdb->prefix = 'wp_';
|
||||||
|
$this->wpdb->insert_id = 1;
|
||||||
|
$this->wpdb->last_error = '';
|
||||||
|
$this->wpdb->prab_cost_logs = 'wp_prab_cost_logs';
|
||||||
|
$this->wpdb->method( 'insert' )->willReturnCallback(
|
||||||
|
function ( $table, $row ) {
|
||||||
|
$this->inserted_rows[] = array_merge( [ '__table' => $table ], $row );
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
$this->wpdb->method( 'delete' )->willReturnCallback(
|
||||||
|
function ( $table, $where ) {
|
||||||
|
$this->deleted_rows[] = [ (string) $table, (array) $where ];
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// prepare() returns its args joined so we can pattern-match in get_* stubs.
|
||||||
|
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
||||||
|
function ( $sql, ...$args ) {
|
||||||
|
return $sql . '|' . implode( '|', array_map( 'strval', $args ) );
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// get_results routes by SQL fragment between the reaper's orphan scan
|
||||||
|
// and the occasional Logger insert probes.
|
||||||
|
$this->wpdb->method( 'get_results' )->willReturnCallback(
|
||||||
|
function ( $sql ) {
|
||||||
|
if ( false !== stripos( (string) $sql, "post_id IS NULL AND created_at <" ) ) {
|
||||||
|
return $this->orphan_fixtures;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// get_row serves amortize_research_costs's research-row lookup.
|
||||||
|
$this->wpdb->method( 'get_row' )->willReturnCallback(
|
||||||
|
function ( $sql ) {
|
||||||
|
foreach ( $this->orphan_fixtures as $o ) {
|
||||||
|
if ( false !== stripos( (string) $sql, (string) $o->run_id ) ) {
|
||||||
|
return (object) [
|
||||||
|
'id' => $o->id,
|
||||||
|
'estimated_cost' => $o->estimated_cost ?? 0.50,
|
||||||
|
'provider' => $o->provider ?? 'openrouter',
|
||||||
|
'model' => $o->model ?? 'gemini-flash',
|
||||||
|
'prompt_tokens' => $o->prompt_tokens ?? 1000,
|
||||||
|
'completion_tokens' => $o->completion_tokens ?? 500,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// get_col is the reaper's fallback "posts by run_id" and amortize's
|
||||||
|
// "distinct post_ids" query — both read from the same fixture.
|
||||||
|
$this->wpdb->method( 'get_col' )->willReturnCallback(
|
||||||
|
fn() => array_map( 'strval', $this->gen_log_post_ids )
|
||||||
|
);
|
||||||
|
|
||||||
|
$GLOBALS['wpdb'] = $this->wpdb;
|
||||||
|
|
||||||
|
$this->stub_get_option( [
|
||||||
|
'prautoblogger_log_level' => 'info',
|
||||||
|
] );
|
||||||
|
|
||||||
|
// Reaper uses get_posts to look up sibling posts via post_meta.
|
||||||
|
Functions\when( 'get_posts' )->justReturn( [] );
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void {
|
||||||
|
unset( $GLOBALS['wpdb'] );
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return only the `generation_log` inserts from the capture — filters out
|
||||||
|
* the `event_log` rows that Logger::instance()->info() writes as a side
|
||||||
|
* effect of the reaper + amortize's own progress logging.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function gen_log_inserts(): array {
|
||||||
|
return array_values( array_filter(
|
||||||
|
$this->inserted_rows,
|
||||||
|
static fn( $row ) => false !== strpos( (string) ( $row['__table'] ?? '' ), 'generation_log' )
|
||||||
|
) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: seed the orphan list returned by the reaper's primary SELECT.
|
||||||
|
*
|
||||||
|
* @param array<int, array{id: int, run_id: string, created_at: string, estimated_cost?: float, provider?: string, model?: string, prompt_tokens?: int, completion_tokens?: int}> $orphans
|
||||||
|
*/
|
||||||
|
private function seed_orphans( array $orphans ): void {
|
||||||
|
$this->orphan_fixtures = array_map(
|
||||||
|
static fn( $r ) => (object) $r,
|
||||||
|
$orphans
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: set the post_ids the gen_log fallback query returns.
|
||||||
|
*
|
||||||
|
* @param int[] $post_ids
|
||||||
|
*/
|
||||||
|
private function seed_gen_log_posts_for_run( array $post_ids ): void {
|
||||||
|
$this->gen_log_post_ids = $post_ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Reap succeeds via post_meta primary lookup.
|
||||||
|
*
|
||||||
|
* Orphan row is >1h old, `_prautoblogger_run_id` meta returns two
|
||||||
|
* post IDs, amortize inserts two attributed rows and deletes the
|
||||||
|
* original orphan.
|
||||||
|
*/
|
||||||
|
public function test_reaps_orphan_with_matching_run_id_posts(): void {
|
||||||
|
$old_ts = gmdate( 'Y-m-d H:i:s', time() - ( 2 * HOUR_IN_SECONDS ) );
|
||||||
|
$this->seed_orphans( [ [ 'id' => 99, 'run_id' => 'run-abc', 'created_at' => $old_ts, 'estimated_cost' => 0.40 ] ] );
|
||||||
|
|
||||||
|
// In the v0.8.1 world both sources agree. Meta is the reaper's gate;
|
||||||
|
// gen_log also has rows so `amortize_research_costs()` can do its
|
||||||
|
// own internal post-count query.
|
||||||
|
Functions\when( 'get_posts' )->alias(
|
||||||
|
static fn( $args ) => ( '_prautoblogger_run_id' === ( $args['meta_key'] ?? '' ) && 'run-abc' === ( $args['meta_value'] ?? '' ) )
|
||||||
|
? [ 101, 102 ]
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
$this->seed_gen_log_posts_for_run( [ 101, 102 ] );
|
||||||
|
|
||||||
|
$stats = \PRAutoBlogger_Research_Reaper::reap();
|
||||||
|
|
||||||
|
$this->assertSame( 1, $stats['reaped'] );
|
||||||
|
$this->assertSame( 0, $stats['deleted'] );
|
||||||
|
$this->assertSame( 0, $stats['skipped'] );
|
||||||
|
$this->assertCount(
|
||||||
|
2,
|
||||||
|
$this->gen_log_inserts(),
|
||||||
|
'One amortized row per post should be inserted into the generation_log.'
|
||||||
|
);
|
||||||
|
// Original orphan deleted by amortize_research_costs.
|
||||||
|
$this->assertNotEmpty( array_filter( $this->deleted_rows, static fn( $d ) => ( $d[1]['id'] ?? null ) === 99 ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2. Orphan within the 1-hour grace window is left alone — a live
|
||||||
|
* pipeline may still be in the middle of its own amortize step.
|
||||||
|
*/
|
||||||
|
public function test_skips_orphan_within_grace_period(): void {
|
||||||
|
// The primary SELECT filters on `created_at < (now - 1h)`, so a
|
||||||
|
// fresh orphan is excluded from the result set at the DB boundary.
|
||||||
|
// We simulate that by returning an empty orphan list.
|
||||||
|
$this->seed_orphans( [] );
|
||||||
|
|
||||||
|
Functions\when( 'get_posts' )->justReturn( [ 101, 102 ] );
|
||||||
|
|
||||||
|
$stats = \PRAutoBlogger_Research_Reaper::reap();
|
||||||
|
|
||||||
|
$this->assertSame( 0, $stats['reaped'] );
|
||||||
|
$this->assertSame( 0, $stats['deleted'] );
|
||||||
|
$this->assertSame( 0, $stats['skipped'] );
|
||||||
|
$this->assertEmpty( $this->inserted_rows );
|
||||||
|
$this->assertEmpty( $this->deleted_rows );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3. Orphan older than 7 days with zero matching posts is deleted
|
||||||
|
* outright — research cost is sunk with nothing to attribute to.
|
||||||
|
*/
|
||||||
|
public function test_deletes_orphan_with_no_articles_after_7_days(): void {
|
||||||
|
$stale_ts = gmdate( 'Y-m-d H:i:s', time() - ( 8 * DAY_IN_SECONDS ) );
|
||||||
|
$this->seed_orphans( [ [ 'id' => 77, 'run_id' => 'run-dead', 'created_at' => $stale_ts ] ] );
|
||||||
|
|
||||||
|
// No posts via meta, no posts via gen_log.
|
||||||
|
Functions\when( 'get_posts' )->justReturn( [] );
|
||||||
|
$this->seed_gen_log_posts_for_run( [] );
|
||||||
|
|
||||||
|
$stats = \PRAutoBlogger_Research_Reaper::reap();
|
||||||
|
|
||||||
|
$this->assertSame( 0, $stats['reaped'] );
|
||||||
|
$this->assertSame( 1, $stats['deleted'] );
|
||||||
|
$this->assertNotEmpty( array_filter( $this->deleted_rows, static fn( $d ) => ( $d[1]['id'] ?? null ) === 77 ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 4. When `_prautoblogger_run_id` post_meta is absent (legacy posts
|
||||||
|
* predating v0.8.1), the reaper falls back to querying gen_log for
|
||||||
|
* distinct post_ids carrying the same run_id.
|
||||||
|
*/
|
||||||
|
public function test_falls_back_to_gen_log_when_post_meta_missing(): void {
|
||||||
|
$old_ts = gmdate( 'Y-m-d H:i:s', time() - ( 2 * HOUR_IN_SECONDS ) );
|
||||||
|
$this->seed_orphans( [ [ 'id' => 55, 'run_id' => 'run-legacy', 'created_at' => $old_ts, 'estimated_cost' => 0.30 ] ] );
|
||||||
|
|
||||||
|
// Meta lookup returns nothing.
|
||||||
|
Functions\when( 'get_posts' )->justReturn( [] );
|
||||||
|
// But gen_log has two posts with this run_id.
|
||||||
|
$this->seed_gen_log_posts_for_run( [ 201, 202 ] );
|
||||||
|
|
||||||
|
$stats = \PRAutoBlogger_Research_Reaper::reap();
|
||||||
|
|
||||||
|
$this->assertSame( 1, $stats['reaped'] );
|
||||||
|
$this->assertCount( 2, $this->gen_log_inserts() );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 5. A second reap run is a no-op — the first run deleted the orphan
|
||||||
|
* and inserted amortized per-article rows, so the scan returns empty.
|
||||||
|
*/
|
||||||
|
public function test_idempotent_on_repeat_run(): void {
|
||||||
|
// Simulate "already reaped" state: scan returns no orphans.
|
||||||
|
$this->seed_orphans( [] );
|
||||||
|
|
||||||
|
$stats_1 = \PRAutoBlogger_Research_Reaper::reap();
|
||||||
|
$stats_2 = \PRAutoBlogger_Research_Reaper::reap();
|
||||||
|
|
||||||
|
$this->assertSame( 0, $stats_1['reaped'] );
|
||||||
|
$this->assertSame( 0, $stats_2['reaped'] );
|
||||||
|
$this->assertEmpty( $this->inserted_rows );
|
||||||
|
$this->assertEmpty( $this->deleted_rows );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,8 @@ class ContentRequestTest extends BaseTestCase {
|
|||||||
1000,
|
1000,
|
||||||
2500,
|
2500,
|
||||||
'Technology and innovation',
|
'Technology and innovation',
|
||||||
[ 'politics', 'religion' ]
|
[ 'politics', 'religion' ],
|
||||||
|
'Always open with a hook question.'
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->assertSame( $idea, $request->get_idea() );
|
$this->assertSame( $idea, $request->get_idea() );
|
||||||
@@ -35,6 +36,7 @@ class ContentRequestTest extends BaseTestCase {
|
|||||||
$this->assertSame( 2500, $request->get_max_word_count() );
|
$this->assertSame( 2500, $request->get_max_word_count() );
|
||||||
$this->assertSame( 'Technology and innovation', $request->get_niche_description() );
|
$this->assertSame( 'Technology and innovation', $request->get_niche_description() );
|
||||||
$this->assertSame( [ 'politics', 'religion' ], $request->get_topic_exclusions() );
|
$this->assertSame( [ 'politics', 'religion' ], $request->get_topic_exclusions() );
|
||||||
|
$this->assertSame( 'Always open with a hook question.', $request->get_writing_instructions() );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,6 +65,25 @@ class ContentRequestTest extends BaseTestCase {
|
|||||||
$this->assertIsArray( $request->get_topic_exclusions() );
|
$this->assertIsArray( $request->get_topic_exclusions() );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test writing_instructions defaults to empty string when omitted.
|
||||||
|
*/
|
||||||
|
public function test_writing_instructions_defaults_to_empty(): void {
|
||||||
|
$idea_data = $this->get_article_idea_fixture();
|
||||||
|
$idea = new \PRAutoBlogger_Article_Idea( $idea_data );
|
||||||
|
|
||||||
|
$request = new \PRAutoBlogger_Content_Request(
|
||||||
|
$idea,
|
||||||
|
'auto',
|
||||||
|
'professional',
|
||||||
|
1000,
|
||||||
|
2500,
|
||||||
|
'Tech'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame( '', $request->get_writing_instructions() );
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test with empty topic exclusions.
|
* Test with empty topic exclusions.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,432 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Tests for PRAutoBlogger_Cloudflare_Image_Provider.
|
|
||||||
*
|
|
||||||
* Validates the image provider interface implementation methods.
|
|
||||||
* All HTTP calls are mocked — no real API calls.
|
|
||||||
*
|
|
||||||
* @package PRAutoBlogger\Tests\Providers
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace PRAutoBlogger\Tests\Providers;
|
|
||||||
|
|
||||||
use PRAutoBlogger\Tests\BaseTestCase;
|
|
||||||
use Brain\Monkey\Functions;
|
|
||||||
|
|
||||||
class CloudflareImageProviderTest extends BaseTestCase {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fake PNG payload — bytes don't need to be a real PNG, just non-empty.
|
|
||||||
* Using a short string keeps failure messages readable.
|
|
||||||
*/
|
|
||||||
private const FAKE_IMAGE_BYTES = 'FAKE_PNG_BYTES';
|
|
||||||
|
|
||||||
protected function setUp(): void {
|
|
||||||
parent::setUp();
|
|
||||||
|
|
||||||
// PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL constant is defined in
|
|
||||||
// prautoblogger.php — define here for test isolation.
|
|
||||||
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL' ) ) {
|
|
||||||
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL', 'flux-1-schnell' );
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->stub_get_option( [
|
|
||||||
'prautoblogger_cloudflare_ai_token' => '',
|
|
||||||
'prautoblogger_cloudflare_account_id' => 'test-account-uuid',
|
|
||||||
'prautoblogger_image_model' => 'flux-1-schnell',
|
|
||||||
'prautoblogger_log_level' => 'info',
|
|
||||||
] );
|
|
||||||
|
|
||||||
Functions\when( 'wp_salt' )->justReturn( 'test_salt_key_for_unit_tests' );
|
|
||||||
Functions\when( 'is_wp_error' )->justReturn( false );
|
|
||||||
Functions\when( 'wp_remote_retrieve_header' )->justReturn( '' );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper: configure the plaintext API token the provider will eventually
|
|
||||||
* see after decryption. We round-trip through the real Encryption class
|
|
||||||
* (rather than stubbing it) so this test catches any regressions in the
|
|
||||||
* encrypt → option → decrypt flow at the same time.
|
|
||||||
*/
|
|
||||||
private function with_token( string $plaintext ): void {
|
|
||||||
$ciphertext = \PRAutoBlogger_Encryption::encrypt( $plaintext );
|
|
||||||
$this->stub_get_option( [
|
|
||||||
'prautoblogger_cloudflare_ai_token' => $ciphertext,
|
|
||||||
'prautoblogger_cloudflare_account_id' => 'test-account-uuid',
|
|
||||||
'prautoblogger_image_model' => 'flux-1-schnell',
|
|
||||||
'prautoblogger_log_level' => 'info',
|
|
||||||
] );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provider implements the interface.
|
|
||||||
*/
|
|
||||||
public function test_provider_implements_interface(): void {
|
|
||||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
|
||||||
$this->assertInstanceOf( \PRAutoBlogger_Image_Provider_Interface::class, $provider );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* get_provider_name returns the stable machine identifier.
|
|
||||||
*/
|
|
||||||
public function test_provider_name_is_stable_identifier(): void {
|
|
||||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
|
||||||
$this->assertSame( 'cloudflare_workers_ai', $provider->get_provider_name() );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dimension validation rejects inputs outside the [64, 2048] envelope
|
|
||||||
* before any HTTP call is made.
|
|
||||||
*/
|
|
||||||
public function test_generate_image_rejects_out_of_range_dimensions(): void {
|
|
||||||
$this->with_token( 'test-token' );
|
|
||||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
|
||||||
|
|
||||||
$this->expectException( \InvalidArgumentException::class );
|
|
||||||
$provider->generate_image( 'a prompt', 32, 1080 );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Empty prompts are rejected — an empty string burns budget on
|
|
||||||
* Workers AI for a deterministic "bad request" result.
|
|
||||||
*/
|
|
||||||
public function test_generate_image_rejects_empty_prompt(): void {
|
|
||||||
$this->with_token( 'test-token' );
|
|
||||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
|
||||||
|
|
||||||
$this->expectException( \InvalidArgumentException::class );
|
|
||||||
$provider->generate_image( ' ', 1080, 1080 );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Happy path — raw image bytes come back with expected metadata shape.
|
|
||||||
*/
|
|
||||||
public function test_generate_image_returns_raw_bytes_and_metadata(): void {
|
|
||||||
$this->with_token( 'test-token' );
|
|
||||||
|
|
||||||
Functions\when( 'wp_remote_post' )->justReturn( [
|
|
||||||
'body' => self::FAKE_IMAGE_BYTES,
|
|
||||||
'response' => [ 'code' => 200 ],
|
|
||||||
'headers' => [ 'content-type' => 'image/png' ],
|
|
||||||
] );
|
|
||||||
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
|
|
||||||
Functions\when( 'wp_remote_retrieve_body' )->justReturn( self::FAKE_IMAGE_BYTES );
|
|
||||||
Functions\when( 'wp_remote_retrieve_header' )->alias(
|
|
||||||
function ( $_response, $name ) {
|
|
||||||
return 'content-type' === strtolower( (string) $name ) ? 'image/png' : '';
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
|
||||||
$result = $provider->generate_image( 'a prompt', 1080, 1080, [ 'seed' => 42 ] );
|
|
||||||
|
|
||||||
$this->assertSame( self::FAKE_IMAGE_BYTES, $result['bytes'] );
|
|
||||||
$this->assertSame( 'image/png', $result['mime_type'] );
|
|
||||||
$this->assertSame( 1080, $result['width'] );
|
|
||||||
$this->assertSame( 1080, $result['height'] );
|
|
||||||
$this->assertSame( 42, $result['seed'] );
|
|
||||||
$this->assertSame( '@cf/black-forest-labs/flux-1-schnell', $result['model'] );
|
|
||||||
$this->assertGreaterThan( 0.0, $result['cost_usd'] );
|
|
||||||
$this->assertGreaterThanOrEqual( 0, $result['latency_ms'] );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Happy path where Workers AI wraps the image in a JSON envelope with
|
|
||||||
* base64-encoded bytes under `result.image`. The provider must decode
|
|
||||||
* and return the raw bytes so the pipeline never has to branch.
|
|
||||||
*/
|
|
||||||
public function test_generate_image_decodes_json_envelope(): void {
|
|
||||||
$this->with_token( 'test-token' );
|
|
||||||
|
|
||||||
$encoded = base64_encode( self::FAKE_IMAGE_BYTES );
|
|
||||||
$body = wp_json_encode( [ 'result' => [ 'image' => $encoded ], 'success' => true ] );
|
|
||||||
|
|
||||||
Functions\when( 'wp_remote_post' )->justReturn( [
|
|
||||||
'body' => $body,
|
|
||||||
'response' => [ 'code' => 200 ],
|
|
||||||
'headers' => [ 'content-type' => 'application/json' ],
|
|
||||||
] );
|
|
||||||
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
|
|
||||||
Functions\when( 'wp_remote_retrieve_body' )->justReturn( $body );
|
|
||||||
Functions\when( 'wp_remote_retrieve_header' )->alias(
|
|
||||||
function ( $_response, $name ) {
|
|
||||||
return 'content-type' === strtolower( (string) $name ) ? 'application/json' : '';
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
|
||||||
$result = $provider->generate_image( 'a prompt', 600, 600 );
|
|
||||||
|
|
||||||
$this->assertSame( self::FAKE_IMAGE_BYTES, $result['bytes'] );
|
|
||||||
$this->assertSame( 'image/png', $result['mime_type'] );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A 401 must NOT retry — auth errors are deterministic and retrying
|
|
||||||
* burns budget. The provider should throw on the first response.
|
|
||||||
*
|
|
||||||
* We can't assert "exactly one HTTP call" without injecting a call
|
|
||||||
* counter, so we assert that the throw message contains the status.
|
|
||||||
*/
|
|
||||||
public function test_generate_image_fails_loudly_on_401(): void {
|
|
||||||
$this->with_token( 'test-token' );
|
|
||||||
|
|
||||||
Functions\when( 'wp_remote_post' )->justReturn( [
|
|
||||||
'body' => '{"errors":[{"message":"bad token"}]}',
|
|
||||||
'response' => [ 'code' => 401 ],
|
|
||||||
'headers' => [],
|
|
||||||
] );
|
|
||||||
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 401 );
|
|
||||||
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"errors":[{"message":"bad token"}]}' );
|
|
||||||
|
|
||||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
|
||||||
|
|
||||||
try {
|
|
||||||
$provider->generate_image( 'a prompt', 1080, 1080 );
|
|
||||||
$this->fail( 'Expected RuntimeException for HTTP 401.' );
|
|
||||||
} catch ( \RuntimeException $e ) {
|
|
||||||
$this->assertStringContainsString( '401', $e->getMessage() );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Response shape must be one of: image/* bytes OR JSON envelope with
|
|
||||||
* base64 image. Anything else is a contract breach — fail loudly.
|
|
||||||
*/
|
|
||||||
public function test_generate_image_throws_on_unexpected_response_shape(): void {
|
|
||||||
$this->with_token( 'test-token' );
|
|
||||||
|
|
||||||
Functions\when( 'wp_remote_post' )->justReturn( [
|
|
||||||
'body' => '<!doctype html><html><body>oops</body></html>',
|
|
||||||
'response' => [ 'code' => 200 ],
|
|
||||||
'headers' => [ 'content-type' => 'text/html' ],
|
|
||||||
] );
|
|
||||||
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
|
|
||||||
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '<!doctype html><html><body>oops</body></html>' );
|
|
||||||
Functions\when( 'wp_remote_retrieve_header' )->alias(
|
|
||||||
function ( $_response, $name ) {
|
|
||||||
return 'content-type' === strtolower( (string) $name ) ? 'text/html' : '';
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
|
||||||
$this->expectException( \RuntimeException::class );
|
|
||||||
$provider->generate_image( 'a prompt', 1080, 1080 );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cost estimation is positive for in-range dimensions and scales with
|
|
||||||
* area — a 1080² image costs more than a 600² image on the same model.
|
|
||||||
*/
|
|
||||||
public function test_estimate_cost_scales_with_area(): void {
|
|
||||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
|
||||||
|
|
||||||
$small = $provider->estimate_cost( 600, 600, [ 'model' => 'flux-1-schnell' ] );
|
|
||||||
$large = $provider->estimate_cost( 1080, 1080, [ 'model' => 'flux-1-schnell' ] );
|
|
||||||
|
|
||||||
$this->assertGreaterThan( 0.0, $small );
|
|
||||||
$this->assertGreaterThan( $small, $large );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [dev] is meaningfully pricier than [schnell] at the same dimensions —
|
|
||||||
* the ~4x multiplier is the reason we default to schnell.
|
|
||||||
*/
|
|
||||||
public function test_estimate_cost_dev_more_expensive_than_schnell(): void {
|
|
||||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
|
||||||
|
|
||||||
$schnell = $provider->estimate_cost( 1080, 1080, [ 'model' => 'flux-1-schnell' ] );
|
|
||||||
$dev = $provider->estimate_cost( 1080, 1080, [ 'model' => 'flux-1-dev' ] );
|
|
||||||
|
|
||||||
$this->assertGreaterThan( $schnell, $dev );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* validate_credentials_detailed reports 'token_empty' debug tag when
|
|
||||||
* no token is configured, without making an HTTP call.
|
|
||||||
*/
|
|
||||||
public function test_validate_credentials_reports_missing_token(): void {
|
|
||||||
// Default setUp leaves token empty.
|
|
||||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
|
||||||
$result = $provider->validate_credentials_detailed();
|
|
||||||
|
|
||||||
$this->assertSame( 'error', $result['status'] );
|
|
||||||
$this->assertSame( 'token_empty', $result['debug'] );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* validate_credentials_detailed reports 'account_id_empty' when the
|
|
||||||
* account ID option is blank, without touching the network.
|
|
||||||
*/
|
|
||||||
public function test_validate_credentials_reports_missing_account_id(): void {
|
|
||||||
$this->stub_get_option( [
|
|
||||||
'prautoblogger_cloudflare_ai_token' => 'not-empty',
|
|
||||||
'prautoblogger_cloudflare_account_id' => '',
|
|
||||||
'prautoblogger_log_level' => 'info',
|
|
||||||
] );
|
|
||||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
|
||||||
$result = $provider->validate_credentials_detailed();
|
|
||||||
|
|
||||||
$this->assertSame( 'error', $result['status'] );
|
|
||||||
$this->assertSame( 'account_id_empty', $result['debug'] );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* validate_credentials_detailed must never invoke the generation endpoint.
|
|
||||||
* Asserted by tracking every wp_remote_post call: a real-image call is
|
|
||||||
* the only thing that POSTs to `/ai/run/...`. Validator uses GET on
|
|
||||||
* `/ai/models/search`, which is the free read-only path.
|
|
||||||
*/
|
|
||||||
public function test_validate_credentials_never_calls_generate_endpoint(): void {
|
|
||||||
$this->with_token( 'test-token' );
|
|
||||||
|
|
||||||
$post_calls = [];
|
|
||||||
Functions\when( 'wp_remote_post' )->alias(
|
|
||||||
function ( $url, $args ) use ( &$post_calls ) {
|
|
||||||
$post_calls[] = $url;
|
|
||||||
return [ 'body' => '', 'response' => [ 'code' => 500 ] ];
|
|
||||||
}
|
|
||||||
);
|
|
||||||
Functions\when( 'wp_remote_get' )->justReturn( [
|
|
||||||
'body' => '{"result":[]}',
|
|
||||||
'response' => [ 'code' => 200 ],
|
|
||||||
] );
|
|
||||||
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
|
|
||||||
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"result":[]}' );
|
|
||||||
|
|
||||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
|
||||||
$provider->validate_credentials_detailed();
|
|
||||||
|
|
||||||
$this->assertSame( [], $post_calls, 'validate_credentials_detailed() must not POST to /ai/run/ endpoints.' );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retry path: a 500 followed by a 200 must yield the 200 payload.
|
|
||||||
* Exercises the exponential-backoff loop end-to-end without sleeping
|
|
||||||
* (bootstrap sets PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS to 0).
|
|
||||||
*/
|
|
||||||
public function test_generate_image_retries_on_5xx_then_succeeds(): void {
|
|
||||||
$this->with_token( 'test-token' );
|
|
||||||
|
|
||||||
$response_codes = [ 500, 200 ];
|
|
||||||
$response_bodies = [ 'upstream hiccup', self::FAKE_IMAGE_BYTES ];
|
|
||||||
|
|
||||||
Functions\when( 'wp_remote_post' )->justReturn( [
|
|
||||||
'body' => self::FAKE_IMAGE_BYTES,
|
|
||||||
'response' => [ 'code' => 200 ],
|
|
||||||
'headers' => [ 'content-type' => 'image/png' ],
|
|
||||||
] );
|
|
||||||
Functions\when( 'wp_remote_retrieve_response_code' )->alias(
|
|
||||||
function () use ( &$response_codes ) {
|
|
||||||
return array_shift( $response_codes ) ?? 200;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
Functions\when( 'wp_remote_retrieve_body' )->alias(
|
|
||||||
function () use ( &$response_bodies ) {
|
|
||||||
return array_shift( $response_bodies ) ?? self::FAKE_IMAGE_BYTES;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
// wp_remote_retrieve_header is called only on the success attempt —
|
|
||||||
// the 5xx branch returns before any header lookup. One justReturn
|
|
||||||
// for the content-type is enough; retry-after lookups accept the
|
|
||||||
// same stub and will coerce to (int) 0.
|
|
||||||
Functions\when( 'wp_remote_retrieve_header' )->justReturn( 'image/png' );
|
|
||||||
|
|
||||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
|
||||||
$result = $provider->generate_image( 'a prompt', 1080, 1080 );
|
|
||||||
|
|
||||||
$this->assertSame( self::FAKE_IMAGE_BYTES, $result['bytes'] );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retry exhaustion: persistent 502s must throw after MAX_RETRIES attempts
|
|
||||||
* rather than hanging indefinitely.
|
|
||||||
*/
|
|
||||||
public function test_generate_image_fails_after_max_retries_on_persistent_5xx(): void {
|
|
||||||
$this->with_token( 'test-token' );
|
|
||||||
|
|
||||||
$call_count = 0;
|
|
||||||
Functions\when( 'wp_remote_post' )->alias(
|
|
||||||
function () use ( &$call_count ) {
|
|
||||||
++$call_count;
|
|
||||||
return [
|
|
||||||
'body' => 'bad gateway',
|
|
||||||
'response' => [ 'code' => 502 ],
|
|
||||||
'headers' => [],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
);
|
|
||||||
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 502 );
|
|
||||||
Functions\when( 'wp_remote_retrieve_body' )->justReturn( 'bad gateway' );
|
|
||||||
|
|
||||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
|
||||||
|
|
||||||
try {
|
|
||||||
$provider->generate_image( 'a prompt', 1080, 1080 );
|
|
||||||
$this->fail( 'Expected RuntimeException after retry exhaustion.' );
|
|
||||||
} catch ( \RuntimeException $e ) {
|
|
||||||
$this->assertStringContainsString( '502', $e->getMessage() );
|
|
||||||
}
|
|
||||||
$this->assertSame(
|
|
||||||
PRAUTOBLOGGER_MAX_RETRIES,
|
|
||||||
$call_count,
|
|
||||||
'Provider must attempt exactly PRAUTOBLOGGER_MAX_RETRIES times before giving up.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 429 triggers the same retry loop as 5xx (rate-limited, not deterministic
|
|
||||||
* error). The provider must retry and eventually succeed if the second
|
|
||||||
* attempt returns 200.
|
|
||||||
*/
|
|
||||||
public function test_generate_image_retries_on_429(): void {
|
|
||||||
$this->with_token( 'test-token' );
|
|
||||||
|
|
||||||
$response_codes = [ 429, 200 ];
|
|
||||||
$response_bodies = [ 'slow down', self::FAKE_IMAGE_BYTES ];
|
|
||||||
|
|
||||||
Functions\when( 'wp_remote_post' )->justReturn( [
|
|
||||||
'body' => self::FAKE_IMAGE_BYTES,
|
|
||||||
'response' => [ 'code' => 200 ],
|
|
||||||
'headers' => [ 'content-type' => 'image/png' ],
|
|
||||||
] );
|
|
||||||
Functions\when( 'wp_remote_retrieve_response_code' )->alias(
|
|
||||||
function () use ( &$response_codes ) {
|
|
||||||
return array_shift( $response_codes ) ?? 200;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
Functions\when( 'wp_remote_retrieve_body' )->alias(
|
|
||||||
function () use ( &$response_bodies ) {
|
|
||||||
return array_shift( $response_bodies ) ?? self::FAKE_IMAGE_BYTES;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
// Content-type is only read on the success attempt; retry-after is
|
|
||||||
// read on the 429 attempt and (int)-coerces anything non-numeric
|
|
||||||
// back to 0, falling through to exponential backoff.
|
|
||||||
Functions\when( 'wp_remote_retrieve_header' )->justReturn( 'image/png' );
|
|
||||||
|
|
||||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
|
||||||
$result = $provider->generate_image( 'a prompt', 1080, 1080 );
|
|
||||||
|
|
||||||
$this->assertSame( self::FAKE_IMAGE_BYTES, $result['bytes'] );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* validate_credentials_detailed returns 'ok' on a 200 from the models
|
|
||||||
* listing endpoint.
|
|
||||||
*/
|
|
||||||
public function test_validate_credentials_ok_on_200(): void {
|
|
||||||
$this->with_token( 'test-token' );
|
|
||||||
Functions\when( 'wp_remote_get' )->justReturn( [
|
|
||||||
'body' => '{"result":[{"id":"@cf/black-forest-labs/flux-1-schnell"}]}',
|
|
||||||
'response' => [ 'code' => 200 ],
|
|
||||||
] );
|
|
||||||
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
|
|
||||||
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"result":[]}' );
|
|
||||||
|
|
||||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
|
||||||
$result = $provider->validate_credentials_detailed();
|
|
||||||
|
|
||||||
$this->assertSame( 'ok', $result['status'] );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
68
tests/unit/Providers/OpenRouterImagePricingTest.php
Normal file
68
tests/unit/Providers/OpenRouterImagePricingTest.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Tests for PRAutoBlogger_OpenRouter_Image_Pricing.
|
||||||
|
*
|
||||||
|
* Validates model resolution fallback logic and per-image cost estimation.
|
||||||
|
*
|
||||||
|
* @package PRAutoBlogger\Tests\Providers
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace PRAutoBlogger\Tests\Providers;
|
||||||
|
|
||||||
|
use PRAutoBlogger\Tests\BaseTestCase;
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
|
||||||
|
class OpenRouterImagePricingTest extends BaseTestCase {
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
parent::setUp();
|
||||||
|
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
|
||||||
|
static $options = [];
|
||||||
|
return $options[ $key ] ?? $default;
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Empty hint resolves to the PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL constant. */
|
||||||
|
public function test_resolve_model_empty_hint_uses_default(): void {
|
||||||
|
$pricing = new \PRAutoBlogger_OpenRouter_Image_Pricing();
|
||||||
|
$model = $pricing->resolve_model( '' );
|
||||||
|
$this->assertSame( PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL, $model );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Explicit hint resolves to itself, regardless of what default is set. */
|
||||||
|
public function test_resolve_model_valid_hint(): void {
|
||||||
|
$pricing = new \PRAutoBlogger_OpenRouter_Image_Pricing();
|
||||||
|
$model = $pricing->resolve_model( 'openai/gpt-5-image' );
|
||||||
|
$this->assertSame( 'openai/gpt-5-image', $model );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gemini Flash Image costs $0.005/image. */
|
||||||
|
public function test_estimate_cost_gemini_flash(): void {
|
||||||
|
$pricing = new \PRAutoBlogger_OpenRouter_Image_Pricing();
|
||||||
|
$cost = $pricing->estimate_cost( 1200, 632, 'google/gemini-2.5-flash-image' );
|
||||||
|
$this->assertEqualsWithDelta( 0.005, $cost, 0.000001 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GPT-5 Image costs $0.08/image. */
|
||||||
|
public function test_estimate_cost_gpt5_image(): void {
|
||||||
|
$pricing = new \PRAutoBlogger_OpenRouter_Image_Pricing();
|
||||||
|
$cost = $pricing->estimate_cost( 1200, 632, 'openai/gpt-5-image' );
|
||||||
|
$this->assertEqualsWithDelta( 0.08, $cost, 0.000001 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unknown model falls back to conservative $0.05 estimate. */
|
||||||
|
public function test_estimate_cost_unknown_model(): void {
|
||||||
|
$pricing = new \PRAutoBlogger_OpenRouter_Image_Pricing();
|
||||||
|
$cost = $pricing->estimate_cost( 1200, 632, 'unknown/model' );
|
||||||
|
$this->assertEqualsWithDelta( 0.05, $cost, 0.000001 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** get_model_costs returns a non-empty map with known models. */
|
||||||
|
public function test_get_model_costs(): void {
|
||||||
|
$costs = \PRAutoBlogger_OpenRouter_Image_Pricing::get_model_costs();
|
||||||
|
$this->assertIsArray( $costs );
|
||||||
|
$this->assertArrayHasKey( 'google/gemini-2.5-flash-image', $costs );
|
||||||
|
$this->assertArrayHasKey( 'openai/gpt-5-image', $costs );
|
||||||
|
$this->assertGreaterThan( 0, count( $costs ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
154
tests/unit/Providers/OpenRouterImageProviderTest.php
Normal file
154
tests/unit/Providers/OpenRouterImageProviderTest.php
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Tests for PRAutoBlogger_OpenRouter_Image_Provider.
|
||||||
|
*
|
||||||
|
* Validates provider interface compliance, response parsing, and error handling.
|
||||||
|
* All HTTP calls are mocked — no real API calls.
|
||||||
|
*
|
||||||
|
* @package PRAutoBlogger\Tests\Providers
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace PRAutoBlogger\Tests\Providers;
|
||||||
|
|
||||||
|
use PRAutoBlogger\Tests\BaseTestCase;
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
|
||||||
|
class OpenRouterImageProviderTest extends BaseTestCase {
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
// Provide a valid encrypted API key.
|
||||||
|
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
|
||||||
|
static $options = [
|
||||||
|
'prautoblogger_openrouter_api_key' => 'enc:fake-encrypted-key',
|
||||||
|
'prautoblogger_image_model' => 'black-forest-labs/flux-2-pro',
|
||||||
|
'prautoblogger_ai_gateway_base_url' => '',
|
||||||
|
];
|
||||||
|
return $options[ $key ] ?? $default;
|
||||||
|
} );
|
||||||
|
|
||||||
|
Functions\when( 'is_wp_error' )->alias( function ( $thing ) {
|
||||||
|
return $thing instanceof \WP_Error;
|
||||||
|
} );
|
||||||
|
Functions\when( 'esc_html' )->returnArg();
|
||||||
|
Functions\when( 'esc_html__' )->returnArg();
|
||||||
|
|
||||||
|
// Mock encryption: decrypt returns a valid sk-or- key.
|
||||||
|
if ( ! class_exists( 'PRAutoBlogger_Encryption' ) ) {
|
||||||
|
eval( '
|
||||||
|
class PRAutoBlogger_Encryption {
|
||||||
|
public static function is_encrypted( $v ) { return 0 === strpos( $v, "enc:" ); }
|
||||||
|
public static function decrypt( $v ) { return "sk-or-test-key-123"; }
|
||||||
|
public static function encrypt( $v ) { return "enc:" . $v; }
|
||||||
|
}
|
||||||
|
' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Provider name matches expected identifier. */
|
||||||
|
public function test_get_provider_name(): void {
|
||||||
|
$provider = new \PRAutoBlogger_OpenRouter_Image_Provider();
|
||||||
|
$this->assertSame( 'openrouter', $provider->get_provider_name() );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** estimate_cost returns a positive float. */
|
||||||
|
public function test_estimate_cost_returns_float(): void {
|
||||||
|
$provider = new \PRAutoBlogger_OpenRouter_Image_Provider();
|
||||||
|
$cost = $provider->estimate_cost( 1200, 632 );
|
||||||
|
$this->assertIsFloat( $cost );
|
||||||
|
$this->assertGreaterThan( 0, $cost );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Empty prompt throws InvalidArgumentException. */
|
||||||
|
public function test_generate_image_empty_prompt_throws(): void {
|
||||||
|
$provider = new \PRAutoBlogger_OpenRouter_Image_Provider();
|
||||||
|
$this->expectException( \InvalidArgumentException::class );
|
||||||
|
$provider->generate_image( '', 1200, 632 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dimension below minimum throws InvalidArgumentException. */
|
||||||
|
public function test_generate_image_tiny_dimensions_throws(): void {
|
||||||
|
$provider = new \PRAutoBlogger_OpenRouter_Image_Provider();
|
||||||
|
$this->expectException( \InvalidArgumentException::class );
|
||||||
|
$provider->generate_image( 'A test prompt', 10, 10 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dimension above maximum throws InvalidArgumentException. */
|
||||||
|
public function test_generate_image_huge_dimensions_throws(): void {
|
||||||
|
$provider = new \PRAutoBlogger_OpenRouter_Image_Provider();
|
||||||
|
$this->expectException( \InvalidArgumentException::class );
|
||||||
|
$provider->generate_image( 'A test prompt', 5000, 5000 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Successful generation returns correct array structure. */
|
||||||
|
public function test_generate_image_success(): void {
|
||||||
|
// Build a fake OpenRouter response with base64 image data.
|
||||||
|
$fake_bytes = 'fake-image-data';
|
||||||
|
$b64 = base64_encode( $fake_bytes );
|
||||||
|
$data_url = 'data:image/png;base64,' . $b64;
|
||||||
|
$body = wp_json_encode( [
|
||||||
|
'choices' => [ [
|
||||||
|
'message' => [
|
||||||
|
'role' => 'assistant',
|
||||||
|
'content' => '',
|
||||||
|
'images' => [ [
|
||||||
|
'type' => 'image_url',
|
||||||
|
'image_url' => [ 'url' => $data_url ],
|
||||||
|
] ],
|
||||||
|
],
|
||||||
|
] ],
|
||||||
|
] );
|
||||||
|
|
||||||
|
Functions\when( 'wp_remote_post' )->justReturn( [
|
||||||
|
'response' => [ 'code' => 200 ],
|
||||||
|
'headers' => [],
|
||||||
|
'body' => $body,
|
||||||
|
] );
|
||||||
|
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
|
||||||
|
Functions\when( 'wp_remote_retrieve_body' )->justReturn( $body );
|
||||||
|
Functions\when( 'wp_remote_retrieve_header' )->justReturn( '' );
|
||||||
|
Functions\when( 'wp_parse_url' )->alias( function ( $url, $component = -1 ) {
|
||||||
|
return parse_url( $url, $component );
|
||||||
|
} );
|
||||||
|
Functions\when( 'add_action' )->justReturn( true );
|
||||||
|
Functions\when( 'remove_action' )->justReturn( true );
|
||||||
|
Functions\when( 'home_url' )->justReturn( 'https://peptiderepo.com' );
|
||||||
|
|
||||||
|
$provider = new \PRAutoBlogger_OpenRouter_Image_Provider();
|
||||||
|
$result = $provider->generate_image( 'A peptide research infographic', 1200, 632 );
|
||||||
|
|
||||||
|
$this->assertArrayHasKey( 'bytes', $result );
|
||||||
|
$this->assertArrayHasKey( 'mime_type', $result );
|
||||||
|
$this->assertArrayHasKey( 'model', $result );
|
||||||
|
$this->assertArrayHasKey( 'cost_usd', $result );
|
||||||
|
$this->assertArrayHasKey( 'latency_ms', $result );
|
||||||
|
$this->assertSame( $fake_bytes, $result['bytes'] );
|
||||||
|
$this->assertSame( 'image/png', $result['mime_type'] );
|
||||||
|
$this->assertSame( 1200, $result['width'] );
|
||||||
|
$this->assertSame( 632, $result['height'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 4xx error throws RuntimeException. */
|
||||||
|
public function test_generate_image_4xx_throws(): void {
|
||||||
|
Functions\when( 'wp_remote_post' )->justReturn( [
|
||||||
|
'response' => [ 'code' => 401 ],
|
||||||
|
'headers' => [],
|
||||||
|
'body' => '{"error":"unauthorized"}',
|
||||||
|
] );
|
||||||
|
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 401 );
|
||||||
|
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"error":"unauthorized"}' );
|
||||||
|
Functions\when( 'wp_remote_retrieve_header' )->justReturn( '' );
|
||||||
|
Functions\when( 'wp_parse_url' )->alias( function ( $url, $component = -1 ) {
|
||||||
|
return parse_url( $url, $component );
|
||||||
|
} );
|
||||||
|
Functions\when( 'add_action' )->justReturn( true );
|
||||||
|
Functions\when( 'remove_action' )->justReturn( true );
|
||||||
|
Functions\when( 'home_url' )->justReturn( 'https://peptiderepo.com' );
|
||||||
|
|
||||||
|
$provider = new \PRAutoBlogger_OpenRouter_Image_Provider();
|
||||||
|
$this->expectException( \RuntimeException::class );
|
||||||
|
$this->expectExceptionMessage( 'HTTP 401' );
|
||||||
|
$provider->generate_image( 'A test prompt', 1200, 632 );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,9 @@ class OpenRouterProviderTest extends BaseTestCase {
|
|||||||
);
|
);
|
||||||
Functions\when( 'set_transient' )->justReturn( true );
|
Functions\when( 'set_transient' )->justReturn( true );
|
||||||
|
|
||||||
|
// Stub URL parsing.
|
||||||
|
Functions\when( 'wp_parse_url' )->alias( 'parse_url' );
|
||||||
|
|
||||||
// Stub HTTP functions for API calls.
|
// Stub HTTP functions for API calls.
|
||||||
Functions\when( 'wp_remote_post' )->justReturn( [
|
Functions\when( 'wp_remote_post' )->justReturn( [
|
||||||
'body' => '{"choices":[{"message":{"content":"test"},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}',
|
'body' => '{"choices":[{"message":{"content":"test"},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}',
|
||||||
|
|||||||
199
tests/unit/Providers/OpenRouterValidatorTest.php
Normal file
199
tests/unit/Providers/OpenRouterValidatorTest.php
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Tests for PRAutoBlogger_OpenRouter_Validator.
|
||||||
|
*
|
||||||
|
* Validates credential checking logic including encryption, format validation,
|
||||||
|
* and HTTP connectivity probes.
|
||||||
|
* All HTTP calls are mocked — no real API calls.
|
||||||
|
*
|
||||||
|
* @package PRAutoBlogger\Tests\Providers
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace PRAutoBlogger\Tests\Providers;
|
||||||
|
|
||||||
|
use PRAutoBlogger\Tests\BaseTestCase;
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
|
||||||
|
class OpenRouterValidatorTest extends BaseTestCase {
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
// Stub get_option with default empty state.
|
||||||
|
$this->stub_get_option( [
|
||||||
|
'prautoblogger_openrouter_api_key' => '',
|
||||||
|
'prautoblogger_ai_gateway_base_url' => '',
|
||||||
|
'prautoblogger_ai_gateway_cache_ttl' => 0,
|
||||||
|
'prautoblogger_log_level' => 'info',
|
||||||
|
] );
|
||||||
|
|
||||||
|
// Stub wp_salt for encryption.
|
||||||
|
Functions\when( 'wp_salt' )->justReturn( 'test_salt_key_for_unit_tests' );
|
||||||
|
|
||||||
|
// Stub URL parsing.
|
||||||
|
Functions\when( 'wp_parse_url' )->alias( 'parse_url' );
|
||||||
|
|
||||||
|
// Stub HTTP functions.
|
||||||
|
Functions\when( 'wp_remote_get' )->justReturn( [
|
||||||
|
'body' => '{}',
|
||||||
|
'response' => [ 'code' => 200 ],
|
||||||
|
] );
|
||||||
|
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
|
||||||
|
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{}' );
|
||||||
|
Functions\when( 'is_wp_error' )->justReturn( false );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test validator can be instantiated.
|
||||||
|
*/
|
||||||
|
public function test_validator_instantiation(): void {
|
||||||
|
$validator = new \PRAutoBlogger_OpenRouter_Validator();
|
||||||
|
$this->assertInstanceOf( \PRAutoBlogger_OpenRouter_Validator::class, $validator );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test run() returns array with expected keys.
|
||||||
|
*/
|
||||||
|
public function test_run_returns_array_with_status(): void {
|
||||||
|
$validator = new \PRAutoBlogger_OpenRouter_Validator();
|
||||||
|
$result = $validator->run();
|
||||||
|
|
||||||
|
$this->assertIsArray( $result );
|
||||||
|
$this->assertArrayHasKey( 'status', $result );
|
||||||
|
$this->assertArrayHasKey( 'message', $result );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test validation fails when no API key is configured.
|
||||||
|
*/
|
||||||
|
public function test_run_fails_with_no_api_key(): void {
|
||||||
|
$validator = new \PRAutoBlogger_OpenRouter_Validator();
|
||||||
|
$result = $validator->run();
|
||||||
|
|
||||||
|
$this->assertSame( 'error', $result['status'] );
|
||||||
|
$this->assertStringContainsString( 'No API key', $result['message'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test validation succeeds with a valid decrypted key.
|
||||||
|
*/
|
||||||
|
public function test_run_succeeds_with_valid_key(): void {
|
||||||
|
// Round-trip through encryption to get a valid ciphertext.
|
||||||
|
$plaintext = 'sk-or-test-key-1234567890';
|
||||||
|
$ciphertext = \PRAutoBlogger_Encryption::encrypt( $plaintext );
|
||||||
|
|
||||||
|
$this->stub_get_option( [
|
||||||
|
'prautoblogger_openrouter_api_key' => $ciphertext,
|
||||||
|
'prautoblogger_ai_gateway_base_url' => '',
|
||||||
|
'prautoblogger_ai_gateway_cache_ttl' => 0,
|
||||||
|
'prautoblogger_log_level' => 'info',
|
||||||
|
] );
|
||||||
|
|
||||||
|
$validator = new \PRAutoBlogger_OpenRouter_Validator();
|
||||||
|
$result = $validator->run();
|
||||||
|
|
||||||
|
$this->assertSame( 'ok', $result['status'] );
|
||||||
|
$this->assertStringContainsString( 'connected', strtolower( $result['message'] ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test validation fails when decryption fails.
|
||||||
|
*/
|
||||||
|
public function test_run_fails_with_bad_ciphertext(): void {
|
||||||
|
// Stub get_option to return invalid ciphertext.
|
||||||
|
$this->stub_get_option( [
|
||||||
|
'prautoblogger_openrouter_api_key' => 'corrupted_base64_data_that_is_not_valid',
|
||||||
|
'prautoblogger_ai_gateway_base_url' => '',
|
||||||
|
'prautoblogger_ai_gateway_cache_ttl' => 0,
|
||||||
|
'prautoblogger_log_level' => 'info',
|
||||||
|
] );
|
||||||
|
|
||||||
|
$validator = new \PRAutoBlogger_OpenRouter_Validator();
|
||||||
|
$result = $validator->run();
|
||||||
|
|
||||||
|
$this->assertSame( 'error', $result['status'] );
|
||||||
|
$this->assertStringContainsString( 'decryption', strtolower( $result['message'] ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test validation fails when decrypted key has wrong format.
|
||||||
|
*/
|
||||||
|
public function test_run_fails_with_bad_key_format(): void {
|
||||||
|
// Encrypt a key that doesn't start with "sk-or-".
|
||||||
|
$plaintext = 'invalid-key-format';
|
||||||
|
$ciphertext = \PRAutoBlogger_Encryption::encrypt( $plaintext );
|
||||||
|
|
||||||
|
$this->stub_get_option( [
|
||||||
|
'prautoblogger_openrouter_api_key' => $ciphertext,
|
||||||
|
'prautoblogger_ai_gateway_base_url' => '',
|
||||||
|
'prautoblogger_ai_gateway_cache_ttl' => 0,
|
||||||
|
'prautoblogger_log_level' => 'info',
|
||||||
|
] );
|
||||||
|
|
||||||
|
$validator = new \PRAutoBlogger_OpenRouter_Validator();
|
||||||
|
$result = $validator->run();
|
||||||
|
|
||||||
|
$this->assertSame( 'error', $result['status'] );
|
||||||
|
$this->assertStringContainsString( 'corrupted', strtolower( $result['message'] ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test validation fails when HTTP request fails.
|
||||||
|
*/
|
||||||
|
public function test_run_fails_with_wp_error(): void {
|
||||||
|
$plaintext = 'sk-or-test-key-1234567890';
|
||||||
|
$ciphertext = \PRAutoBlogger_Encryption::encrypt( $plaintext );
|
||||||
|
|
||||||
|
$this->stub_get_option( [
|
||||||
|
'prautoblogger_openrouter_api_key' => $ciphertext,
|
||||||
|
'prautoblogger_ai_gateway_base_url' => '',
|
||||||
|
'prautoblogger_ai_gateway_cache_ttl' => 0,
|
||||||
|
'prautoblogger_log_level' => 'info',
|
||||||
|
] );
|
||||||
|
|
||||||
|
// Stub wp_remote_get to return an error.
|
||||||
|
Functions\when( 'is_wp_error' )->justReturn( true );
|
||||||
|
Functions\when( 'wp_remote_get' )->alias(
|
||||||
|
function () {
|
||||||
|
$error = new \WP_Error( 'http_error', 'Connection refused' );
|
||||||
|
return $error;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
$validator = new \PRAutoBlogger_OpenRouter_Validator();
|
||||||
|
$result = $validator->run();
|
||||||
|
|
||||||
|
$this->assertSame( 'error', $result['status'] );
|
||||||
|
$this->assertStringContainsString( 'Network error', $result['message'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test validation fails when API returns non-200 status.
|
||||||
|
*/
|
||||||
|
public function test_run_fails_with_http_error(): void {
|
||||||
|
$plaintext = 'sk-or-test-key-1234567890';
|
||||||
|
$ciphertext = \PRAutoBlogger_Encryption::encrypt( $plaintext );
|
||||||
|
|
||||||
|
$this->stub_get_option( [
|
||||||
|
'prautoblogger_openrouter_api_key' => $ciphertext,
|
||||||
|
'prautoblogger_ai_gateway_base_url' => '',
|
||||||
|
'prautoblogger_ai_gateway_cache_ttl' => 0,
|
||||||
|
'prautoblogger_log_level' => 'info',
|
||||||
|
] );
|
||||||
|
|
||||||
|
// Stub to return 401 error.
|
||||||
|
Functions\when( 'is_wp_error' )->justReturn( false );
|
||||||
|
Functions\when( 'wp_remote_get' )->justReturn( [
|
||||||
|
'body' => '{"error":{"message":"Unauthorized"}}',
|
||||||
|
'response' => [ 'code' => 401 ],
|
||||||
|
] );
|
||||||
|
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 401 );
|
||||||
|
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"error":{"message":"Unauthorized"}}' );
|
||||||
|
|
||||||
|
$validator = new \PRAutoBlogger_OpenRouter_Validator();
|
||||||
|
$result = $validator->run();
|
||||||
|
|
||||||
|
$this->assertSame( 'error', $result['status'] );
|
||||||
|
$this->assertStringContainsString( 'HTTP 401', $result['message'] );
|
||||||
|
}
|
||||||
|
}
|
||||||
91
tests/unit/Providers/RunwareImagePricingTest.php
Normal file
91
tests/unit/Providers/RunwareImagePricingTest.php
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Tests for PRAutoBlogger_Runware_Image_Pricing.
|
||||||
|
*
|
||||||
|
* Validates model resolution fallback logic and per-image cost estimation
|
||||||
|
* for the Runware FLUX models.
|
||||||
|
*
|
||||||
|
* @package PRAutoBlogger\Tests\Providers
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace PRAutoBlogger\Tests\Providers;
|
||||||
|
|
||||||
|
use PRAutoBlogger\Tests\BaseTestCase;
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
|
||||||
|
class RunwareImagePricingTest extends BaseTestCase {
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
parent::setUp();
|
||||||
|
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
|
||||||
|
static $options = [];
|
||||||
|
return $options[ $key ] ?? $default;
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Empty hint resolves to the v0.9.0 default (runware schnell). */
|
||||||
|
public function test_resolve_model_empty_hint_uses_schnell_default(): void {
|
||||||
|
$pricing = new \PRAutoBlogger_Runware_Image_Pricing();
|
||||||
|
$model = $pricing->resolve_model( '' );
|
||||||
|
$this->assertSame( 'runware:100@1', $model );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Explicit schnell hint resolves to itself. */
|
||||||
|
public function test_resolve_model_schnell_hint(): void {
|
||||||
|
$pricing = new \PRAutoBlogger_Runware_Image_Pricing();
|
||||||
|
$this->assertSame( 'runware:100@1', $pricing->resolve_model( 'runware:100@1' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Explicit dev hint resolves to itself. */
|
||||||
|
public function test_resolve_model_dev_hint(): void {
|
||||||
|
$pricing = new \PRAutoBlogger_Runware_Image_Pricing();
|
||||||
|
$this->assertSame( 'runware:101@1', $pricing->resolve_model( 'runware:101@1' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A non-Runware hint (e.g. a Gemini slug) falls back to schnell because
|
||||||
|
* the Runware provider was selected explicitly — the user wants a
|
||||||
|
* Runware model even if the admin option still holds a foreign slug.
|
||||||
|
*/
|
||||||
|
public function test_resolve_model_unknown_hint_falls_back_to_schnell(): void {
|
||||||
|
$pricing = new \PRAutoBlogger_Runware_Image_Pricing();
|
||||||
|
$this->assertSame( 'runware:100@1', $pricing->resolve_model( 'google/gemini-2.5-flash-image' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FLUX.1 schnell costs ~$0.0006/image. */
|
||||||
|
public function test_estimate_cost_schnell(): void {
|
||||||
|
$pricing = new \PRAutoBlogger_Runware_Image_Pricing();
|
||||||
|
$cost = $pricing->estimate_cost( 1216, 640, 'runware:100@1' );
|
||||||
|
$this->assertEqualsWithDelta( 0.0006, $cost, 0.000001 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FLUX.1 dev costs ~$0.02/image. */
|
||||||
|
public function test_estimate_cost_dev(): void {
|
||||||
|
$pricing = new \PRAutoBlogger_Runware_Image_Pricing();
|
||||||
|
$cost = $pricing->estimate_cost( 1216, 640, 'runware:101@1' );
|
||||||
|
$this->assertEqualsWithDelta( 0.02, $cost, 0.000001 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unknown model falls back to conservative $0.01 estimate. */
|
||||||
|
public function test_estimate_cost_unknown_model(): void {
|
||||||
|
$pricing = new \PRAutoBlogger_Runware_Image_Pricing();
|
||||||
|
$cost = $pricing->estimate_cost( 1216, 640, 'runware:999@1' );
|
||||||
|
$this->assertEqualsWithDelta( 0.01, $cost, 0.000001 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default step count: schnell=4, dev=28. */
|
||||||
|
public function test_default_steps(): void {
|
||||||
|
$pricing = new \PRAutoBlogger_Runware_Image_Pricing();
|
||||||
|
$this->assertSame( 4, $pricing->default_steps( 'runware:100@1' ) );
|
||||||
|
$this->assertSame( 28, $pricing->default_steps( 'runware:101@1' ) );
|
||||||
|
$this->assertSame( 4, $pricing->default_steps( 'runware:999@1' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** get_model_costs returns a non-empty map with Runware models. */
|
||||||
|
public function test_get_model_costs(): void {
|
||||||
|
$costs = \PRAutoBlogger_Runware_Image_Pricing::get_model_costs();
|
||||||
|
$this->assertIsArray( $costs );
|
||||||
|
$this->assertArrayHasKey( 'runware:100@1', $costs );
|
||||||
|
$this->assertArrayHasKey( 'runware:101@1', $costs );
|
||||||
|
}
|
||||||
|
}
|
||||||
239
tests/unit/Providers/RunwareImageProviderTest.php
Normal file
239
tests/unit/Providers/RunwareImageProviderTest.php
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Tests for PRAutoBlogger_Runware_Image_Provider.
|
||||||
|
*
|
||||||
|
* Validates interface compliance, two-step POST-then-GET flow, retry
|
||||||
|
* behavior on 5xx, immediate-fail on 4xx, and dimension/prompt guards.
|
||||||
|
* All HTTP calls are mocked — no real API traffic.
|
||||||
|
*
|
||||||
|
* @package PRAutoBlogger\Tests\Providers
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace PRAutoBlogger\Tests\Providers;
|
||||||
|
|
||||||
|
use PRAutoBlogger\Tests\BaseTestCase;
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
|
||||||
|
class RunwareImageProviderTest extends BaseTestCase {
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
|
||||||
|
static $options = [
|
||||||
|
'prautoblogger_runware_api_key' => 'rw-plain-test-key',
|
||||||
|
'prautoblogger_image_model' => 'runware:100@1',
|
||||||
|
];
|
||||||
|
return $options[ $key ] ?? $default;
|
||||||
|
} );
|
||||||
|
|
||||||
|
Functions\when( 'is_wp_error' )->alias( function ( $thing ) {
|
||||||
|
return $thing instanceof \WP_Error;
|
||||||
|
} );
|
||||||
|
|
||||||
|
Functions\when( 'wp_generate_uuid4' )->justReturn( '11111111-2222-3333-4444-555555555555' );
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Provider name is 'runware'. */
|
||||||
|
public function test_get_provider_name(): void {
|
||||||
|
$provider = new \PRAutoBlogger_Runware_Image_Provider();
|
||||||
|
$this->assertSame( 'runware', $provider->get_provider_name() );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** estimate_cost returns a positive float for schnell. */
|
||||||
|
public function test_estimate_cost_schnell(): void {
|
||||||
|
$provider = new \PRAutoBlogger_Runware_Image_Provider();
|
||||||
|
$cost = $provider->estimate_cost( 1216, 640, [ 'model' => 'runware:100@1' ] );
|
||||||
|
$this->assertIsFloat( $cost );
|
||||||
|
$this->assertEqualsWithDelta( 0.0006, $cost, 0.000001 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** estimate_cost returns higher value for dev. */
|
||||||
|
public function test_estimate_cost_dev_higher_than_schnell(): void {
|
||||||
|
$provider = new \PRAutoBlogger_Runware_Image_Provider();
|
||||||
|
$schnell = $provider->estimate_cost( 1216, 640, [ 'model' => 'runware:100@1' ] );
|
||||||
|
$dev = $provider->estimate_cost( 1216, 640, [ 'model' => 'runware:101@1' ] );
|
||||||
|
$this->assertGreaterThan( $schnell, $dev );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Empty prompt throws InvalidArgumentException. */
|
||||||
|
public function test_generate_image_empty_prompt_throws(): void {
|
||||||
|
$provider = new \PRAutoBlogger_Runware_Image_Provider();
|
||||||
|
$this->expectException( \InvalidArgumentException::class );
|
||||||
|
$provider->generate_image( '', 1200, 640 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whitespace-only prompt throws InvalidArgumentException. */
|
||||||
|
public function test_generate_image_whitespace_prompt_throws(): void {
|
||||||
|
$provider = new \PRAutoBlogger_Runware_Image_Provider();
|
||||||
|
$this->expectException( \InvalidArgumentException::class );
|
||||||
|
$provider->generate_image( " \t\n ", 1200, 640 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dimension below minimum throws. */
|
||||||
|
public function test_generate_image_tiny_dimensions_throws(): void {
|
||||||
|
$provider = new \PRAutoBlogger_Runware_Image_Provider();
|
||||||
|
$this->expectException( \InvalidArgumentException::class );
|
||||||
|
$provider->generate_image( 'A test prompt', 10, 10 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dimension above maximum throws. */
|
||||||
|
public function test_generate_image_huge_dimensions_throws(): void {
|
||||||
|
$provider = new \PRAutoBlogger_Runware_Image_Provider();
|
||||||
|
$this->expectException( \InvalidArgumentException::class );
|
||||||
|
$provider->generate_image( 'A test prompt', 5000, 5000 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Happy path: POST returns URL, GET returns bytes, result shape correct. */
|
||||||
|
public function test_generate_image_success(): void {
|
||||||
|
$image_url = 'https://im.runware.ai/image/abc.png';
|
||||||
|
$post_body = wp_json_encode( [
|
||||||
|
'data' => [
|
||||||
|
[ 'taskType' => 'imageInference', 'imageURL' => $image_url ],
|
||||||
|
],
|
||||||
|
] );
|
||||||
|
$fake_bytes = 'fake-png-bytes';
|
||||||
|
|
||||||
|
Functions\when( 'wp_remote_post' )->justReturn( [
|
||||||
|
'response' => [ 'code' => 200 ],
|
||||||
|
'headers' => [],
|
||||||
|
'body' => $post_body,
|
||||||
|
] );
|
||||||
|
Functions\when( 'wp_remote_get' )->justReturn( [
|
||||||
|
'response' => [ 'code' => 200 ],
|
||||||
|
'body' => $fake_bytes,
|
||||||
|
] );
|
||||||
|
Functions\when( 'wp_remote_retrieve_response_code' )->alias( function ( $r ) {
|
||||||
|
return isset( $r['response']['code'] ) ? (int) $r['response']['code'] : 0;
|
||||||
|
} );
|
||||||
|
Functions\when( 'wp_remote_retrieve_body' )->alias( function ( $r ) {
|
||||||
|
return isset( $r['body'] ) ? (string) $r['body'] : '';
|
||||||
|
} );
|
||||||
|
Functions\when( 'wp_remote_retrieve_header' )->justReturn( '' );
|
||||||
|
|
||||||
|
$provider = new \PRAutoBlogger_Runware_Image_Provider();
|
||||||
|
$result = $provider->generate_image( 'A gym bro holding a vial', 1200, 632 );
|
||||||
|
|
||||||
|
$this->assertArrayHasKey( 'bytes', $result );
|
||||||
|
$this->assertArrayHasKey( 'mime_type', $result );
|
||||||
|
$this->assertArrayHasKey( 'width', $result );
|
||||||
|
$this->assertArrayHasKey( 'height', $result );
|
||||||
|
$this->assertArrayHasKey( 'model', $result );
|
||||||
|
$this->assertArrayHasKey( 'seed', $result );
|
||||||
|
$this->assertArrayHasKey( 'cost_usd', $result );
|
||||||
|
$this->assertArrayHasKey( 'latency_ms', $result );
|
||||||
|
|
||||||
|
$this->assertSame( $fake_bytes, $result['bytes'] );
|
||||||
|
$this->assertSame( 'image/png', $result['mime_type'] );
|
||||||
|
// Snapped to multiples of 64: 1200 → 1216, 632 → 640.
|
||||||
|
$this->assertSame( 1216, $result['width'] );
|
||||||
|
$this->assertSame( 640, $result['height'] );
|
||||||
|
$this->assertSame( 'runware:100@1', $result['model'] );
|
||||||
|
$this->assertGreaterThan( 0, $result['seed'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 4xx (non-429) fails immediately with RuntimeException. */
|
||||||
|
public function test_generate_image_4xx_throws_immediately(): void {
|
||||||
|
Functions\when( 'wp_remote_post' )->justReturn( [
|
||||||
|
'response' => [ 'code' => 401 ],
|
||||||
|
'headers' => [],
|
||||||
|
'body' => '{"errors":[{"message":"invalid key"}]}',
|
||||||
|
] );
|
||||||
|
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 401 );
|
||||||
|
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"errors":[{"message":"invalid key"}]}' );
|
||||||
|
Functions\when( 'wp_remote_retrieve_header' )->justReturn( '' );
|
||||||
|
|
||||||
|
$provider = new \PRAutoBlogger_Runware_Image_Provider();
|
||||||
|
$this->expectException( \RuntimeException::class );
|
||||||
|
$this->expectExceptionMessage( 'HTTP 401' );
|
||||||
|
$provider->generate_image( 'A test prompt', 1200, 632 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 5xx triggers retry loop and eventually throws after PRAUTOBLOGGER_MAX_RETRIES. */
|
||||||
|
public function test_generate_image_5xx_retries_then_throws(): void {
|
||||||
|
$call_count = 0;
|
||||||
|
Functions\when( 'wp_remote_post' )->alias( function () use ( &$call_count ) {
|
||||||
|
$call_count++;
|
||||||
|
return [
|
||||||
|
'response' => [ 'code' => 503 ],
|
||||||
|
'headers' => [],
|
||||||
|
'body' => '{"errors":[{"message":"temporarily unavailable"}]}',
|
||||||
|
];
|
||||||
|
} );
|
||||||
|
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 503 );
|
||||||
|
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"errors":[{"message":"temporarily unavailable"}]}' );
|
||||||
|
Functions\when( 'wp_remote_retrieve_header' )->justReturn( '' );
|
||||||
|
|
||||||
|
$provider = new \PRAutoBlogger_Runware_Image_Provider();
|
||||||
|
try {
|
||||||
|
$provider->generate_image( 'A test prompt', 1200, 632 );
|
||||||
|
$this->fail( 'Expected RuntimeException after max retries' );
|
||||||
|
} catch ( \RuntimeException $e ) {
|
||||||
|
$this->assertSame(
|
||||||
|
PRAUTOBLOGGER_MAX_RETRIES,
|
||||||
|
$call_count,
|
||||||
|
'Provider must attempt exactly PRAUTOBLOGGER_MAX_RETRIES times before giving up.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** errors[] in a 200 body is treated as an upstream error. */
|
||||||
|
public function test_generate_image_errors_in_200_body_throws(): void {
|
||||||
|
$body = wp_json_encode( [
|
||||||
|
'errors' => [ [ 'message' => 'moderation rejected' ] ],
|
||||||
|
] );
|
||||||
|
Functions\when( 'wp_remote_post' )->justReturn( [
|
||||||
|
'response' => [ 'code' => 200 ],
|
||||||
|
'headers' => [],
|
||||||
|
'body' => $body,
|
||||||
|
] );
|
||||||
|
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
|
||||||
|
Functions\when( 'wp_remote_retrieve_body' )->justReturn( $body );
|
||||||
|
Functions\when( 'wp_remote_retrieve_header' )->justReturn( '' );
|
||||||
|
|
||||||
|
$provider = new \PRAutoBlogger_Runware_Image_Provider();
|
||||||
|
$this->expectException( \RuntimeException::class );
|
||||||
|
$provider->generate_image( 'A test prompt', 1200, 632 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** validate_credentials_detailed returns ok for a 200 response with no errors. */
|
||||||
|
public function test_validate_credentials_detailed_ok(): void {
|
||||||
|
Functions\when( 'wp_remote_post' )->justReturn( [
|
||||||
|
'response' => [ 'code' => 200 ],
|
||||||
|
'body' => '{"data":[{"taskType":"authentication"}]}',
|
||||||
|
] );
|
||||||
|
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
|
||||||
|
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"data":[{"taskType":"authentication"}]}' );
|
||||||
|
|
||||||
|
$provider = new \PRAutoBlogger_Runware_Image_Provider();
|
||||||
|
$result = $provider->validate_credentials_detailed();
|
||||||
|
$this->assertSame( 'ok', $result['status'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** validate_credentials_detailed returns error when key missing. */
|
||||||
|
public function test_validate_credentials_detailed_missing_key(): void {
|
||||||
|
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
|
||||||
|
return $default;
|
||||||
|
} );
|
||||||
|
|
||||||
|
$provider = new \PRAutoBlogger_Runware_Image_Provider();
|
||||||
|
$result = $provider->validate_credentials_detailed();
|
||||||
|
$this->assertSame( 'error', $result['status'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** validate_credentials_detailed returns error when response contains errors[]. */
|
||||||
|
public function test_validate_credentials_detailed_rejected_key(): void {
|
||||||
|
Functions\when( 'wp_remote_post' )->justReturn( [
|
||||||
|
'response' => [ 'code' => 200 ],
|
||||||
|
'body' => '{"errors":[{"message":"invalid key"}]}',
|
||||||
|
] );
|
||||||
|
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
|
||||||
|
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"errors":[{"message":"invalid key"}]}' );
|
||||||
|
|
||||||
|
$provider = new \PRAutoBlogger_Runware_Image_Provider();
|
||||||
|
$result = $provider->validate_credentials_detailed();
|
||||||
|
$this->assertSame( 'error', $result['status'] );
|
||||||
|
}
|
||||||
|
}
|
||||||
182
tests/unit/Providers/RunwareImageSupportTest.php
Normal file
182
tests/unit/Providers/RunwareImageSupportTest.php
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Tests for PRAutoBlogger_Runware_Image_Support.
|
||||||
|
*
|
||||||
|
* Covers response parsing (URL extraction), dimension snapping to FLUX
|
||||||
|
* constraints, seed normalization, and API-key lookup.
|
||||||
|
*
|
||||||
|
* @package PRAutoBlogger\Tests\Providers
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace PRAutoBlogger\Tests\Providers;
|
||||||
|
|
||||||
|
use PRAutoBlogger\Tests\BaseTestCase;
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
|
||||||
|
class RunwareImageSupportTest extends BaseTestCase {
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
Functions\when( 'esc_html' )->returnArg();
|
||||||
|
Functions\when( 'esc_html__' )->returnArg();
|
||||||
|
Functions\when( '__' )->returnArg();
|
||||||
|
// Real PRAutoBlogger_Encryption uses wp_salt('auth') internally on the
|
||||||
|
// decrypt path. Stub it with a deterministic 32-char string so OpenSSL
|
||||||
|
// has a key to work with in the "enc:"-prefixed round-trip test.
|
||||||
|
Functions\when( 'wp_salt' )->justReturn( 'test-salt-32chars-aaaaaaaaaaaaaa' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A well-formed imageInference entry returns its imageURL. */
|
||||||
|
public function test_extract_image_url_valid_response(): void {
|
||||||
|
$support = new \PRAutoBlogger_Runware_Image_Support();
|
||||||
|
$raw = wp_json_encode( [
|
||||||
|
'data' => [
|
||||||
|
[
|
||||||
|
'taskType' => 'imageInference',
|
||||||
|
'imageURL' => 'https://im.runware.ai/image/abc123.png',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
] );
|
||||||
|
$this->assertSame(
|
||||||
|
'https://im.runware.ai/image/abc123.png',
|
||||||
|
$support->extract_image_url( $raw )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An errors[] array in the response causes a RuntimeException. */
|
||||||
|
public function test_extract_image_url_error_response_throws(): void {
|
||||||
|
$support = new \PRAutoBlogger_Runware_Image_Support();
|
||||||
|
$raw = wp_json_encode( [
|
||||||
|
'errors' => [
|
||||||
|
[ 'message' => 'invalid api key' ],
|
||||||
|
],
|
||||||
|
] );
|
||||||
|
$this->expectException( \RuntimeException::class );
|
||||||
|
$this->expectExceptionMessage( 'invalid api key' );
|
||||||
|
$support->extract_image_url( $raw );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Malformed JSON throws RuntimeException. */
|
||||||
|
public function test_extract_image_url_invalid_json_throws(): void {
|
||||||
|
$support = new \PRAutoBlogger_Runware_Image_Support();
|
||||||
|
$this->expectException( \RuntimeException::class );
|
||||||
|
$support->extract_image_url( 'not json at all' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Data array present but no imageInference entry throws. */
|
||||||
|
public function test_extract_image_url_no_inference_entry_throws(): void {
|
||||||
|
$support = new \PRAutoBlogger_Runware_Image_Support();
|
||||||
|
$raw = wp_json_encode( [
|
||||||
|
'data' => [
|
||||||
|
[ 'taskType' => 'authentication' ],
|
||||||
|
],
|
||||||
|
] );
|
||||||
|
$this->expectException( \RuntimeException::class );
|
||||||
|
$support->extract_image_url( $raw );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** imageInference present but empty imageURL throws. */
|
||||||
|
public function test_extract_image_url_empty_url_throws(): void {
|
||||||
|
$support = new \PRAutoBlogger_Runware_Image_Support();
|
||||||
|
$raw = wp_json_encode( [
|
||||||
|
'data' => [
|
||||||
|
[ 'taskType' => 'imageInference', 'imageURL' => '' ],
|
||||||
|
],
|
||||||
|
] );
|
||||||
|
$this->expectException( \RuntimeException::class );
|
||||||
|
$support->extract_image_url( $raw );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** snap_dimension rounds to nearest multiple of 64. */
|
||||||
|
public function test_snap_dimension_rounds_to_64(): void {
|
||||||
|
$support = new \PRAutoBlogger_Runware_Image_Support();
|
||||||
|
$this->assertSame( 1216, $support->snap_dimension( 1200 ) );
|
||||||
|
$this->assertSame( 640, $support->snap_dimension( 632 ) );
|
||||||
|
$this->assertSame( 1024, $support->snap_dimension( 1024 ) );
|
||||||
|
$this->assertSame( 1024, $support->snap_dimension( 1050 ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** snap_dimension clamps to minimum 512. */
|
||||||
|
public function test_snap_dimension_clamps_min(): void {
|
||||||
|
$support = new \PRAutoBlogger_Runware_Image_Support();
|
||||||
|
$this->assertSame( 512, $support->snap_dimension( 64 ) );
|
||||||
|
$this->assertSame( 512, $support->snap_dimension( 200 ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** snap_dimension clamps to maximum 2048. */
|
||||||
|
public function test_snap_dimension_clamps_max(): void {
|
||||||
|
$support = new \PRAutoBlogger_Runware_Image_Support();
|
||||||
|
$this->assertSame( 2048, $support->snap_dimension( 4096 ) );
|
||||||
|
$this->assertSame( 2048, $support->snap_dimension( 2500 ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A valid positive seed passes through unchanged. */
|
||||||
|
public function test_normalize_seed_positive_passthrough(): void {
|
||||||
|
$support = new \PRAutoBlogger_Runware_Image_Support();
|
||||||
|
$this->assertSame( 42, $support->normalize_seed( 42 ) );
|
||||||
|
$this->assertSame( 1234567, $support->normalize_seed( 1234567 ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Null seed derives a positive integer. */
|
||||||
|
public function test_normalize_seed_null_derives_positive(): void {
|
||||||
|
$support = new \PRAutoBlogger_Runware_Image_Support();
|
||||||
|
$seed = $support->normalize_seed( null );
|
||||||
|
$this->assertGreaterThan( 0, $seed );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zero seed is replaced with a derived positive integer. */
|
||||||
|
public function test_normalize_seed_zero_replaced(): void {
|
||||||
|
$support = new \PRAutoBlogger_Runware_Image_Support();
|
||||||
|
$seed = $support->normalize_seed( 0 );
|
||||||
|
$this->assertGreaterThan( 0, $seed );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Negative seed is replaced with a derived positive integer. */
|
||||||
|
public function test_normalize_seed_negative_replaced(): void {
|
||||||
|
$support = new \PRAutoBlogger_Runware_Image_Support();
|
||||||
|
$seed = $support->normalize_seed( -5 );
|
||||||
|
$this->assertGreaterThan( 0, $seed );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Missing API key option throws RuntimeException. */
|
||||||
|
public function test_get_api_key_missing_throws(): void {
|
||||||
|
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
|
||||||
|
return $default;
|
||||||
|
} );
|
||||||
|
|
||||||
|
$support = new \PRAutoBlogger_Runware_Image_Support();
|
||||||
|
$this->expectException( \RuntimeException::class );
|
||||||
|
$support->get_api_key();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encrypted key is decrypted on read (round-trips through real PRAutoBlogger_Encryption). */
|
||||||
|
public function test_get_api_key_decrypts_encrypted(): void {
|
||||||
|
$plaintext = 'rw-real-round-trip-key';
|
||||||
|
$cipher = \PRAutoBlogger_Encryption::encrypt( $plaintext );
|
||||||
|
$this->assertStringStartsWith( 'enc:', $cipher, 'encrypt() must return an enc:-prefixed value' );
|
||||||
|
|
||||||
|
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) use ( $cipher ) {
|
||||||
|
if ( 'prautoblogger_runware_api_key' === $key ) {
|
||||||
|
return $cipher;
|
||||||
|
}
|
||||||
|
return $default;
|
||||||
|
} );
|
||||||
|
|
||||||
|
$support = new \PRAutoBlogger_Runware_Image_Support();
|
||||||
|
$this->assertSame( $plaintext, $support->get_api_key() );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A plaintext key (pre-migration) passes through unchanged. */
|
||||||
|
public function test_get_api_key_plaintext_passthrough(): void {
|
||||||
|
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
|
||||||
|
if ( 'prautoblogger_runware_api_key' === $key ) {
|
||||||
|
return 'rw-plain-key';
|
||||||
|
}
|
||||||
|
return $default;
|
||||||
|
} );
|
||||||
|
|
||||||
|
$support = new \PRAutoBlogger_Runware_Image_Support();
|
||||||
|
$this->assertSame( 'rw-plain-key', $support->get_api_key() );
|
||||||
|
}
|
||||||
|
}
|
||||||
102
tests/unit/Services/ImageModelRegistryTest.php
Normal file
102
tests/unit/Services/ImageModelRegistryTest.php
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Tests for PRAutoBlogger_Image_Model_Registry.
|
||||||
|
*
|
||||||
|
* The admin save flow (v0.8.0) collapses Provider and Image Model into a
|
||||||
|
* single dropdown and derives the provider from the model via
|
||||||
|
* Image_Model_Registry::provider_for(). This test pins that mapping down
|
||||||
|
* so a registry edit that breaks the contract fails CI.
|
||||||
|
*
|
||||||
|
* @package PRAutoBlogger\Tests\Services
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace PRAutoBlogger\Tests\Services;
|
||||||
|
|
||||||
|
use PRAutoBlogger\Tests\BaseTestCase;
|
||||||
|
|
||||||
|
class ImageModelRegistryTest extends BaseTestCase {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every entry returned by get_models() must carry a non-empty id and
|
||||||
|
* a provider of 'runware' or 'openrouter' (Cloudflare Workers AI was
|
||||||
|
* removed as an image provider in v0.10.0). If this breaks, the
|
||||||
|
* admin save flow will silently drop the provider derivation.
|
||||||
|
*/
|
||||||
|
public function test_every_registry_entry_has_id_and_known_provider(): void {
|
||||||
|
$models = \PRAutoBlogger_Image_Model_Registry::get_models();
|
||||||
|
$this->assertNotEmpty( $models );
|
||||||
|
|
||||||
|
foreach ( $models as $model ) {
|
||||||
|
$this->assertArrayHasKey( 'id', $model );
|
||||||
|
$this->assertNotEmpty( $model['id'] );
|
||||||
|
$this->assertArrayHasKey( 'provider', $model );
|
||||||
|
$this->assertContains(
|
||||||
|
$model['provider'],
|
||||||
|
[ 'runware', 'openrouter' ],
|
||||||
|
sprintf( 'Unexpected provider %s for model %s', (string) $model['provider'], (string) $model['id'] )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* provider_for() returns the paired provider for a known model id.
|
||||||
|
* This is the core of the v0.8.0 collapsed-dropdown contract.
|
||||||
|
*/
|
||||||
|
public function test_provider_for_returns_paired_provider_for_known_model(): void {
|
||||||
|
$this->assertSame(
|
||||||
|
'openrouter',
|
||||||
|
\PRAutoBlogger_Image_Model_Registry::provider_for( 'google/gemini-2.5-flash-image' )
|
||||||
|
);
|
||||||
|
$this->assertSame(
|
||||||
|
'runware',
|
||||||
|
\PRAutoBlogger_Image_Model_Registry::provider_for( 'runware:100@1' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.10.0 scope boundary: no Cloudflare entries remain in the registry.
|
||||||
|
* The legacy `flux-1-schnell` slug (pre-Runware) must resolve to '' so
|
||||||
|
* the migration's detection path catches it. If a registry regression
|
||||||
|
* re-adds a cloudflare entry this test fails loudly.
|
||||||
|
*/
|
||||||
|
public function test_cloudflare_entries_removed_v0100(): void {
|
||||||
|
foreach ( \PRAutoBlogger_Image_Model_Registry::get_models() as $model ) {
|
||||||
|
$this->assertNotSame( 'cloudflare', $model['provider'] ?? '' );
|
||||||
|
}
|
||||||
|
$this->assertSame(
|
||||||
|
'',
|
||||||
|
\PRAutoBlogger_Image_Model_Registry::provider_for( 'flux-1-schnell' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runware model ids (added v0.9.0) resolve to the 'runware' provider.
|
||||||
|
* This test pins the v0.9.0 default flip so a registry regression
|
||||||
|
* that loses the Runware entries fails CI loudly.
|
||||||
|
*/
|
||||||
|
public function test_provider_for_runware_models(): void {
|
||||||
|
$this->assertSame(
|
||||||
|
'runware',
|
||||||
|
\PRAutoBlogger_Image_Model_Registry::provider_for( 'runware:100@1' )
|
||||||
|
);
|
||||||
|
$this->assertSame(
|
||||||
|
'runware',
|
||||||
|
\PRAutoBlogger_Image_Model_Registry::provider_for( 'runware:101@1' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unknown model ids return an empty string so callers can distinguish
|
||||||
|
* "not in registry" from a valid provider and show a settings error.
|
||||||
|
*/
|
||||||
|
public function test_provider_for_returns_empty_for_unknown_model(): void {
|
||||||
|
$this->assertSame(
|
||||||
|
'',
|
||||||
|
\PRAutoBlogger_Image_Model_Registry::provider_for( 'nonexistent/model' )
|
||||||
|
);
|
||||||
|
$this->assertSame(
|
||||||
|
'',
|
||||||
|
\PRAutoBlogger_Image_Model_Registry::provider_for( '' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user