Compare commits
85 Commits
refactor/s
...
claude/v0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7026e2f0a | ||
|
|
52a8ffbd9e | ||
|
|
0505e50e85 | ||
|
|
fb1451c4dc | ||
|
|
a3ed663bae | ||
|
|
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 | ||
|
|
f81ae1fdaa | ||
|
|
ebf4f87f98 |
40
.github/workflows/deploy.yml
vendored
40
.github/workflows/deploy.yml
vendored
@@ -6,7 +6,7 @@ on:
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate Code Quality
|
||||
name: PHP Lint Gate
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -17,37 +17,17 @@ jobs:
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.1'
|
||||
tools: composer, phpcs
|
||||
coverage: none
|
||||
|
||||
- name: PHP Lint Check
|
||||
run: |
|
||||
find . -name "*.php" -not -path "./vendor/*" -not -path "./node_modules/*" -exec php -l {} \; | (grep -v "No syntax errors" || true) | head -20
|
||||
test ${PIPESTATUS[0]} -eq 0
|
||||
|
||||
- name: Install Composer dependencies
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
|
||||
- name: Install WordPress Coding Standards
|
||||
run: |
|
||||
# Composer 2.2+ blocks plugins by default. The WPCS installer needs
|
||||
# dealerdirect/phpcodesniffer-composer-installer to wire standards
|
||||
# into PHPCS. Allow it globally before requiring WPCS.
|
||||
composer global config --no-plugins allow-plugins.dealerdirect/phpcodesniffer-composer-installer true
|
||||
composer global require "wp-coding-standards/wpcs:^3.0" "phpcsstandards/phpcsutils:^1.0" --no-interaction
|
||||
phpcs --config-set installed_paths "$(composer global config home)/vendor/wp-coding-standards/wpcs"
|
||||
|
||||
- name: Run PHPCS (WordPress standards)
|
||||
run: |
|
||||
phpcs --standard=WordPress-Core \
|
||||
--extensions=php \
|
||||
--ignore=vendor/*,tests/*,node_modules/* \
|
||||
--warning-severity=0 \
|
||||
--report=summary \
|
||||
includes/ prautoblogger.php uninstall.php || true
|
||||
|
||||
- name: Run PHPUnit
|
||||
run: vendor/bin/phpunit --testdox
|
||||
set -e
|
||||
find . -name '*.php' \
|
||||
-not -path './vendor/*' \
|
||||
-not -path './node_modules/*' \
|
||||
-not -path './.git/*' \
|
||||
-not -path './.github/*' \
|
||||
-print0 | xargs -0 -n1 -P4 php -l > /dev/null
|
||||
|
||||
deploy:
|
||||
needs: validate
|
||||
@@ -85,7 +65,9 @@ jobs:
|
||||
run: |
|
||||
ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
|
||||
${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_HOST }} \
|
||||
"cd ~/domains/peptiderepo.com/public_html && find wp-content/litespeed/ -type f -delete 2>/dev/null; touch .lscache_purge 2>/dev/null || true"
|
||||
"cd ~/domains/peptiderepo.com/public_html && \
|
||||
wp litespeed-purge all 2>/dev/null || \
|
||||
(find wp-content/litespeed/ -type f -delete 2>/dev/null; touch .lscache_purge 2>/dev/null) || true"
|
||||
|
||||
- name: Health check
|
||||
run: |
|
||||
|
||||
122
ARCHITECTURE.md
122
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
|
||||
│ (Reddit RSS primary │ Reddit RSS/Atom feeds (primary) / .json (fallback)
|
||||
│ / .json fallback) Stores raw data in `ab_source_data` table
|
||||
│ 1. Source Collector │ Pulls data from enabled sources:
|
||||
│ (Reddit + LLM Research)│ - Reddit RSS/Atom (primary) / .json (fallback)
|
||||
│ │ - 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)
|
||||
│ │ - 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)
|
||||
│
|
||||
├── 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-deactivator.php # Deactivation: clear cron, cleanup transients
|
||||
│ ├── class-autoloader.php # PSR-4-style autoloader for plugin classes
|
||||
│ │
|
||||
│ ├── admin/
|
||||
│ │ ├── 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-dashboard-widget.php # WP Dashboard widget showing generation status
|
||||
│ │ ├── class-post-metabox.php # Metabox on posts showing generation metadata
|
||||
│ │ ├── 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-log-viewer.php # Activity log viewer with level filtering
|
||||
│ │
|
||||
@@ -123,40 +136,55 @@ prautoblogger/
|
||||
│ │ ├── class-scheduler.php # WP-Cron / Action Scheduler job management
|
||||
│ │ ├── class-source-collector.php # Orchestrates data collection from all sources
|
||||
│ │ ├── 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-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-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-media-sideloader.php # Imports images into WordPress media library
|
||||
│ │ ├── class-cost-tracker.php # Logs all API costs, enforces budget limits
|
||||
│ │ ├── 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-metrics-collector.php# Collects post performance data (WP + GA4)
|
||||
│ │
|
||||
│ ├── providers/
|
||||
│ │ ├── 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
|
||||
│ │ ├── interface-source-provider.php # Contract for any social media source
|
||||
│ │ ├── 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)
|
||||
│ │ ├── interface-image-provider.php # Contract for any image generation provider
|
||||
│ │ ├── class-cloudflare-image-provider.php # FLUX.1 via Cloudflare Workers AI
|
||||
│ │ ├── class-cloudflare-image-pricing.php # Model alias + per-MP cost estimation
|
||||
│ │ ├── class-cloudflare-image-validator.php # Non-destructive credential + connectivity check
|
||||
│ │ ├── class-cloudflare-image-support.php # Token, account, URL, and log helpers for the CF provider
|
||||
│ │ ├── interface-image-provider.php # Contract for any image generation provider (incl. batch)
|
||||
│ │ ├── class-open-router-image-provider.php # OpenRouter image gen (single + batch dispatch)
|
||||
│ │ ├── class-open-router-image-batch.php # Parallel curl_multi execution for batch image gen
|
||||
│ │ ├── class-open-router-image-support.php # API key, response parsing, retry/backoff helpers
|
||||
│ │ ├── class-open-router-image-pricing.php # Model resolution + per-image cost estimation
|
||||
│ │ ├── class-open-router-config.php # API base URL (direct vs AI Gateway)
|
||||
│ │ ├── class-open-router-request-builder.php # Header building + Hostinger cURL auth workaround
|
||||
│ │ ├── class-cloudflare-image-provider.php # Cloudflare Workers AI (sequential batch fallback)
|
||||
│ │ ├── class-cloudflare-image-pricing.php # Model alias + per-MP cost estimation
|
||||
│ │ ├── class-cloudflare-image-validator.php # Non-destructive credential + connectivity check
|
||||
│ │ ├── class-cloudflare-image-support.php # Token, account, URL, and log helpers for CF provider
|
||||
│ │ └── (new providers go here — see CONVENTIONS.md)
|
||||
│ │
|
||||
│ ├── services/
|
||||
│ │ ├── interface-model-registry.php # Contract for provider-specific model registries
|
||||
│ │ ├── class-openrouter-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-registry.php # Fetches, caches, queries the OpenRouter model list
|
||||
│ │ ├── class-open-router-model-normalizer.php # Maps raw OpenRouter data → standardized record shape
|
||||
│ │ └── (Phase 3: class-cloudflare-workers-ai-model-registry.php)
|
||||
│ │
|
||||
│ ├── frontend/
|
||||
│ │ ├── class-article-typography.php # Inline CSS for font, size, table borders on generated posts
|
||||
│ │ └── class-posts-widget.php # [prautoblogger_posts] shortcode + REST endpoint
|
||||
│ │
|
||||
│ └── 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
|
||||
|
||||
### Custom Tables
|
||||
@@ -362,7 +437,14 @@ All prefixed with `prautoblogger_`:
|
||||
| `prautoblogger_schedule_time` | Daily generation time (HH:MM, default: '03:00') |
|
||||
| `prautoblogger_cloudflare_ai_token` | Encrypted Cloudflare Workers AI API token |
|
||||
| `prautoblogger_cloudflare_account_id` | Cloudflare account UUID (plaintext — identifier, not secret) |
|
||||
| `prautoblogger_image_model` | Image model alias: `flux-1-schnell` (default) or `flux-1-dev` |
|
||||
| `prautoblogger_image_model` | Image model slug from `Image_Model_Registry::get_models()`; provider is derived from this on save |
|
||||
| `prautoblogger_image_provider` | Derived from the chosen model on save (v0.8.0+); not editable in admin UI |
|
||||
| `prautoblogger_image_prompt_instructions` | System prompt given to the image rewriter LLM (v0.8.0+); falls back to `Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT` when empty |
|
||||
| `prautoblogger_image_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_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 |
|
||||
@@ -446,7 +528,7 @@ Reddit rejected our OAuth API application (April 2026). We initially switched to
|
||||
|
||||
### #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.
|
||||
|
||||
### #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.
|
||||
|
||||
332
CHANGELOG.md
332
CHANGELOG.md
@@ -5,6 +5,334 @@ All notable changes to PRAutoBlogger will be documented in this file.
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project uses [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [0.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]
|
||||
|
||||
### Added
|
||||
@@ -40,8 +368,8 @@ and this project uses [Semantic Versioning](https://semver.org/).
|
||||
list (`/api/v1/models` — free, unauthenticated). Daily refresh via the
|
||||
existing `prautoblogger_daily_generation` cron hook with 12h idempotency.
|
||||
- `includes/services/interface-model-registry.php` — Phase 3-aware contract.
|
||||
- `includes/services/class-openrouter-model-registry.php` — fetch + cache + query.
|
||||
- `includes/services/class-openrouter-model-normalizer.php` — raw → standardized shape.
|
||||
- `includes/services/class-open-router-model-registry.php` — fetch + cache + query.
|
||||
- `includes/services/class-open-router-model-normalizer.php` — raw → standardized shape.
|
||||
- Capability vocabulary: `text→text`, `text+image→text`, `text→embedding`, etc.
|
||||
- Zero-coupling: no PRAUTOBLOGGER_* constants inside the class — Phase 2 lift
|
||||
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);
|
||||
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(); });
|
||||
}, 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
|
||||
* knows it hasn't hung.
|
||||
*/
|
||||
var generateStages = [
|
||||
{ text: 'Collecting sources from Reddit…', delay: 0 },
|
||||
{ text: 'Analyzing topics and scoring…', delay: 8000 },
|
||||
{ text: 'Selecting best topic…', delay: 16000 },
|
||||
{ text: 'Generating article draft via AI…', delay: 24000 },
|
||||
{ text: 'Running editorial pass…', delay: 50000 },
|
||||
{ text: 'Saving and publishing…', delay: 75000 },
|
||||
{ text: 'Almost done — finalizing…', delay: 100000 }
|
||||
];
|
||||
var stageTimers = [];
|
||||
/*
|
||||
* Pipeline stages are now reported by the server via the status polling
|
||||
* endpoint. The old client-side fake timers have been removed.
|
||||
*/
|
||||
|
||||
/** Poll interval handle for generation status checks. */
|
||||
var statusPollTimer = null;
|
||||
|
||||
/**
|
||||
* 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 () {
|
||||
var $btn = $(this);
|
||||
if ($btn.prop('disabled')) return;
|
||||
|
||||
setButtonLoading($btn, config.generatingText || 'Generating…');
|
||||
|
||||
// Start pipeline stage messages.
|
||||
stageTimers = [];
|
||||
$.each(generateStages, function (i, stage) {
|
||||
var t = setTimeout(function () { showProgress(stage.text); }, stage.delay);
|
||||
stageTimers.push(t);
|
||||
});
|
||||
showProgress('Starting generation…');
|
||||
|
||||
$.ajax({
|
||||
url: config.ajaxUrl,
|
||||
@@ -158,21 +244,19 @@
|
||||
nonce: config.generateNonce,
|
||||
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) {
|
||||
if (response.success) {
|
||||
var d = response.data;
|
||||
showStatus(
|
||||
'Generation complete: ' + d.generated + ' generated, ' +
|
||||
d.published + ' published, ' + d.rejected + ' rejected. ' +
|
||||
'Cost: $' + parseFloat(d.cost).toFixed(4),
|
||||
'success'
|
||||
);
|
||||
// Generation kicked off in background — start polling.
|
||||
showProgress(response.data.message || 'Generation started…');
|
||||
pollGenerationStatus($btn);
|
||||
} else {
|
||||
showStatus(
|
||||
'Generation failed: ' + (response.data && response.data.message || 'Unknown error'),
|
||||
'error'
|
||||
);
|
||||
hideProgress();
|
||||
resetButton($btn, config.generateText || 'Generate Now');
|
||||
}
|
||||
},
|
||||
error: function (xhr, status) {
|
||||
@@ -180,11 +264,6 @@
|
||||
'Request failed: ' + status + '. Check server error logs.',
|
||||
'error'
|
||||
);
|
||||
},
|
||||
complete: function () {
|
||||
// Clear all stage timers.
|
||||
$.each(stageTimers, function (_, t) { clearTimeout(t); });
|
||||
stageTimers = [];
|
||||
hideProgress();
|
||||
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": {
|
||||
"phpunit/phpunit": "^9.5",
|
||||
"wp-phpunit/wp-phpunit": "^6.0",
|
||||
"brain/monkey": "^2.6"
|
||||
},
|
||||
"autoload": {
|
||||
|
||||
@@ -58,6 +58,7 @@ class PRAutoBlogger_Admin_Page {
|
||||
'toplevel_page_' . self::PAGE_SLUG,
|
||||
'prautoblogger_page_prautoblogger-metrics',
|
||||
'prautoblogger_page_prautoblogger-review-queue',
|
||||
'prautoblogger_page_prautoblogger-ideas',
|
||||
'prautoblogger_page_prautoblogger-logs',
|
||||
];
|
||||
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-model-picker', PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/model-picker.css', [ 'prautoblogger-admin' ], PRAUTOBLOGGER_VERSION );
|
||||
wp_enqueue_script( 'prautoblogger-admin', PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/admin.js', [ 'jquery' ], PRAUTOBLOGGER_VERSION, true );
|
||||
wp_enqueue_script( 'prautoblogger-model-picker', PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/model-picker.js', [ 'jquery', 'prautoblogger-admin' ], PRAUTOBLOGGER_VERSION, true );
|
||||
|
||||
wp_localize_script( 'prautoblogger-admin', 'prautobloggerAdmin', [
|
||||
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||
'adminUrl' => admin_url(),
|
||||
'siteUrl' => home_url( '/' ),
|
||||
'generateNonce' => wp_create_nonce( 'prautoblogger_generate_now' ),
|
||||
'imageNonce' => wp_create_nonce( 'prautoblogger_generate_image' ),
|
||||
'testNonce' => wp_create_nonce( 'prautoblogger_test_connection' ),
|
||||
'modelsNonce' => wp_create_nonce( 'prautoblogger_get_models' ),
|
||||
'reviewNonce' => wp_create_nonce( 'prautoblogger_review_queue' ),
|
||||
'ideaGenNonce' => wp_create_nonce( 'prautoblogger_idea_gen' ),
|
||||
'generatingText' => __( 'Generating...', 'prautoblogger' ),
|
||||
'generateText' => __( 'Generate Now', 'prautoblogger' ),
|
||||
'testingText' => __( 'Testing...', 'prautoblogger' ),
|
||||
'testText' => __( 'Test Connections', 'prautoblogger' ),
|
||||
// Image models for the picker — defined in the settings class to keep this file short.
|
||||
'imageModels' => PRAutoBlogger_Settings_Fields_Extended::get_image_models(),
|
||||
] );
|
||||
|
||||
// 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). */
|
||||
@@ -143,6 +158,11 @@ class PRAutoBlogger_Admin_Page {
|
||||
|
||||
case 'checkboxes':
|
||||
$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 ) {
|
||||
$is_checked = in_array( $v, $current, true );
|
||||
$disabled = strpos( $label, 'coming soon' ) !== false ? ' disabled' : '';
|
||||
@@ -151,6 +171,10 @@ class PRAutoBlogger_Admin_Page {
|
||||
}
|
||||
break;
|
||||
|
||||
case 'model_select':
|
||||
$this->render_model_select_field( $id, (string) $value, $args );
|
||||
break;
|
||||
|
||||
case 'source_status':
|
||||
$this->render_source_status_field();
|
||||
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
|
||||
* with live status checks via transient cache and rate limit info.
|
||||
*
|
||||
* Side effects: Reads transients for cached availability status.
|
||||
*
|
||||
* @return void
|
||||
* @see admin/fields/class-source-status-field.php
|
||||
*/
|
||||
private function render_source_status_field(): void {
|
||||
$last_collection = get_option( 'prautoblogger_last_collection_time', '' );
|
||||
$last_source_used = get_option( 'prautoblogger_last_source_used', '' );
|
||||
PRAutoBlogger_Source_Status_Field::render();
|
||||
}
|
||||
|
||||
echo '<div class="ab-source-status">';
|
||||
|
||||
// Reddit RSS — primary source.
|
||||
printf(
|
||||
'<div class="ab-source-row">'
|
||||
. '<span class="ab-source-dot ab-status-ok"></span>'
|
||||
. '<strong>%s</strong> <span class="ab-source-badge">%s</span>'
|
||||
. '<span class="ab-source-label">%s</span>'
|
||||
. '</div>',
|
||||
esc_html__( 'Reddit RSS', 'prautoblogger' ),
|
||||
esc_html__( 'Primary', 'prautoblogger' ),
|
||||
esc_html__( 'Reliable from all IPs', 'prautoblogger' )
|
||||
);
|
||||
|
||||
// Reddit .json — fallback source.
|
||||
printf(
|
||||
'<div class="ab-source-row">'
|
||||
. '<span class="ab-source-dot ab-status-ok"></span>'
|
||||
. '<strong>%s</strong> <span class="ab-source-badge ab-badge-secondary">%s</span>'
|
||||
. '<span class="ab-source-label">%s</span>'
|
||||
. '</div>',
|
||||
esc_html__( 'Reddit .json', 'prautoblogger' ),
|
||||
esc_html__( 'Fallback + Comments', 'prautoblogger' ),
|
||||
esc_html__( 'May be IP-blocked on some hosts', 'prautoblogger' )
|
||||
);
|
||||
|
||||
// Last collection info.
|
||||
if ( '' !== $last_collection ) {
|
||||
$time_ago = human_time_diff( (int) $last_collection, time() );
|
||||
printf(
|
||||
'<div class="ab-source-meta">%s <strong>%s</strong> %s</div>',
|
||||
esc_html__( 'Last collection:', 'prautoblogger' ),
|
||||
/* translators: %s is a human-readable time difference like "2 hours" */
|
||||
esc_html( sprintf( __( '%s ago', 'prautoblogger' ), $time_ago ) ),
|
||||
'' !== $last_source_used ? esc_html( sprintf( __( 'via %s', 'prautoblogger' ), $last_source_used ) ) : ''
|
||||
);
|
||||
}
|
||||
|
||||
echo '</div>';
|
||||
/**
|
||||
* Delegate model picker rendering to the dedicated field class.
|
||||
*
|
||||
* @param string $id Field HTML id/name.
|
||||
* @param string $value Current model id.
|
||||
* @param array<string, mixed> $args Field definition.
|
||||
*
|
||||
* @see admin/fields/class-openrouter-model-field.php
|
||||
*/
|
||||
private function render_model_select_field( string $id, string $value, array $args ): void {
|
||||
PRAutoBlogger_OpenRouter_Model_Field::render( $id, $value, $args );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -240,7 +231,7 @@ class PRAutoBlogger_Admin_Page {
|
||||
$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_cloudflare_ai_token' ];
|
||||
if ( in_array( $option_name, $encrypted, true ) ) {
|
||||
// Empty value means password field wasn't touched — keep existing.
|
||||
if ( '' === $value ) {
|
||||
@@ -262,19 +253,47 @@ class PRAutoBlogger_Admin_Page {
|
||||
|
||||
$json_fields = [ 'prautoblogger_target_subreddits', 'prautoblogger_topic_exclusions', 'prautoblogger_enabled_sources' ];
|
||||
if ( in_array( $option_name, $json_fields, true ) ) {
|
||||
if ( is_array( $value ) ) { return wp_json_encode( array_values( array_map( 'sanitize_text_field', $value ) ) ); }
|
||||
if ( is_string( $value ) && '[' !== substr( trim( $value ), 0, 1 ) ) {
|
||||
$items = array_filter( array_map( 'trim', explode( ',', $value ) ) );
|
||||
return wp_json_encode( array_values( $items ) );
|
||||
// PHP array from checkboxes — sanitize each item and re-encode.
|
||||
if ( is_array( $value ) ) {
|
||||
return wp_json_encode( array_values( array_map( 'sanitize_text_field', $value ) ) );
|
||||
}
|
||||
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' ];
|
||||
if ( in_array( $option_name, $numeric, true ) ) {
|
||||
return is_numeric( $value ) ? $value : 0;
|
||||
}
|
||||
|
||||
if ( 'prautoblogger_image_model' === $option_name ) {
|
||||
return $this->sanitize_image_model( (string) $value );
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
99
includes/admin/class-image-model-registry.php
Normal file
99
includes/admin/class-image-model-registry.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?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) and
|
||||
* PRAutoBlogger_Image_Pipeline (model validation).
|
||||
* Dependencies: None.
|
||||
*
|
||||
* @see admin/class-settings-fields-extended.php — Image settings reference this registry.
|
||||
* @see core/class-image-pipeline.php — Validates selected model against this list.
|
||||
*/
|
||||
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: 'openrouter' or 'cloudflare'.
|
||||
* - cost_per_image: Estimated USD cost per generation.
|
||||
* - capabilities: Array of capability tags.
|
||||
* - description: Short description for the admin UI.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function get_models(): array {
|
||||
return [
|
||||
[
|
||||
'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' => __( 'Good quality, low cost. Recommended.', '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' ),
|
||||
],
|
||||
[
|
||||
'id' => 'flux-1-schnell',
|
||||
'name' => 'FLUX.1 schnell (Cloudflare)',
|
||||
'provider' => 'cloudflare',
|
||||
'cost_per_image' => 0.0007,
|
||||
'capabilities' => [ 'image_generation' ],
|
||||
'description' => __( 'Cheapest. Low quality, 4-step.', '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 ('openrouter' | 'cloudflare' | '').
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
265
includes/admin/class-settings-fields-extended.php
Normal file
265
includes/admin/class-settings-fields-extended.php
Normal file
@@ -0,0 +1,265 @@
|
||||
<?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' => __( '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' ),
|
||||
],
|
||||
[
|
||||
'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' ),
|
||||
],
|
||||
|
||||
// ── 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 (OpenRouter or Cloudflare Workers AI) is derived from the model registry on save, so mismatched pairs are no longer possible.', 'prautoblogger' ),
|
||||
'badge' => __( 'Quality', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
'id' => 'prautoblogger_cloudflare_ai_token',
|
||||
'label' => __( 'Cloudflare API Token', 'prautoblogger' ),
|
||||
'type' => 'password',
|
||||
'section' => 'prautoblogger_images',
|
||||
'description' => __( 'Only needed if using Cloudflare provider. Workers AI token with Read + Edit scope.', 'prautoblogger' ),
|
||||
'icon' => '🔑',
|
||||
],
|
||||
[
|
||||
'id' => 'prautoblogger_cloudflare_account_id',
|
||||
'label' => __( 'Cloudflare Account ID', 'prautoblogger' ),
|
||||
'type' => 'text',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => '',
|
||||
'description' => __( 'Only needed if using Cloudflare provider.', '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 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 (e.g. Cloudflare code 3030), 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
|
||||
* 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.
|
||||
* 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 CONVENTIONS.md — "How To: Add a New Admin Setting".
|
||||
* @see admin/class-settings-fields-extended.php — Schedule, publishing, analytics, images fields.
|
||||
* @see admin/class-admin-page.php — Calls get_sections() and get_fields() to register settings.
|
||||
* @see CONVENTIONS.md — "How To: Add a New Admin Setting".
|
||||
*/
|
||||
class PRAutoBlogger_Settings_Fields {
|
||||
|
||||
@@ -57,20 +60,34 @@ class PRAutoBlogger_Settings_Fields {
|
||||
'icon' => 'dashicons-chart-area',
|
||||
'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' => [
|
||||
'title' => __( 'Images', 'prautoblogger' ),
|
||||
'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>>
|
||||
*/
|
||||
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 [
|
||||
// ── API Keys ────────────────────────────────────────────────
|
||||
[
|
||||
@@ -113,27 +130,30 @@ class PRAutoBlogger_Settings_Fields {
|
||||
[
|
||||
'id' => 'prautoblogger_analysis_model',
|
||||
'label' => __( 'Analysis Model', 'prautoblogger' ),
|
||||
'type' => 'text',
|
||||
'type' => 'model_select',
|
||||
'section' => 'prautoblogger_models',
|
||||
'default' => PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL,
|
||||
'capability' => 'text→text',
|
||||
'description' => __( 'Used for source analysis. Pick a cheap, fast model.', 'prautoblogger' ),
|
||||
'badge' => __( 'Low cost', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
'id' => 'prautoblogger_writing_model',
|
||||
'label' => __( 'Writing Model', 'prautoblogger' ),
|
||||
'type' => 'text',
|
||||
'type' => 'model_select',
|
||||
'section' => 'prautoblogger_models',
|
||||
'default' => PRAUTOBLOGGER_DEFAULT_WRITING_MODEL,
|
||||
'capability' => 'text→text',
|
||||
'description' => __( 'Used for article generation. Quality matters here.', 'prautoblogger' ),
|
||||
'badge' => __( 'Quality', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
'id' => 'prautoblogger_editor_model',
|
||||
'label' => __( 'Editor Model', 'prautoblogger' ),
|
||||
'type' => 'text',
|
||||
'type' => 'model_select',
|
||||
'section' => 'prautoblogger_models',
|
||||
'default' => PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL,
|
||||
'capability' => 'text→text',
|
||||
'description' => __( 'Used for the chief editor review pass.', 'prautoblogger' ),
|
||||
'badge' => __( 'Quality', 'prautoblogger' ),
|
||||
],
|
||||
@@ -146,6 +166,14 @@ class PRAutoBlogger_Settings_Fields {
|
||||
'section' => 'prautoblogger_content',
|
||||
'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',
|
||||
'label' => __( 'Content Tone', 'prautoblogger' ),
|
||||
@@ -187,6 +215,22 @@ class PRAutoBlogger_Settings_Fields {
|
||||
'default' => 2000,
|
||||
'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',
|
||||
'label' => __( 'Topic Exclusions', 'prautoblogger' ),
|
||||
@@ -209,7 +253,8 @@ class PRAutoBlogger_Settings_Fields {
|
||||
'type' => 'checkboxes',
|
||||
'section' => 'prautoblogger_sources',
|
||||
'options' => [
|
||||
'reddit' => __( 'Reddit (RSS + .json)', 'prautoblogger' ),
|
||||
'reddit' => __( 'Reddit (RSS + .json)', 'prautoblogger' ),
|
||||
'llm_research' => __( 'LLM Deep Research (reasoning model)', 'prautoblogger' ),
|
||||
],
|
||||
'default' => '["reddit"]',
|
||||
'description' => __( 'Select which platforms to monitor for topics.', 'prautoblogger' ),
|
||||
@@ -247,140 +292,6 @@ class PRAutoBlogger_Settings_Fields {
|
||||
'max' => 100,
|
||||
'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>';
|
||||
}
|
||||
}
|
||||
@@ -169,6 +169,9 @@ class PRAutoBlogger_Activator {
|
||||
'prautoblogger_max_word_count' => 2000,
|
||||
'prautoblogger_topic_exclusions' => '[]',
|
||||
'prautoblogger_enabled_sources' => '["reddit"]',
|
||||
'prautoblogger_article_font_family' => 'default',
|
||||
'prautoblogger_article_font_size' => 0,
|
||||
'prautoblogger_table_borders' => '1',
|
||||
'prautoblogger_schedule_time' => '03:00',
|
||||
'prautoblogger_log_level' => 'info',
|
||||
];
|
||||
|
||||
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 on Cloudflare Workers AI.
|
||||
// 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 = [
|
||||
'', // includes/ root
|
||||
'admin/',
|
||||
'admin/fields/',
|
||||
'core/',
|
||||
'providers/',
|
||||
'models/',
|
||||
|
||||
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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,144 +9,142 @@ declare(strict_types=1);
|
||||
* business logic, not hook registration.
|
||||
*
|
||||
* 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 core/class-pipeline-runner.php — Executes the generation pipeline.
|
||||
* @see ARCHITECTURE.md — Data flow diagram showing how classes interact.
|
||||
* @see CONVENTIONS.md — Hook naming conventions.
|
||||
* @see prautoblogger.php — Plugin bootstrap that instantiates this class.
|
||||
* @see class-executor.php — Cron/AJAX handlers for generation, model registry.
|
||||
* @see class-ajax-handlers.php — Non-generation AJAX endpoints (images, models, test).
|
||||
* @see class-generation-lock.php — DB mutex for single-writer generation.
|
||||
* @see core/class-pipeline-runner.php — Executes the generation pipeline.
|
||||
* @see ARCHITECTURE.md — Data flow diagram showing how classes interact.
|
||||
* @see CONVENTIONS.md — Hook naming conventions.
|
||||
*/
|
||||
class PRAutoBlogger {
|
||||
|
||||
private bool $initialized = false;
|
||||
|
||||
/** @var PRAutoBlogger_OpenRouter_Model_Registry|null Lazy-loaded singleton. */
|
||||
private ?PRAutoBlogger_OpenRouter_Model_Registry $model_registry = null;
|
||||
/** @var PRAutoBlogger_Executor Handles generation cron/AJAX, model registry. */
|
||||
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.
|
||||
*
|
||||
* Called once on `plugins_loaded`. Idempotent — calling it twice is a no-op.
|
||||
* Called once on `plugins_loaded`. Idempotent.
|
||||
*
|
||||
* Side effects: registers WordPress actions and filters.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function run(): void {
|
||||
if ( $this->initialized ) {
|
||||
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' ] );
|
||||
|
||||
// Register custom cron schedules.
|
||||
add_filter( 'cron_schedules', [ $this, 'filter_add_cron_schedules' ] );
|
||||
|
||||
// Admin hooks — only load on admin pages.
|
||||
if ( is_admin() ) {
|
||||
$this->register_admin_hooks();
|
||||
}
|
||||
|
||||
// Cron hooks — always registered so WP-Cron can fire them.
|
||||
$this->register_cron_hooks();
|
||||
|
||||
// Frontend hooks — shortcode and REST API for public-facing widget.
|
||||
$this->register_frontend_hooks();
|
||||
|
||||
// AJAX hooks — always registered so admin AJAX works.
|
||||
$this->register_ajax_hooks();
|
||||
|
||||
/**
|
||||
* Fires after PRAutoBlogger has finished registering all hooks.
|
||||
*
|
||||
* Useful for add-ons that need to hook into PRAutoBlogger's lifecycle.
|
||||
*/
|
||||
/** Fires after PRAutoBlogger has finished registering all hooks. */
|
||||
do_action( 'prautoblogger_loaded' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register admin-only hooks (settings page, notices, metabox, dashboard widget).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
/** Register admin-only hooks (settings, notices, metabox, dashboard widget). */
|
||||
private function register_admin_hooks(): void {
|
||||
$admin_page = new PRAutoBlogger_Admin_Page();
|
||||
add_action( 'admin_menu', [ $admin_page, 'on_register_menu' ] );
|
||||
add_action( 'admin_init', [ $admin_page, 'on_register_settings' ] );
|
||||
add_action( 'admin_enqueue_scripts', [ $admin_page, 'on_enqueue_assets' ] );
|
||||
|
||||
$notices = new PRAutoBlogger_Admin_Notices();
|
||||
add_action( 'admin_notices', [ $notices, 'on_display_notices' ] );
|
||||
|
||||
$metabox = new PRAutoBlogger_Post_Metabox();
|
||||
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' ] );
|
||||
add_action( 'admin_notices', [ new PRAutoBlogger_Admin_Notices(), 'on_display_notices' ] );
|
||||
add_action( 'add_meta_boxes', [ new PRAutoBlogger_Post_Metabox(), 'on_register_metabox' ] );
|
||||
add_action( 'admin_menu', [ new PRAutoBlogger_Metrics_Page(), 'on_register_menu' ] );
|
||||
add_action( 'wp_dashboard_setup', [ new PRAutoBlogger_Dashboard_Widget(), 'on_register_widget' ] );
|
||||
|
||||
$review_queue = new PRAutoBlogger_Review_Queue();
|
||||
add_action( 'admin_menu', [ $review_queue, 'on_register_menu' ] );
|
||||
|
||||
$log_viewer = new PRAutoBlogger_Log_Viewer();
|
||||
add_action( 'admin_menu', [ $log_viewer, 'on_register_menu' ] );
|
||||
$ideas_browser = new PRAutoBlogger_Ideas_Browser();
|
||||
add_action( 'admin_menu', [ $ideas_browser, 'on_register_menu' ] );
|
||||
add_action( 'admin_menu', [ new PRAutoBlogger_Log_Viewer(), 'on_register_menu' ] );
|
||||
|
||||
// Block false update notifications from wordpress.org — our plugin name
|
||||
// collides with a different "PRAutoBlogger" in the plugin directory.
|
||||
( new PRAutoBlogger_Post_List_Columns() )->register();
|
||||
|
||||
// Block false update notifications — our slug collides with another plugin.
|
||||
add_filter( 'site_transient_update_plugins', [ $this, 'filter_block_false_updates' ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
/** Register frontend hooks (shortcode, REST, typography). */
|
||||
private function register_frontend_hooks(): void {
|
||||
$posts_widget = new PRAutoBlogger_Posts_Widget();
|
||||
add_action( 'init', [ $posts_widget, 'on_register_shortcode' ] );
|
||||
add_action( 'rest_api_init', [ $posts_widget, 'on_register_rest_route' ] );
|
||||
|
||||
$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. */
|
||||
private function register_cron_hooks(): void {
|
||||
add_action( 'prautoblogger_daily_generation', [ $this, 'on_daily_generation' ] );
|
||||
add_action( 'prautoblogger_collect_metrics', [ $this, 'on_collect_metrics' ] );
|
||||
add_action( 'prautoblogger_daily_generation', [ $this->executor, 'on_daily_generation' ] );
|
||||
add_action( 'prautoblogger_collect_metrics', [ $this->executor, 'on_collect_metrics' ] );
|
||||
|
||||
// Model registry refresh — fires inside on_daily_generation() before the
|
||||
// generation lock, so a stuck lock doesn't freeze the registry too.
|
||||
$registry = $this->get_model_registry();
|
||||
// Manual "Generate Now" runs as a one-shot cron event to avoid
|
||||
// Hostinger's 120-second web-server connection timeout.
|
||||
add_action( 'prautoblogger_manual_generation', [ $this->executor, 'on_manual_generation' ] );
|
||||
|
||||
// Chained article generation — each queued article fires as its own
|
||||
// cron event so it gets a fresh PHP process and execution time budget.
|
||||
add_action(
|
||||
PRAutoBlogger_Pipeline_Runner::CRON_ACTION,
|
||||
[ $this->executor, 'on_process_article_queue' ]
|
||||
);
|
||||
|
||||
$registry = $this->executor->get_model_registry();
|
||||
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' ] );
|
||||
}
|
||||
|
||||
/** Register AJAX handlers for admin actions. */
|
||||
private function register_ajax_hooks(): void {
|
||||
add_action( 'wp_ajax_prautoblogger_generate_now', [ $this, 'on_ajax_generate_now' ] );
|
||||
add_action( 'wp_ajax_prautoblogger_test_connection', [ $this, 'on_ajax_test_connection' ] );
|
||||
// Generation AJAX (start + status polling) stays on executor.
|
||||
add_action( 'wp_ajax_prautoblogger_generate_now', [ $this->executor, 'on_ajax_generate_now' ] );
|
||||
add_action( 'wp_ajax_prautoblogger_generation_status', [ $this->executor, 'on_ajax_generation_status' ] );
|
||||
|
||||
// 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();
|
||||
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' ] );
|
||||
|
||||
$log_viewer = new PRAutoBlogger_Log_Viewer();
|
||||
add_action( 'wp_ajax_prautoblogger_clear_logs', [ $log_viewer, 'on_ajax_clear_logs' ] );
|
||||
add_action( 'wp_ajax_prautoblogger_clear_logs', [ new PRAutoBlogger_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.
|
||||
*
|
||||
* Compares stored DB version with current PRAUTOBLOGGER_DB_VERSION.
|
||||
* If different, re-runs activation to apply schema changes via dbDelta.
|
||||
* Compares stored DB version with PRAUTOBLOGGER_DB_VERSION; re-runs activation if different.
|
||||
*
|
||||
* Side effects: may update database schema and db_version option.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function on_check_db_version(): void {
|
||||
$stored_version = get_option( 'prautoblogger_db_version', '0' );
|
||||
@@ -162,15 +160,58 @@ class PRAutoBlogger {
|
||||
update_option( 'prautoblogger_migrated_gemini_flash_lite', '1' );
|
||||
}
|
||||
|
||||
// One-time migration: re-wrap existing encrypted values with "enc:" prefix
|
||||
// to prevent the double-encryption bug permanently.
|
||||
// One-time migration (v0.8.0): the admin no longer has an independent
|
||||
// Image Provider dropdown; provider is derived from the image model on
|
||||
// save. Auto-heal any existing site where the saved provider doesn't
|
||||
// match the saved model's provider (the root cause of posts 650/657
|
||||
// silently missing their featured image on 2026-04-20). Runs once.
|
||||
if ( ! get_option( 'prautoblogger_migrated_image_provider_v080' ) ) {
|
||||
$saved_model = (string) get_option( 'prautoblogger_image_model', '' );
|
||||
$provider = PRAutoBlogger_Image_Model_Registry::provider_for( $saved_model );
|
||||
if ( '' !== $provider ) {
|
||||
update_option( 'prautoblogger_image_provider', $provider );
|
||||
}
|
||||
update_option( 'prautoblogger_migrated_image_provider_v080', '1' );
|
||||
}
|
||||
|
||||
// One-time migration (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' );
|
||||
}
|
||||
|
||||
// One-time migration: re-wrap existing encrypted values with "enc:" prefix.
|
||||
if ( ! get_option( 'prautoblogger_migrated_enc_prefix' ) ) {
|
||||
$enc_options = [ 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json' ];
|
||||
foreach ( $enc_options as $opt ) {
|
||||
$val = get_option( $opt, '' );
|
||||
// If it has a value but no "enc:" prefix, it's a legacy encrypted value.
|
||||
// Clear it — user must re-enter. We can't reliably tell if it's
|
||||
// single or double encrypted, so a fresh entry is safest.
|
||||
if ( '' !== $val && ! PRAutoBlogger_Encryption::is_encrypted( $val ) ) {
|
||||
delete_option( $opt );
|
||||
}
|
||||
@@ -183,8 +224,7 @@ class PRAutoBlogger {
|
||||
* Add custom cron schedules (six-hourly for metrics collection).
|
||||
*
|
||||
* @param array<string, array{interval: int, display: string}> $schedules Existing schedules.
|
||||
*
|
||||
* @return array<string, array{interval: int, display: string}> Modified schedules.
|
||||
* @return array<string, array{interval: int, display: string}>
|
||||
*/
|
||||
public function filter_add_cron_schedules( array $schedules ): array {
|
||||
$schedules['prautoblogger_six_hours'] = [
|
||||
@@ -196,209 +236,37 @@ class PRAutoBlogger {
|
||||
|
||||
/**
|
||||
* Block false update notifications from wordpress.org.
|
||||
*
|
||||
* Our plugin slug may collide with a different plugin in the directory.
|
||||
* This filter removes any update notice that wordpress.org might push
|
||||
* for our plugin basename, since we manage updates via our own CI/CD.
|
||||
*
|
||||
* @param object $transient The update_plugins transient data.
|
||||
*
|
||||
* @return object Modified transient with our plugin removed from updates.
|
||||
* @return object Modified transient.
|
||||
*/
|
||||
public function filter_block_false_updates( $transient ) {
|
||||
$plugin_basename = PRAUTOBLOGGER_PLUGIN_BASENAME;
|
||||
if ( isset( $transient->response[ $plugin_basename ] ) ) {
|
||||
unset( $transient->response[ $plugin_basename ] );
|
||||
if ( isset( $transient->response[ PRAUTOBLOGGER_PLUGIN_BASENAME ] ) ) {
|
||||
unset( $transient->response[ PRAUTOBLOGGER_PLUGIN_BASENAME ] );
|
||||
}
|
||||
return $transient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the daily generation cron event.
|
||||
*
|
||||
* 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();
|
||||
}
|
||||
/** Expose the executor for external access (e.g., model registry). */
|
||||
public function get_executor(): PRAutoBlogger_Executor {
|
||||
return $this->executor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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' );
|
||||
}
|
||||
}
|
||||
// ── Backward-compatible proxies ──
|
||||
|
||||
/**
|
||||
* AJAX handler: trigger manual generation.
|
||||
*
|
||||
* Verifies nonce and capability, then runs the generation pipeline.
|
||||
* Uses the same atomic DB lock as the cron handler.
|
||||
*
|
||||
* Side effects: API calls, database writes, WordPress post creation.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function on_ajax_generate_now(): void {
|
||||
check_ajax_referer( 'prautoblogger_generate_now', 'nonce' );
|
||||
/** @see PRAutoBlogger_Executor::on_daily_generation() */
|
||||
public function on_daily_generation(): void { $this->executor->on_daily_generation(); }
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 );
|
||||
return;
|
||||
}
|
||||
/** @see PRAutoBlogger_Executor::on_collect_metrics() */
|
||||
public function on_collect_metrics(): void { $this->executor->on_collect_metrics(); }
|
||||
|
||||
// LLM API calls can take 30-60+ seconds each. Extend PHP's execution
|
||||
// limit so the pipeline isn't killed mid-generation. This may be
|
||||
// blocked by Hostinger's hardcoded limits, but it's worth trying.
|
||||
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
@set_time_limit( 300 );
|
||||
/** @see PRAutoBlogger_Executor::on_ajax_generate_now() */
|
||||
public function on_ajax_generate_now(): void { $this->executor->on_ajax_generate_now(); }
|
||||
|
||||
// Allow admins to force-clear a stale lock.
|
||||
$force = isset( $_POST['force'] ) && '1' === $_POST['force'];
|
||||
if ( $force ) {
|
||||
$this->release_generation_lock();
|
||||
}
|
||||
|
||||
if ( ! $this->acquire_generation_lock() ) {
|
||||
wp_send_json_error( [ 'message' => __( 'A generation run is already in progress. Pass force=1 to clear the lock.', 'prautoblogger' ) ] );
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Manual runs skip dedup so "Generate Now" always produces content
|
||||
// even when a related article was recently published.
|
||||
$results = ( new PRAutoBlogger_Pipeline_Runner() )
|
||||
->set_skip_dedup( true )
|
||||
->run();
|
||||
wp_send_json_success( $results );
|
||||
} catch ( \Exception $e ) {
|
||||
PRAutoBlogger_Logger::instance()->error( 'Manual generation FAILED: ' . $e->getMessage(), 'pipeline' );
|
||||
wp_send_json_error( [ 'message' => $e->getMessage() ] );
|
||||
} finally {
|
||||
$this->release_generation_lock();
|
||||
}
|
||||
}
|
||||
|
||||
/** AJAX handler: test API connections (OpenRouter, Reddit). */
|
||||
public function on_ajax_test_connection(): void {
|
||||
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_Ajax_Handlers::on_ajax_test_connection() */
|
||||
public function on_ajax_test_connection(): void { $this->ajax_handlers->on_ajax_test_connection(); }
|
||||
|
||||
/** @see PRAutoBlogger_Executor::get_model_registry() */
|
||||
public function get_model_registry(): PRAutoBlogger_OpenRouter_Model_Registry { return $this->executor->get_model_registry(); }
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
* @see core/class-content-generator.php — Produces the content we review.
|
||||
* @see core/class-json-extractor.php — Tolerant JSON parsing for LLM output.
|
||||
* @see core/class-publisher.php — Publishes approved content.
|
||||
* @see ARCHITECTURE.md — Data flow step 5.
|
||||
*/
|
||||
@@ -108,6 +109,13 @@ class PRAutoBlogger_Chief_Editor {
|
||||
$prompt .= "- REVISE if fixable issues exist — you MUST provide the full revised HTML\n";
|
||||
$prompt .= "- REJECT only if the content is fundamentally flawed or off-topic\n";
|
||||
$prompt .= "- When revising, fix the issues yourself. Don't just describe them.\n";
|
||||
$prompt .= "- When revising, PRESERVE all bullet points, numbered lists, hyperlinks, and structural formatting from the original. Do NOT flatten lists into prose or remove links.\n";
|
||||
|
||||
// Append user-defined editor instructions if configured.
|
||||
$instructions = trim( (string) get_option( 'prautoblogger_editor_instructions', '' ) );
|
||||
if ( '' !== $instructions ) {
|
||||
$prompt .= "\nAdditional instructions:\n" . $instructions . "\n";
|
||||
}
|
||||
|
||||
return $prompt;
|
||||
}
|
||||
@@ -145,10 +153,14 @@ class PRAutoBlogger_Chief_Editor {
|
||||
* @return 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'] ) ) {
|
||||
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( [
|
||||
'verdict' => 'rejected',
|
||||
'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.
|
||||
* 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).
|
||||
* 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-cost-tracker.php — Logs API costs.
|
||||
* @see core/class-idea-scorer.php — Consumes analysis results.
|
||||
* @see ARCHITECTURE.md — Data flow step 2.
|
||||
* @see core/class-analysis-prompts.php — System/user prompt builders + performance context.
|
||||
* @see providers/interface-llm-provider.php — LLM used for analysis.
|
||||
* @see core/class-cost-tracker.php — Logs API costs.
|
||||
* @see core/class-json-extractor.php — Tolerant JSON parsing for LLM output.
|
||||
* @see core/class-idea-scorer.php — Consumes analysis results.
|
||||
* @see ARCHITECTURE.md — Data flow step 2.
|
||||
*/
|
||||
class PRAutoBlogger_Content_Analyzer {
|
||||
|
||||
@@ -37,11 +40,9 @@ class PRAutoBlogger_Content_Analyzer {
|
||||
*
|
||||
* 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.
|
||||
* 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 {
|
||||
$source_items = $this->fetch_recent_source_data();
|
||||
@@ -54,14 +55,10 @@ class PRAutoBlogger_Content_Analyzer {
|
||||
$niche = get_option( 'prautoblogger_niche_description', '' );
|
||||
$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 );
|
||||
|
||||
// Include past performance data for self-improvement.
|
||||
$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 );
|
||||
$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 );
|
||||
$user_prompt = PRAutoBlogger_Analysis_Prompts::build_user_prompt( $summary, $target_idea_count );
|
||||
|
||||
$response = $this->llm->send_chat_completion(
|
||||
[
|
||||
@@ -76,7 +73,6 @@ class PRAutoBlogger_Content_Analyzer {
|
||||
]
|
||||
);
|
||||
|
||||
// Log the cost.
|
||||
$this->cost_tracker->log_api_call(
|
||||
null,
|
||||
'analysis',
|
||||
@@ -86,15 +82,38 @@ class PRAutoBlogger_Content_Analyzer {
|
||||
$response['completion_tokens']
|
||||
);
|
||||
|
||||
// Parse LLM response into Analysis_Result objects.
|
||||
$results = $this->parse_analysis_response( $response['content'], $source_items );
|
||||
|
||||
// Store in database.
|
||||
$this->store_results( $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.
|
||||
*
|
||||
@@ -120,7 +139,6 @@ class PRAutoBlogger_Content_Analyzer {
|
||||
* Prioritizes high-engagement content and keeps total text under ~8000 tokens.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $items Source data rows.
|
||||
*
|
||||
* @return string Formatted text summary.
|
||||
*/
|
||||
private function build_source_summary( array $items ): string {
|
||||
@@ -129,12 +147,14 @@ class PRAutoBlogger_Content_Analyzer {
|
||||
$current_chars = 0;
|
||||
|
||||
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(
|
||||
"[%s] r/%s | Score: %d | Comments: %d\nTitle: %s\n%s\n---",
|
||||
$item['source_type'],
|
||||
$item['subreddit'] ?? 'unknown',
|
||||
(int) $item['score'],
|
||||
(int) $item['comment_count'],
|
||||
"%s\nTitle: %s\n%s\n---",
|
||||
$source_label,
|
||||
$item['title'] ?? '(comment)',
|
||||
mb_substr( $item['content'] ?? '', 0, 500 )
|
||||
);
|
||||
@@ -150,115 +170,21 @@ class PRAutoBlogger_Content_Analyzer {
|
||||
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.
|
||||
*
|
||||
* @param string $content LLM response content (JSON).
|
||||
* @param array<int, array<string, mixed>> $source_items Original source data for ID mapping.
|
||||
*
|
||||
* @param string $content LLM response content (JSON).
|
||||
* @param array<int, array<string, mixed>> $source_items Original source data for ID mapping.
|
||||
* @return PRAutoBlogger_Analysis_Result[]
|
||||
*/
|
||||
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'] ) ) {
|
||||
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 [];
|
||||
}
|
||||
|
||||
@@ -291,8 +217,6 @@ class PRAutoBlogger_Content_Analyzer {
|
||||
* Side effects: database inserts.
|
||||
*
|
||||
* @param PRAutoBlogger_Analysis_Result[] $results
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function store_results( array $results ): void {
|
||||
global $wpdb;
|
||||
|
||||
@@ -9,11 +9,12 @@ declare(strict_types=1);
|
||||
* - multi_step: Outline → Draft → Polish pipeline (higher quality, more cost).
|
||||
*
|
||||
* 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 ARCHITECTURE.md — Data flow step 4.
|
||||
* @see ARCHITECTURE.md — Data flow step 4.
|
||||
*/
|
||||
class PRAutoBlogger_Content_Generator {
|
||||
|
||||
@@ -32,13 +33,10 @@ class PRAutoBlogger_Content_Generator {
|
||||
* Generate a blog post from an article idea.
|
||||
*
|
||||
* Reads pipeline mode from settings and dispatches to the appropriate method.
|
||||
*
|
||||
* Side effects: LLM API calls (1-3 depending on mode), cost logging.
|
||||
*
|
||||
* @param PRAutoBlogger_Article_Idea $idea The scored idea to generate content for.
|
||||
*
|
||||
* @return string Generated HTML content.
|
||||
*
|
||||
* @throws \RuntimeException On generation failure.
|
||||
*/
|
||||
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_max_word_count', 2000 ) ),
|
||||
get_option( 'prautoblogger_niche_description', '' ),
|
||||
json_decode( get_option( 'prautoblogger_topic_exclusions', '[]' ), true ) ?: []
|
||||
json_decode( get_option( 'prautoblogger_topic_exclusions', '[]' ), true ) ?: [],
|
||||
get_option( 'prautoblogger_writing_instructions', '' )
|
||||
);
|
||||
|
||||
if ( 'single_pass' === $mode ) {
|
||||
@@ -64,139 +63,16 @@ class PRAutoBlogger_Content_Generator {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 );
|
||||
|
||||
// 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(
|
||||
[
|
||||
[ 'role' => 'system', 'content' => $this->build_writer_system_prompt( $request ) ],
|
||||
[ 'role' => 'user', 'content' => $prompt ],
|
||||
],
|
||||
$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 ],
|
||||
[ 'role' => 'system', 'content' => PRAutoBlogger_Content_Prompts::build_system( $request ) ],
|
||||
[ 'role' => 'user', 'content' => PRAutoBlogger_Content_Prompts::build_single_pass( $request ) ],
|
||||
],
|
||||
$model,
|
||||
[ 'temperature' => 0.7, 'max_tokens' => 4000 ]
|
||||
@@ -210,29 +86,67 @@ class PRAutoBlogger_Content_Generator {
|
||||
return $response['content'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-step stage 3: Polish and refine the draft.
|
||||
*
|
||||
* @param PRAutoBlogger_Content_Request $request
|
||||
* @param string $draft
|
||||
* @param string $model
|
||||
*
|
||||
* @return string Polished HTML content.
|
||||
*/
|
||||
private function stage_polish( PRAutoBlogger_Content_Request $request, string $draft, string $model ): string {
|
||||
$prompt = "Review and polish this blog post draft. Improve:\n" .
|
||||
"1. Flow and readability\n" .
|
||||
"2. SEO optimization (headings, keyword placement)\n" .
|
||||
"3. Engagement (hooks, transitions, call-to-action)\n" .
|
||||
"4. Accuracy and clarity\n" .
|
||||
"5. Remove any filler or redundant sentences\n\n" .
|
||||
"Return the polished HTML content only. Do not add commentary.\n\n" .
|
||||
"DRAFT:\n" . $draft;
|
||||
/** Multi-step generation: Outline → Draft → Polish. */
|
||||
private function generate_multi_step( PRAutoBlogger_Content_Request $request ): string {
|
||||
$model = get_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
|
||||
|
||||
$outline = $this->stage_outline( $request, $model );
|
||||
$draft = $this->stage_draft( $request, $outline, $model );
|
||||
$polished = $this->stage_polish( $request, $draft, $model );
|
||||
|
||||
return $polished;
|
||||
}
|
||||
|
||||
/** Multi-step stage 1: Generate an article outline. */
|
||||
private function stage_outline( PRAutoBlogger_Content_Request $request, string $model ): string {
|
||||
$system = PRAutoBlogger_Content_Prompts::build_system( $request );
|
||||
|
||||
$response = $this->llm->send_chat_completion(
|
||||
[
|
||||
[ 'role' => 'system', 'content' => $this->build_writer_system_prompt( $request ) ],
|
||||
[ 'role' => 'user', 'content' => $prompt ],
|
||||
[ 'role' => 'system', 'content' => $system ],
|
||||
[ '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,
|
||||
[ 'temperature' => 0.4, 'max_tokens' => 4000 ]
|
||||
@@ -245,53 +159,4 @@ class PRAutoBlogger_Content_Generator {
|
||||
|
||||
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.
|
||||
*
|
||||
* Compares against existing published posts to avoid topic overlap,
|
||||
* scores ideas by relevance, frequency, and freshness, then returns
|
||||
* the top N ideas matching the daily target.
|
||||
* Uses PRAutoBlogger_Semantic_Dedup for similarity detection — embedding-based
|
||||
* cosine similarity with automatic fallback to keyword overlap if the embedding
|
||||
* API is unavailable.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger::run_generation_pipeline() (step 3).
|
||||
* Dependencies: WordPress $wpdb, WP_Query.
|
||||
* Triggered by: PRAutoBlogger_Pipeline_Runner (step 3).
|
||||
* 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-generator.php — Consumes the ranked ideas we produce.
|
||||
* @see ARCHITECTURE.md — Data flow step 3.
|
||||
*/
|
||||
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;
|
||||
|
||||
/**
|
||||
* @param bool $skip True to skip deduplication.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function set_skip_dedup( bool $skip ): self {
|
||||
@@ -33,29 +33,60 @@ class PRAutoBlogger_Idea_Scorer {
|
||||
/**
|
||||
* Score, deduplicate, and rank article ideas.
|
||||
*
|
||||
* @param PRAutoBlogger_Analysis_Result[] $analysis_results Raw analysis output.
|
||||
* @param int $target_count Number of ideas to return.
|
||||
* Initializes the semantic dedup engine once, then checks each candidate
|
||||
* 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 {
|
||||
$exclusions = json_decode( get_option( 'prautoblogger_topic_exclusions', '[]' ), true ) ?: [];
|
||||
$ideas = [];
|
||||
$exclusions = json_decode( get_option( 'prautoblogger_topic_exclusions', '[]' ), true ) ?: [];
|
||||
$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 ) {
|
||||
// Skip excluded topics.
|
||||
if ( $this->is_excluded( $result->get_topic(), $exclusions ) ) {
|
||||
$excluded_count++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if we've already published something very similar
|
||||
// (unless dedup is disabled for manual runs).
|
||||
if ( ! $this->skip_dedup && $this->has_similar_post( $result->get_topic() ) ) {
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf( 'Skipping topic "%s" — similar post exists.', substr( $result->get_topic(), 0, 80 ) ),
|
||||
'scorer'
|
||||
);
|
||||
continue;
|
||||
if ( ! $this->skip_dedup ) {
|
||||
$topic = $result->get_topic();
|
||||
$topic_keywords = $this->extract_meaningful_keywords( $topic );
|
||||
|
||||
if ( $dedup->is_batch_duplicate( $topic, $topic_keywords, $accepted_keyword_sets ) ) {
|
||||
$deduped_count++;
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
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() ?? [];
|
||||
@@ -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 {
|
||||
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%).
|
||||
*
|
||||
* @param PRAutoBlogger_Analysis_Result $result
|
||||
*
|
||||
* @return float Score between 0 and 1.
|
||||
*/
|
||||
private function compute_score( PRAutoBlogger_Analysis_Result $result ): float {
|
||||
$relevance = min( $result->get_relevance_score(), 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;
|
||||
$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[] $exclusions List of excluded terms/phrases.
|
||||
*
|
||||
* @return 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 $topic Topic to check.
|
||||
*
|
||||
* @return bool True if a similar post exists.
|
||||
* @param string $text Input text.
|
||||
* @return string[] Unique lowercase keywords.
|
||||
*/
|
||||
private function has_similar_post( string $topic ): bool {
|
||||
$query = new \WP_Query( [
|
||||
'post_type' => 'post',
|
||||
'post_status' => [ 'publish', 'draft', 'pending' ],
|
||||
'meta_key' => '_prautoblogger_generated',
|
||||
'meta_value' => '1',
|
||||
's' => $topic,
|
||||
'posts_per_page' => 1,
|
||||
'fields' => 'ids',
|
||||
] );
|
||||
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',
|
||||
];
|
||||
|
||||
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.
|
||||
*
|
||||
* @param string $analysis_type 'question', 'complaint', 'comparison'.
|
||||
*
|
||||
* @param string $analysis_type 'question', 'complaint', 'comparison', etc.
|
||||
* @return string Article type label.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
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.
|
||||
if ( empty( $image_data['bytes'] ) ) {
|
||||
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' )
|
||||
);
|
||||
|
||||
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 (currently only Cloudflare Workers AI) 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,297 @@ declare(strict_types=1);
|
||||
/**
|
||||
* Orchestrates image generation for published articles.
|
||||
*
|
||||
* Generates two images per article (A/B experiment):
|
||||
* - Image A: Article-driven prompt → featured image
|
||||
* - 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).
|
||||
* Generates two images per article (A/B): Image A (article-driven) as featured
|
||||
* image, Image B (source-driven) in post meta. Each is independently fallible.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Pipeline_Runner after publisher creates the post.
|
||||
* Dependencies: PRAutoBlogger_Image_Provider_Interface (concrete provider),
|
||||
* PRAutoBlogger_Image_Prompt_Builder, PRAutoBlogger_Image_Media_Sideloader,
|
||||
* PRAutoBlogger_Cost_Tracker, PRAutoBlogger_Logger.
|
||||
* Dependencies: Image provider, Prompt builder, Sideloader, Cost tracker, Logger.
|
||||
*
|
||||
* @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.
|
||||
* @see core/class-image-prompt-builder.php — Prompt generation (scene + caption).
|
||||
* @see core/class-image-media-sideloader.php — Media library integration.
|
||||
* @see ARCHITECTURE.md — Image pipeline data flow.
|
||||
*/
|
||||
class PRAutoBlogger_Image_Pipeline {
|
||||
|
||||
/**
|
||||
* Default image dimensions (landscape).
|
||||
*/
|
||||
// Dimensions must be divisible by 8 (required by some providers).
|
||||
// 1200×632 is the closest 8-aligned pair to the standard OG image size (1200×630).
|
||||
private const DEFAULT_WIDTH = 1200;
|
||||
private const DEFAULT_HEIGHT = 630;
|
||||
private const DEFAULT_HEIGHT = 632;
|
||||
|
||||
/**
|
||||
* The image provider (FLUX.1 via Cloudflare or another).
|
||||
*
|
||||
* @var PRAutoBlogger_Image_Provider_Interface
|
||||
*/
|
||||
/** @var PRAutoBlogger_Image_Provider_Interface Image gen provider. */
|
||||
private PRAutoBlogger_Image_Provider_Interface $provider;
|
||||
|
||||
/**
|
||||
* Prompt builder.
|
||||
*
|
||||
* @var PRAutoBlogger_Image_Prompt_Builder
|
||||
*/
|
||||
/** @var PRAutoBlogger_Image_Prompt_Builder Builds scene + caption from article data. */
|
||||
private PRAutoBlogger_Image_Prompt_Builder $prompt_builder;
|
||||
|
||||
/**
|
||||
* Media library sideloader.
|
||||
*
|
||||
* @var PRAutoBlogger_Image_Media_Sideloader
|
||||
*/
|
||||
/** @var PRAutoBlogger_Image_Media_Sideloader Downloads images into WP media library. */
|
||||
private PRAutoBlogger_Image_Media_Sideloader $sideloader;
|
||||
|
||||
/**
|
||||
* Cost tracker for logging spend.
|
||||
*
|
||||
* @var PRAutoBlogger_Cost_Tracker
|
||||
*/
|
||||
/** @var PRAutoBlogger_Cost_Tracker Logs image generation spend. */
|
||||
private PRAutoBlogger_Cost_Tracker $cost_tracker;
|
||||
|
||||
/**
|
||||
* 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 ('openrouter' or 'cloudflare').
|
||||
*
|
||||
* @param PRAutoBlogger_Image_Provider_Interface|null $provider Optional provider override.
|
||||
* @param PRAutoBlogger_Cost_Tracker|null $cost_tracker Optional cost tracker.
|
||||
*/
|
||||
public function __construct(
|
||||
?PRAutoBlogger_Image_Provider_Interface $provider = 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->sideloader = new PRAutoBlogger_Image_Media_Sideloader();
|
||||
$this->cost_tracker = $cost_tracker ?? new PRAutoBlogger_Cost_Tracker();
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiate the image provider based on the admin setting.
|
||||
*
|
||||
* @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_Cloudflare_Image_Provider();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and attach images to a published post.
|
||||
*
|
||||
* Generates Image A (article-driven) and Image B (source-driven). If enabled
|
||||
* in settings. Each image is independently fallible.
|
||||
* Builds all prompts upfront, fires a single batch call (parallel via
|
||||
* 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 array{
|
||||
* post_title?: string,
|
||||
* 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.
|
||||
* @param int $post_id Post ID to attach images to.
|
||||
* @param array $article_data Article title + content.
|
||||
* @param array|null $source_data Optional source data for Image B.
|
||||
*
|
||||
* @return array{
|
||||
* image_a_id?: int,
|
||||
* image_b_id?: int,
|
||||
* cost_usd: float,
|
||||
* errors: string[],
|
||||
* } Attachment IDs (if successful) and cost/errors.
|
||||
* @return array{image_a_id?: int, image_b_id?: int, cost_usd: float, errors: string[]}
|
||||
*/
|
||||
public function generate_and_attach_images(
|
||||
int $post_id,
|
||||
array $article_data,
|
||||
?array $source_data = null
|
||||
): array {
|
||||
$result = [
|
||||
'cost_usd' => 0.0,
|
||||
'errors' => [],
|
||||
];
|
||||
$result = [ 'cost_usd' => 0.0, 'errors' => [] ];
|
||||
|
||||
// Early exit if image generation is disabled.
|
||||
if ( ! get_option( 'prautoblogger_image_enabled' ) ) {
|
||||
PRAutoBlogger_Logger::instance()->info( 'Image generation disabled in settings.', 'image_pipeline' );
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Generate Image A (article-driven prompt).
|
||||
$image_a_result = $this->generate_image_a( $post_id, $article_data );
|
||||
if ( ! is_wp_error( $image_a_result ) && isset( $image_a_result['attachment_id'] ) ) {
|
||||
$result['image_a_id'] = $image_a_result['attachment_id'];
|
||||
} else {
|
||||
$result['errors'][] = is_wp_error( $image_a_result )
|
||||
? $image_a_result->get_error_message()
|
||||
: 'Image A generation produced no attachment ID.';
|
||||
}
|
||||
if ( ! is_wp_error( $image_a_result ) ) {
|
||||
$result['cost_usd'] += $image_a_result['cost_usd'] ?? 0.0;
|
||||
// Check whether Image B is enabled in settings.
|
||||
$image_b_enabled = get_option( 'prautoblogger_image_b_enabled', '1' );
|
||||
$has_source_data = null !== $source_data && ! empty( $source_data );
|
||||
$generate_b = $has_source_data && '1' === $image_b_enabled;
|
||||
|
||||
// Budget pre-check: estimate cost for all images before any HTTP calls.
|
||||
$image_count = $generate_b ? 2 : 1;
|
||||
$estimated_cost = $this->provider->estimate_cost( self::DEFAULT_WIDTH, self::DEFAULT_HEIGHT ) * $image_count;
|
||||
if ( $this->cost_tracker->would_exceed_budget( $estimated_cost ) ) {
|
||||
$result['errors'][] = 'Image generation would exceed monthly budget.';
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Generate Image B (source-driven prompt) if source data is available.
|
||||
if ( null !== $source_data && ! empty( $source_data ) ) {
|
||||
$image_b_result = $this->generate_image_b( $post_id, $source_data );
|
||||
if ( ! is_wp_error( $image_b_result ) && isset( $image_b_result['attachment_id'] ) ) {
|
||||
$result['image_b_id'] = $image_b_result['attachment_id'];
|
||||
} else {
|
||||
$result['errors'][] = is_wp_error( $image_b_result )
|
||||
? $image_b_result->get_error_message()
|
||||
: 'Image B generation produced no attachment ID.';
|
||||
}
|
||||
if ( ! is_wp_error( $image_b_result ) ) {
|
||||
$result['cost_usd'] += $image_b_result['cost_usd'] ?? 0.0;
|
||||
}
|
||||
// Build all prompts upfront (LLM calls happen here, sequentially).
|
||||
$article_prompt = $this->prompt_builder->build_article_prompt( $article_data );
|
||||
$batch_requests = [
|
||||
'image_a' => [
|
||||
'prompt' => $article_prompt['prompt'],
|
||||
'width' => self::DEFAULT_WIDTH,
|
||||
'height' => self::DEFAULT_HEIGHT,
|
||||
],
|
||||
];
|
||||
$captions = [ 'image_a' => $article_prompt['caption'] ];
|
||||
|
||||
$source_prompt = null;
|
||||
if ( $generate_b ) {
|
||||
$source_prompt = $this->prompt_builder->build_source_prompt( $source_data );
|
||||
$batch_requests['image_b'] = [
|
||||
'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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Image A from article content.
|
||||
* Process Image A batch result: sideload, set featured image, insert caption.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param array $article_data Article data.
|
||||
*
|
||||
* @return array{
|
||||
* attachment_id?: int,
|
||||
* cost_usd: float,
|
||||
* }|\WP_Error
|
||||
* @param int $post_id Post ID.
|
||||
* @param array $batch_results Keyed results from generate_image_batch().
|
||||
* @param array $captions Keyed captions from prompt builder.
|
||||
* @param array $result Pipeline result array (modified by reference).
|
||||
*/
|
||||
private function generate_image_a( int $post_id, array $article_data ) {
|
||||
try {
|
||||
// Build the article-driven prompt.
|
||||
$prompt = $this->prompt_builder->build_article_prompt( $article_data );
|
||||
|
||||
// Estimate cost before generating.
|
||||
$estimated_cost = $this->provider->estimate_cost( self::DEFAULT_WIDTH, self::DEFAULT_HEIGHT );
|
||||
if ( $this->cost_tracker->would_exceed_budget( $estimated_cost ) ) {
|
||||
return new \WP_Error(
|
||||
'budget_exceeded',
|
||||
'Image generation would exceed monthly budget.'
|
||||
);
|
||||
}
|
||||
|
||||
// Generate the image.
|
||||
$image_data = $this->provider->generate_image( $prompt, self::DEFAULT_WIDTH, self::DEFAULT_HEIGHT );
|
||||
|
||||
// Sideload the image into media library.
|
||||
$attachment_id = $this->sideloader->sideload_image(
|
||||
$image_data,
|
||||
$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'] ),
|
||||
private function process_image_a( int $post_id, array $batch_results, array $captions, array &$result ): void {
|
||||
$image_data = $batch_results['image_a'] ?? null;
|
||||
if ( ! $image_data || isset( $image_data['error'] ) ) {
|
||||
$err_msg = $image_data['error'] ?? 'Image A missing from batch results.';
|
||||
$result['errors'][] = $err_msg;
|
||||
// Log at WARNING so the path is visible even if the outer catch in the
|
||||
// caller doesn't fire. See thread image-mime-bug Issue 2.
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf( 'Image A generation failed for post %d: %s', $post_id, $err_msg ),
|
||||
'image_pipeline'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
return [
|
||||
'attachment_id' => $attachment_id,
|
||||
'cost_usd' => $image_data['cost_usd'],
|
||||
];
|
||||
} catch ( \Exception $e ) {
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
'Image A generation failed: ' . $e->getMessage(),
|
||||
'image_pipeline'
|
||||
);
|
||||
$attachment_id = $this->sideload_and_log( $image_data, $post_id, 'image_a', 'Image A' );
|
||||
if ( is_wp_error( $attachment_id ) ) {
|
||||
$result['errors'][] = $attachment_id->get_error_message();
|
||||
return;
|
||||
}
|
||||
|
||||
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 array $source_data Source Reddit data.
|
||||
*
|
||||
* @return array{
|
||||
* attachment_id?: int,
|
||||
* cost_usd: float,
|
||||
* }|\WP_Error
|
||||
* @param int $post_id Post ID.
|
||||
* @param array $batch_results Keyed results from generate_image_batch().
|
||||
* @param array $captions Keyed captions from prompt builder.
|
||||
* @param array $result Pipeline result array (modified by reference).
|
||||
*/
|
||||
private function generate_image_b( int $post_id, array $source_data ) {
|
||||
try {
|
||||
// Build the source-driven prompt.
|
||||
$prompt = $this->prompt_builder->build_source_prompt( $source_data );
|
||||
|
||||
// Estimate cost before generating.
|
||||
$estimated_cost = $this->provider->estimate_cost( self::DEFAULT_WIDTH, self::DEFAULT_HEIGHT );
|
||||
if ( $this->cost_tracker->would_exceed_budget( $estimated_cost ) ) {
|
||||
return new \WP_Error(
|
||||
'budget_exceeded',
|
||||
'Image generation would exceed monthly budget.'
|
||||
);
|
||||
}
|
||||
|
||||
// Generate the image.
|
||||
$image_data = $this->provider->generate_image( $prompt, self::DEFAULT_WIDTH, self::DEFAULT_HEIGHT );
|
||||
|
||||
// Sideload the image into media library.
|
||||
$attachment_id = $this->sideloader->sideload_image(
|
||||
$image_data,
|
||||
$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'] ),
|
||||
private function process_image_b( int $post_id, array $batch_results, array $captions, array &$result ): void {
|
||||
$image_data = $batch_results['image_b'] ?? null;
|
||||
if ( ! $image_data || isset( $image_data['error'] ) ) {
|
||||
$err_msg = $image_data['error'] ?? 'Image B missing from batch results.';
|
||||
$result['errors'][] = $err_msg;
|
||||
// Log at WARNING so the path is visible even if the outer catch in the
|
||||
// caller doesn't fire. See thread image-mime-bug Issue 2.
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf( 'Image B generation failed for post %d: %s', $post_id, $err_msg ),
|
||||
'image_pipeline'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
return [
|
||||
'attachment_id' => $attachment_id,
|
||||
'cost_usd' => $image_data['cost_usd'],
|
||||
];
|
||||
} catch ( \Exception $e ) {
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
'Image B generation failed: ' . $e->getMessage(),
|
||||
'image_pipeline'
|
||||
);
|
||||
$attachment_id = $this->sideload_and_log( $image_data, $post_id, 'image_b', 'Image B' );
|
||||
if ( is_wp_error( $attachment_id ) ) {
|
||||
$result['errors'][] = $attachment_id->get_error_message();
|
||||
return;
|
||||
}
|
||||
|
||||
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,223 @@ declare(strict_types=1);
|
||||
/**
|
||||
* Builds image prompts from article content or source data.
|
||||
*
|
||||
* Generates concise visual concepts suitable for FLUX.1 image generation,
|
||||
* extracting key themes from article titles/content and Reddit source data.
|
||||
* Uses a cheap LLM (via OpenRouter) to distill article content into concise
|
||||
* visual scene descriptions optimised for image generation. Falls back to rule-based
|
||||
* synthesis if the LLM call fails, so image generation never blocks on a
|
||||
* prompt-rewriting outage.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Image_Pipeline during content generation run.
|
||||
* Dependencies: 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 providers/class-open-router-provider.php — LLM provider used for rewriting.
|
||||
* @see ARCHITECTURE.md — Image generation data flow.
|
||||
*/
|
||||
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. Use anthropomorphized molecules, lab-coat-wearing animals, befuddled scientists, gym bros encountering peptide science, supplement bottles with personality, or everyday situations with a peptide twist. 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
|
||||
- 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 in confusion at a tiny vial while a peptide molecule with arms and legs flexes next to him, clearly outperforming him.
|
||||
|
||||
"I've been doing this wrong for three years."
|
||||
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.
|
||||
*
|
||||
* Extracts the title and first paragraph to synthesize a product-marketing
|
||||
* angle visual concept suitable for a featured image. Appends the style suffix
|
||||
* from plugin options.
|
||||
* Tries LLM rewriting first; falls back to rule-based synthesis on
|
||||
* failure. Splits scene (for image gen) from caption (HTML below the
|
||||
* image) and appends the style suffix.
|
||||
*
|
||||
* @param array{
|
||||
* post_title?: 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.
|
||||
* @param array{post_title?: string, post_content?: string, suggested_title?: string} $article_data
|
||||
* @return array{prompt: string, caption: string}
|
||||
*/
|
||||
public function build_article_prompt( array $article_data ): string {
|
||||
$title = $article_data['post_title'] ?? $article_data['suggested_title'] ?? 'Product';
|
||||
$content = $article_data['post_content'] ?? '';
|
||||
|
||||
// Extract first paragraph from HTML content.
|
||||
public function build_article_prompt( array $article_data ): array {
|
||||
$title = $article_data['post_title'] ?? $article_data['suggested_title'] ?? 'Product';
|
||||
$content = $article_data['post_content'] ?? '';
|
||||
$first_para = $this->extract_first_paragraph( $content );
|
||||
|
||||
// Build visual concepts from title + first paragraph.
|
||||
$concepts = $this->synthesize_visual_concepts( $title, $first_para );
|
||||
$parsed = $this->rewrite_via_llm( $title, $first_para );
|
||||
|
||||
// Append 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
|
||||
* editorial-cartoonist angle visual concept. Appends the style suffix
|
||||
* 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.
|
||||
* @param array{title?: string, selftext?: string, comments?: string[]} $source_data
|
||||
* @return array{prompt: string, caption: string}
|
||||
*/
|
||||
public function build_source_prompt( array $source_data ): string {
|
||||
public function build_source_prompt( array $source_data ): array {
|
||||
$title = $source_data['title'] ?? 'Reddit Discussion';
|
||||
$comments = $source_data['comments'] ?? [];
|
||||
$context = is_array( $comments ) && ! empty( $comments ) ? $comments[0] : '';
|
||||
|
||||
// Build visual concepts from title + top comments.
|
||||
$concepts = $this->synthesize_visual_concepts(
|
||||
$title,
|
||||
is_array( $comments ) && ! empty( $comments ) ? $comments[0] : ''
|
||||
);
|
||||
$parsed = $this->rewrite_via_llm( $title, $context );
|
||||
|
||||
// Append 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 +230,13 @@ class PRAutoBlogger_Image_Prompt_Builder {
|
||||
* or 200 characters, whichever comes first.
|
||||
*
|
||||
* @param string $html HTML content.
|
||||
*
|
||||
* @return string Plain text of the first paragraph.
|
||||
*/
|
||||
private function extract_first_paragraph( string $html ): string {
|
||||
// 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 ) {
|
||||
$para = substr( $para, 0, 200 ) . '...';
|
||||
}
|
||||
@@ -104,33 +245,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
|
||||
* can render. This is intentionally rule-based rather than LLM-driven to
|
||||
* avoid another API call per image generation.
|
||||
*
|
||||
* @param string $title Main heading / topic.
|
||||
* @param string $supporting_text Additional context (first para or comment).
|
||||
*
|
||||
* @return string Synthesized visual prompt concept.
|
||||
* @param string $title Article or source title.
|
||||
* @return array{prompt: string, caption: string}
|
||||
*/
|
||||
private function synthesize_visual_concepts( string $title, string $supporting_text ): string {
|
||||
$title = trim( sanitize_text_field( $title ) );
|
||||
$text = trim( sanitize_text_field( $supporting_text ) );
|
||||
public function build_fallback_prompt( string $title ): array {
|
||||
$parsed = $this->synthesize_visual_concepts_fallback( $title, '' );
|
||||
$style_suffix = get_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
|
||||
return [
|
||||
'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ),
|
||||
'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 ) ) {
|
||||
// Take first 150 chars to avoid bloating the prompt.
|
||||
$snippet = strlen( $text ) > 150
|
||||
? substr( $text, 0, 150 ) . '...'
|
||||
: $text;
|
||||
return [
|
||||
'scene' => trim( $scene ),
|
||||
'caption' => 'Science is full of surprises.',
|
||||
];
|
||||
}
|
||||
|
||||
$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);
|
||||
|
||||
/**
|
||||
* Executes the full content generation pipeline.
|
||||
* Executes the content generation pipeline using a chained-job architecture.
|
||||
*
|
||||
* 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().
|
||||
* Dependencies: All core content classes (Collector, Analyzer, Scorer, Generator, Editor, Publisher).
|
||||
* Why chained jobs: Hostinger kills PHP processes at ~120s. A single article
|
||||
* (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().
|
||||
* @see core/class-cost-tracker.php — Tracks cumulative API costs throughout the run.
|
||||
* @see ARCHITECTURE.md — Data flow diagram showing pipeline steps.
|
||||
* Triggered by: PRAutoBlogger_Executor (daily cron + manual "Generate Now").
|
||||
* Dependencies: Core content classes, Article_Worker, WP-Cron for chaining.
|
||||
*
|
||||
* @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 {
|
||||
|
||||
/**
|
||||
* Execute the full content generation pipeline.
|
||||
*
|
||||
* Steps: Budget check → Collect source data → Analyze patterns →
|
||||
* Score ideas → Generate content → Editorial review → Publish.
|
||||
*
|
||||
* Side effects: API calls (OpenRouter, Reddit), database writes, post creation.
|
||||
*
|
||||
* @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). */
|
||||
/** Option key for the article generation queue. */
|
||||
private const QUEUE_KEY = 'prautoblogger_article_queue';
|
||||
|
||||
/** WP-Cron action for chained article generation. */
|
||||
public const CRON_ACTION = 'prautoblogger_generate_queued_article';
|
||||
|
||||
/** @var bool Whether to skip duplicate-topic detection. */
|
||||
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.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function set_skip_dedup( bool $skip ): self {
|
||||
@@ -44,104 +40,245 @@ class PRAutoBlogger_Pipeline_Runner {
|
||||
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 {
|
||||
$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() );
|
||||
|
||||
// Hard stop if monthly budget is exceeded.
|
||||
if ( $cost_tracker->is_budget_exceeded() ) {
|
||||
throw new \RuntimeException(
|
||||
__( 'Monthly API budget exceeded. Generation halted.', 'prautoblogger' )
|
||||
);
|
||||
}
|
||||
|
||||
$target_count = absint( get_option( 'prautoblogger_daily_article_target', 1 ) );
|
||||
|
||||
// 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 );
|
||||
|
||||
$ideas = $this->orchestrate( $cost_tracker );
|
||||
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 ];
|
||||
}
|
||||
|
||||
// Step 4 & 5: Generate content and run through editor for each idea.
|
||||
$generator = new PRAutoBlogger_Content_Generator( $llm, $cost_tracker );
|
||||
$editor = new PRAutoBlogger_Chief_Editor( $llm, $cost_tracker );
|
||||
$publisher = new PRAutoBlogger_Publisher();
|
||||
$auto_publish = in_array( get_option( 'prautoblogger_auto_publish', '0' ), [ '1', 'yes' ], true );
|
||||
// Generate article 1 in this process — always fits within time limit.
|
||||
$first_idea = array_shift( $ideas );
|
||||
$worker = new PRAutoBlogger_Article_Worker( $cost_tracker );
|
||||
$result = $worker->generate( $first_idea );
|
||||
|
||||
$generated = 0;
|
||||
$published = 0;
|
||||
$rejected = 0;
|
||||
|
||||
foreach ( $ideas as $idea ) {
|
||||
// Re-check budget before each article — stop early if we hit the limit.
|
||||
if ( $cost_tracker->is_budget_exceeded() ) {
|
||||
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.
|
||||
}
|
||||
// Queue remaining articles as chained cron events.
|
||||
if ( ! empty( $ideas ) ) {
|
||||
$this->queue_remaining( $ideas, $cost_tracker->get_run_id(), $result );
|
||||
} else {
|
||||
// Single-article run — amortize research costs now (1 article = full cost).
|
||||
PRAutoBlogger_Post_Assembler::amortize_research_costs( $cost_tracker->get_run_id() );
|
||||
$this->log_summary( $result );
|
||||
}
|
||||
|
||||
$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 ),
|
||||
'pipeline'
|
||||
/**
|
||||
* Process the next article from the persistent queue.
|
||||
*
|
||||
* 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 [
|
||||
'generated' => $generated,
|
||||
'published' => $published,
|
||||
'rejected' => $rejected,
|
||||
'cost' => $total_cost,
|
||||
$queue = [
|
||||
'run_id' => $run_id,
|
||||
'ideas' => $serialized,
|
||||
'results' => $first,
|
||||
];
|
||||
|
||||
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).
|
||||
* Stores all generation metadata as post_meta for transparency and auditability.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger::run_generation_pipeline() (step 6).
|
||||
* Dependencies: WordPress wp_insert_post(), post meta API.
|
||||
* Triggered by: PRAutoBlogger_Pipeline_Runner (step 6).
|
||||
* 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-content-generator.php — Produces the content we publish.
|
||||
* @see ARCHITECTURE.md — Data flow step 6.
|
||||
@@ -19,18 +22,11 @@ class PRAutoBlogger_Publisher {
|
||||
/**
|
||||
* 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 PRAutoBlogger_Article_Idea $idea The original article idea.
|
||||
* @param PRAutoBlogger_Editorial_Review $review The editor's review.
|
||||
* @param string|null $run_id Pipeline run ID for log linking.
|
||||
*
|
||||
* @return int The created post ID.
|
||||
*
|
||||
* @throws \RuntimeException If post creation fails.
|
||||
*/
|
||||
public function publish(
|
||||
@@ -49,9 +45,7 @@ class PRAutoBlogger_Publisher {
|
||||
* @param PRAutoBlogger_Article_Idea $idea The original article idea.
|
||||
* @param PRAutoBlogger_Editorial_Review $review The editor's review with rejection notes.
|
||||
* @param string|null $run_id Pipeline run ID for log linking.
|
||||
*
|
||||
* @return int The created post ID.
|
||||
*
|
||||
* @throws \RuntimeException If post creation fails.
|
||||
*/
|
||||
public function save_as_draft(
|
||||
@@ -71,9 +65,7 @@ class PRAutoBlogger_Publisher {
|
||||
* @param PRAutoBlogger_Editorial_Review $review Editorial review.
|
||||
* @param string $post_status 'publish' or 'draft'.
|
||||
* @param string|null $run_id Pipeline run ID for log linking.
|
||||
*
|
||||
* @return int Post ID.
|
||||
*
|
||||
* @throws \RuntimeException If wp_insert_post fails.
|
||||
*/
|
||||
private function create_post(
|
||||
@@ -83,47 +75,36 @@ class PRAutoBlogger_Publisher {
|
||||
string $post_status,
|
||||
?string $run_id = null
|
||||
): 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_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_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 ),
|
||||
];
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** @see class-prautoblogger.php — listeners registered in main loader. */
|
||||
$post_data = apply_filters( 'prautoblogger_filter_post_data', $post_data, $idea, $review );
|
||||
|
||||
$post_id = wp_insert_post( $post_data, true );
|
||||
|
||||
if ( is_wp_error( $post_id ) ) {
|
||||
throw new \RuntimeException(
|
||||
sprintf(
|
||||
/* translators: %s: WordPress error message */
|
||||
__( 'Failed to create post: %s', 'prautoblogger' ),
|
||||
$post_id->get_error_message()
|
||||
)
|
||||
sprintf( __( 'Failed to create post: %s', 'prautoblogger' ), $post_id->get_error_message() )
|
||||
);
|
||||
}
|
||||
|
||||
// Assign categories/tags based on article type and keywords.
|
||||
$this->assign_taxonomy_terms( $post_id, $idea );
|
||||
PRAutoBlogger_Post_Assembler::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 ) {
|
||||
$this->attach_generated_images( $post_id, $idea, $post_data );
|
||||
PRAutoBlogger_Post_Assembler::attach_generated_images( $post_id, $idea, $post_data );
|
||||
}
|
||||
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
@@ -131,14 +112,6 @@ class PRAutoBlogger_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 );
|
||||
|
||||
return $post_id;
|
||||
@@ -149,239 +122,26 @@ class PRAutoBlogger_Publisher {
|
||||
*
|
||||
* @param PRAutoBlogger_Article_Idea $idea
|
||||
* @param PRAutoBlogger_Editorial_Review $review
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function build_meta(
|
||||
PRAutoBlogger_Article_Idea $idea,
|
||||
PRAutoBlogger_Editorial_Review $review
|
||||
): array {
|
||||
$pipeline_mode = get_option( 'prautoblogger_writing_pipeline', 'multi_step' );
|
||||
$model = get_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
|
||||
|
||||
return [
|
||||
'_prautoblogger_generated' => '1',
|
||||
'_prautoblogger_analysis_id' => $idea->get_analysis_id(),
|
||||
'_prautoblogger_source_ids' => wp_json_encode( $idea->get_source_ids() ),
|
||||
'_prautoblogger_model_used' => $model,
|
||||
'_prautoblogger_pipeline_mode' => $pipeline_mode,
|
||||
'_prautoblogger_editor_verdict' => $review->get_verdict(),
|
||||
'_prautoblogger_editor_notes' => $review->get_notes(),
|
||||
'_prautoblogger_quality_score' => $review->get_quality_score(),
|
||||
'_prautoblogger_seo_score' => $review->get_seo_score(),
|
||||
'_prautoblogger_generated_at' => gmdate( 'c' ),
|
||||
'_prautoblogger_topic' => $idea->get_topic(),
|
||||
'_prautoblogger_article_type' => $idea->get_article_type(),
|
||||
'_prautoblogger_target_keywords' => wp_json_encode( $idea->get_target_keywords() ),
|
||||
'_prautoblogger_generated' => '1',
|
||||
'_prautoblogger_analysis_id' => $idea->get_analysis_id(),
|
||||
'_prautoblogger_source_ids' => wp_json_encode( $idea->get_source_ids() ),
|
||||
'_prautoblogger_model_used' => get_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL ),
|
||||
'_prautoblogger_pipeline_mode' => get_option( 'prautoblogger_writing_pipeline', 'multi_step' ),
|
||||
'_prautoblogger_editor_verdict' => $review->get_verdict(),
|
||||
'_prautoblogger_editor_notes' => $review->get_notes(),
|
||||
'_prautoblogger_quality_score' => $review->get_quality_score(),
|
||||
'_prautoblogger_seo_score' => $review->get_seo_score(),
|
||||
'_prautoblogger_generated_at' => gmdate( 'c' ),
|
||||
'_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 ) {
|
||||
// Precise linking via run_id — no risk of cross-article misattribution.
|
||||
// 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'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
* 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.
|
||||
* 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).
|
||||
* Dependencies: Source provider implementations, WordPress $wpdb.
|
||||
*
|
||||
* @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.
|
||||
*/
|
||||
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.
|
||||
*
|
||||
@@ -25,7 +46,10 @@ class PRAutoBlogger_Source_Collector {
|
||||
* @return int Total number of new records inserted.
|
||||
*/
|
||||
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 ) ) {
|
||||
$enabled = [ 'reddit' ];
|
||||
}
|
||||
@@ -42,14 +66,15 @@ class PRAutoBlogger_Source_Collector {
|
||||
try {
|
||||
$data = $provider->collect_data( $this->get_config_for_source( $source_type ) );
|
||||
$inserted = $this->store_data( $data );
|
||||
$refreshed = count( $data ) - $inserted;
|
||||
$total_inserted += $inserted;
|
||||
|
||||
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'
|
||||
);
|
||||
} catch ( \Exception $e ) {
|
||||
PRAutoBlogger_Logger::instance()->error( "Collection from {$source_type} FAILED: " . $e->getMessage(), 'collector' );
|
||||
} catch ( \Throwable $e ) {
|
||||
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.
|
||||
}
|
||||
}
|
||||
@@ -57,16 +82,61 @@ class PRAutoBlogger_Source_Collector {
|
||||
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.
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* @return int Number of new records inserted.
|
||||
* @return int Number of new records inserted (updates not counted).
|
||||
*/
|
||||
private function store_data( array $items ): int {
|
||||
global $wpdb;
|
||||
@@ -76,13 +146,18 @@ class PRAutoBlogger_Source_Collector {
|
||||
foreach ( $items as $item ) {
|
||||
$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
|
||||
$result = $wpdb->query(
|
||||
$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)
|
||||
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_id'],
|
||||
$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++;
|
||||
}
|
||||
}
|
||||
@@ -114,8 +191,8 @@ class PRAutoBlogger_Source_Collector {
|
||||
*/
|
||||
private function get_provider( string $source_type ): ?PRAutoBlogger_Source_Provider_Interface {
|
||||
$providers = [
|
||||
'reddit' => PRAutoBlogger_Reddit_Provider::class,
|
||||
// New providers can be added here — see CONVENTIONS.md for how-to.
|
||||
'reddit' => PRAutoBlogger_Reddit_Provider::class,
|
||||
'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 {
|
||||
if ( 'reddit' === $source_type ) {
|
||||
return [
|
||||
'subreddits' => json_decode( get_option( 'prautoblogger_target_subreddits', '[]' ), true ) ?: [],
|
||||
'limit' => 25,
|
||||
'time_filter' => 'day',
|
||||
'include_comments' => true,
|
||||
'subreddits' => json_decode( get_option( 'prautoblogger_target_subreddits', '[]' ), true ) ?: [],
|
||||
'limit' => 25,
|
||||
'time_filter' => 'day',
|
||||
'include_comments' => true,
|
||||
'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 [];
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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 string $niche_description;
|
||||
private array $topic_exclusions;
|
||||
private string $writing_instructions;
|
||||
|
||||
public function __construct(
|
||||
PRAutoBlogger_Article_Idea $idea,
|
||||
@@ -29,15 +30,17 @@ class PRAutoBlogger_Content_Request {
|
||||
int $min_word_count,
|
||||
int $max_word_count,
|
||||
string $niche_description,
|
||||
array $topic_exclusions = []
|
||||
array $topic_exclusions = [],
|
||||
string $writing_instructions = ''
|
||||
) {
|
||||
$this->idea = $idea;
|
||||
$this->pipeline_mode = $pipeline_mode;
|
||||
$this->tone = $tone;
|
||||
$this->min_word_count = $min_word_count;
|
||||
$this->max_word_count = $max_word_count;
|
||||
$this->niche_description = $niche_description;
|
||||
$this->topic_exclusions = $topic_exclusions;
|
||||
$this->idea = $idea;
|
||||
$this->pipeline_mode = $pipeline_mode;
|
||||
$this->tone = $tone;
|
||||
$this->min_word_count = $min_word_count;
|
||||
$this->max_word_count = $max_word_count;
|
||||
$this->niche_description = $niche_description;
|
||||
$this->topic_exclusions = $topic_exclusions;
|
||||
$this->writing_instructions = $writing_instructions;
|
||||
}
|
||||
|
||||
public function get_idea(): PRAutoBlogger_Article_Idea {
|
||||
@@ -67,4 +70,8 @@ class PRAutoBlogger_Content_Request {
|
||||
public function get_topic_exclusions(): array {
|
||||
return $this->topic_exclusions;
|
||||
}
|
||||
|
||||
public function get_writing_instructions(): string {
|
||||
return $this->writing_instructions;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,19 +19,22 @@ class PRAutoBlogger_Cloudflare_Image_Pricing {
|
||||
|
||||
/**
|
||||
* Cloudflare Workers AI FLUX pricing (USD per megapixel), April 2026.
|
||||
* Single source of truth — update here if Cloudflare repricies.
|
||||
* Single source of truth — update here if Cloudflare reprices.
|
||||
*/
|
||||
private const COST_PER_MP_SCHNELL_USD = 0.0011;
|
||||
private const COST_MULTIPLIER_DEV = 4.0;
|
||||
|
||||
/**
|
||||
* Short-alias → fully-qualified Workers AI model id.
|
||||
*
|
||||
* FLUX.1 [dev] was removed from Cloudflare Workers AI in April 2026
|
||||
* (returns HTTP 400 "No route for that URI"). Only schnell remains.
|
||||
* Any saved setting referencing flux-1-dev falls through to schnell
|
||||
* via the null-coalesce in resolve_model().
|
||||
*
|
||||
* @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',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -58,9 +61,9 @@ class PRAutoBlogger_Cloudflare_Image_Pricing {
|
||||
/**
|
||||
* 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.
|
||||
* FLUX is priced per megapixel of output. 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.
|
||||
@@ -70,9 +73,6 @@ class PRAutoBlogger_Cloudflare_Image_Pricing {
|
||||
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 );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,10 @@ class PRAutoBlogger_Cloudflare_Image_Provider implements PRAutoBlogger_Image_Pro
|
||||
* @throws \RuntimeException On auth failure, bad response shape, or retry exhaustion.
|
||||
*/
|
||||
public function generate_image( string $prompt, int $width, int $height, array $options = [] ): array {
|
||||
// FLUX.1 requires dimensions divisible by 8; round to nearest valid value.
|
||||
$width = (int) ( round( $width / 8 ) * 8 );
|
||||
$height = (int) ( round( $height / 8 ) * 8 );
|
||||
|
||||
$this->assert_valid_dimensions( $width, $height );
|
||||
$prompt = trim( $prompt );
|
||||
if ( '' === $prompt ) {
|
||||
@@ -132,6 +136,7 @@ class PRAutoBlogger_Cloudflare_Image_Provider implements PRAutoBlogger_Image_Pro
|
||||
|
||||
if ( $status >= 400 ) {
|
||||
$this->support->log_client_error( $status, $api_token, $account_id, $raw );
|
||||
$this->support->throw_if_nsfw( $raw );
|
||||
throw new \RuntimeException(
|
||||
sprintf(
|
||||
/* translators: %1$d: HTTP status, %2$s: error body. */
|
||||
@@ -170,6 +175,26 @@ class PRAutoBlogger_Cloudflare_Image_Provider implements PRAutoBlogger_Image_Pro
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sequential fallback — Cloudflare Workers AI does not support batching.
|
||||
*
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function generate_image_batch( array $requests ): array {
|
||||
$results = [];
|
||||
foreach ( $requests as $key => $req ) {
|
||||
try {
|
||||
$results[ $key ] = $this->generate_image( $req['prompt'], $req['width'], $req['height'], $req['options'] ?? [] );
|
||||
} catch ( PRAutoBlogger_Image_NSFW_Blocked $e ) {
|
||||
// Tag NSFW so the pipeline's retry layer can rebuild the prompt.
|
||||
$results[ $key ] = [ 'error' => $e->getMessage(), 'error_type' => 'nsfw_blocked' ];
|
||||
} catch ( \Throwable $e ) {
|
||||
$results[ $key ] = [ 'error' => $e->getMessage() ];
|
||||
}
|
||||
}
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate the USD cost of a single generation call.
|
||||
*
|
||||
|
||||
@@ -23,11 +23,24 @@ class PRAutoBlogger_Cloudflare_Image_Support {
|
||||
* @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 ) {
|
||||
$stored = (string) get_option( 'prautoblogger_cloudflare_ai_token', '' );
|
||||
if ( '' === $stored ) {
|
||||
return '';
|
||||
}
|
||||
return (string) PRAutoBlogger_Encryption::decrypt( $encrypted );
|
||||
|
||||
// If the token is already encrypted (has "enc:" prefix), decrypt it.
|
||||
if ( PRAutoBlogger_Encryption::is_encrypted( $stored ) ) {
|
||||
return (string) PRAutoBlogger_Encryption::decrypt( $stored );
|
||||
}
|
||||
|
||||
// Legacy: token was saved as plaintext before the field was added to
|
||||
// the encrypted list in sanitize_field(). Encrypt it now so future
|
||||
// reads go through the normal path, then return the plaintext value.
|
||||
$cipher = PRAutoBlogger_Encryption::encrypt( $stored );
|
||||
if ( '' !== $cipher ) {
|
||||
update_option( 'prautoblogger_cloudflare_ai_token', $cipher );
|
||||
}
|
||||
return $stored;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,4 +111,62 @@ class PRAutoBlogger_Cloudflare_Image_Support {
|
||||
'cloudflare-image'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloudflare Workers AI error code for NSFW content rejection.
|
||||
*
|
||||
* Observed on FLUX.1 schnell on 2026-04-20 when backfilling the post
|
||||
* "Semaglutide vs Tirzepatide". Documented upstream as
|
||||
* `AiError: Input prompt contains NSFW content.`
|
||||
*/
|
||||
public const ERROR_CODE_NSFW = 3030;
|
||||
|
||||
/**
|
||||
* Return true when the Cloudflare response body contains the NSFW
|
||||
* content-filter error code (3030).
|
||||
*
|
||||
* The body shape is `{"errors":[{"code":3030,"message":"..."}],...}`.
|
||||
* Parsing is tolerant: any JSON decode failure or missing key returns
|
||||
* false, so a malformed 4xx never masquerades as NSFW.
|
||||
*
|
||||
* @param string $raw Raw response body from Cloudflare.
|
||||
* @return bool True when at least one error entry carries code 3030.
|
||||
*/
|
||||
public function is_nsfw_error( string $raw ): bool {
|
||||
$decoded = json_decode( $raw, true );
|
||||
if ( ! is_array( $decoded ) || empty( $decoded['errors'] ) || ! is_array( $decoded['errors'] ) ) {
|
||||
return false;
|
||||
}
|
||||
foreach ( $decoded['errors'] as $err ) {
|
||||
if ( is_array( $err ) && (int) ( $err['code'] ?? 0 ) === self::ERROR_CODE_NSFW ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw a typed NSFW exception when the response body signals CF's
|
||||
* content-filter rejection (code 3030). No-op otherwise.
|
||||
*
|
||||
* Centralised so callers stay at one line at the call site and so the
|
||||
* exception-construction shape can't drift between call sites.
|
||||
*
|
||||
* @param string $raw Raw response body.
|
||||
* @return void
|
||||
* @throws PRAutoBlogger_Image_NSFW_Blocked When the body contains code 3030.
|
||||
*/
|
||||
public function throw_if_nsfw( string $raw ): void {
|
||||
if ( ! $this->is_nsfw_error( $raw ) ) {
|
||||
return;
|
||||
}
|
||||
throw new PRAutoBlogger_Image_NSFW_Blocked(
|
||||
sprintf(
|
||||
/* translators: %s: truncated error body. */
|
||||
esc_html__( 'Cloudflare Workers AI rejected the prompt as NSFW: %s', 'prautoblogger' ),
|
||||
esc_html( substr( $raw, 0, 300 ) )
|
||||
),
|
||||
$raw
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,8 +40,14 @@ class PRAutoBlogger_Cloudflare_Image_Validator {
|
||||
);
|
||||
}
|
||||
|
||||
$encrypted = (string) get_option( 'prautoblogger_cloudflare_ai_token', '' );
|
||||
$api_token = '' === $encrypted ? '' : (string) PRAutoBlogger_Encryption::decrypt( $encrypted );
|
||||
$stored = (string) get_option( 'prautoblogger_cloudflare_ai_token', '' );
|
||||
$api_token = '';
|
||||
if ( '' !== $stored ) {
|
||||
// Handle legacy plaintext tokens (saved before the field was in the encrypted list).
|
||||
$api_token = PRAutoBlogger_Encryption::is_encrypted( $stored )
|
||||
? (string) PRAutoBlogger_Encryption::decrypt( $stored )
|
||||
: $stored;
|
||||
}
|
||||
if ( '' === $api_token ) {
|
||||
return $this->err(
|
||||
'token_empty',
|
||||
|
||||
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.
|
||||
*
|
||||
* Cloudflare Workers AI returns HTTP 400 with an `errors[].code === 3030`
|
||||
* body when its content filter blocks a prompt. We catch that shape in
|
||||
* the 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: PRAutoBlogger_Cloudflare_Image_Provider on NSFW 4xx.
|
||||
* Dependencies: None.
|
||||
*
|
||||
* @see providers/class-cloudflare-image-provider.php — Raises this.
|
||||
* @see core/class-image-nsfw-retry.php — Catches + rebuilds prompt.
|
||||
* @see https://developers.cloudflare.com/workers-ai/models/flux-1-schnell/ — Upstream error codes.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
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 Cloudflare 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-cloudflare-image-pricing.php — Sibling pattern for Cloudflare.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
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;
|
||||
|
||||
// Fall back to cached model list pricing.
|
||||
|
||||
@@ -36,7 +36,8 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
|
||||
* temperature?: float,
|
||||
* max_tokens?: int,
|
||||
* 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{
|
||||
* content: string,
|
||||
@@ -88,6 +89,16 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
|
||||
$body['response_format'] = $options['response_format'];
|
||||
}
|
||||
|
||||
// Reasoning mode: explicit caller override takes priority, then global setting.
|
||||
if ( isset( $options['reasoning'] ) ) {
|
||||
$body['reasoning'] = $options['reasoning'];
|
||||
} elseif ( '1' === get_option( 'prautoblogger_reasoning_enabled', '0' ) ) {
|
||||
$body['reasoning'] = [
|
||||
'enabled' => true,
|
||||
'effort' => get_option( 'prautoblogger_reasoning_effort', 'medium' ),
|
||||
];
|
||||
}
|
||||
|
||||
$last_error = '';
|
||||
|
||||
$config = new PRAutoBlogger_OpenRouter_Config();
|
||||
|
||||
@@ -43,28 +43,71 @@ class PRAutoBlogger_OpenRouter_Response_Parser {
|
||||
* 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 {
|
||||
if ( ! isset( $data['choices'][0]['message']['content'] ) ) {
|
||||
// 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(
|
||||
__( 'OpenRouter returned unexpected response format.', 'prautoblogger' )
|
||||
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' => $data['choices'][0]['message']['content'],
|
||||
'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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
196
includes/providers/class-reddit-availability.php
Normal file
196
includes/providers/class-reddit-availability.php
Normal file
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Reddit endpoint availability checker.
|
||||
*
|
||||
* Probes Reddit's RSS and .json endpoints to determine which data sources
|
||||
* are reachable from the current server. Datacenter IPs (Hostinger) are
|
||||
* often blocked by Reddit for .json but not RSS, so the client must check
|
||||
* both before deciding which strategy to use.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Reddit_JSON_Client::is_available() delegation,
|
||||
* Admin settings page "test connection" button.
|
||||
* Dependencies: wp_remote_get(), PRAutoBlogger_Logger.
|
||||
*
|
||||
* @see providers/class-reddit-json-client.php — Instantiates and delegates to this class.
|
||||
* @see ARCHITECTURE.md — External API integrations table.
|
||||
*/
|
||||
class PRAutoBlogger_Reddit_Availability {
|
||||
|
||||
/**
|
||||
* Reddit base URL for all endpoints.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private const BASE_URL = 'https://www.reddit.com';
|
||||
|
||||
/**
|
||||
* User agent — Reddit requires a descriptive UA for unauthenticated requests.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private const USER_AGENT = 'Mozilla/5.0 (compatible; PRAutoBlogger/0.2.1; +https://peptiderepo.com)';
|
||||
|
||||
/**
|
||||
* Transient key for rate limit tracking (.json endpoints only).
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private const RATE_LIMIT_TRANSIENT = 'prautoblogger_reddit_json_rate_limit';
|
||||
|
||||
/**
|
||||
* Check if any Reddit endpoint is reachable (RSS or .json).
|
||||
*
|
||||
* Side effects: HTTP request to Reddit.
|
||||
*
|
||||
* @return bool True if at least one endpoint is accessible.
|
||||
*/
|
||||
public function is_available(): bool {
|
||||
return $this->is_rss_available() || $this->is_json_available();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Reddit RSS endpoints are reachable.
|
||||
*
|
||||
* RSS endpoints are rarely blocked by Reddit, even for datacenter IPs.
|
||||
*
|
||||
* Side effects: One HTTP GET to Reddit.
|
||||
*
|
||||
* @return bool True if RSS endpoint returns HTTP 200.
|
||||
*/
|
||||
public function is_rss_available(): bool {
|
||||
$url = self::BASE_URL . '/r/all/hot.rss?limit=1';
|
||||
$response = wp_remote_get(
|
||||
$url,
|
||||
[
|
||||
'timeout' => 10,
|
||||
'headers' => [
|
||||
'User-Agent' => self::USER_AGENT,
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return 200 === wp_remote_retrieve_response_code( $response );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Reddit .json endpoints are reachable.
|
||||
*
|
||||
* Datacenter IPs are often blocked by Reddit for .json endpoints.
|
||||
*
|
||||
* Side effects: One HTTP GET to Reddit.
|
||||
*
|
||||
* @return bool True if .json endpoint returns HTTP 200.
|
||||
*/
|
||||
public function is_json_available(): bool {
|
||||
$url = self::BASE_URL . '/r/all/hot.json?limit=1';
|
||||
$response = wp_remote_get(
|
||||
$url,
|
||||
[
|
||||
'timeout' => 10,
|
||||
'headers' => [
|
||||
'User-Agent' => self::USER_AGENT,
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return 200 === wp_remote_retrieve_response_code( $response );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current rate limit status from cached .json response headers.
|
||||
*
|
||||
* Reddit .json endpoints return rate limit headers. RSS has no rate limits.
|
||||
* The rate data is written by api_get() and read here.
|
||||
*
|
||||
* @return array{remaining: int, limit: int, resets_at: string}
|
||||
*/
|
||||
public function get_rate_limit_status(): array {
|
||||
$status = get_transient( self::RATE_LIMIT_TRANSIENT );
|
||||
|
||||
if ( is_array( $status ) ) {
|
||||
return $status;
|
||||
}
|
||||
|
||||
return [
|
||||
'remaining' => 10,
|
||||
'limit' => 10,
|
||||
'resets_at' => '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an HTTP GET request to a Reddit .json endpoint.
|
||||
*
|
||||
* No authentication — just a plain GET with a descriptive User-Agent.
|
||||
* Tracks rate limits from response headers when available.
|
||||
*
|
||||
* Side effects: HTTP request, sets rate limit transient, logs errors.
|
||||
*
|
||||
* @param string $url Full URL with .json suffix and query params.
|
||||
*
|
||||
* @return array<string, mixed>|null Decoded JSON, or null on failure.
|
||||
*/
|
||||
public function api_get( string $url ): ?array {
|
||||
$response = wp_remote_get(
|
||||
$url,
|
||||
[
|
||||
'timeout' => 30,
|
||||
'headers' => [
|
||||
'User-Agent' => self::USER_AGENT,
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
'Reddit .json GET failed: ' . $response->get_error_message(),
|
||||
'reddit'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Track rate limits from response headers.
|
||||
$rate_remaining = wp_remote_retrieve_header( $response, 'x-ratelimit-remaining' );
|
||||
$rate_used = wp_remote_retrieve_header( $response, 'x-ratelimit-used' );
|
||||
$rate_reset = wp_remote_retrieve_header( $response, 'x-ratelimit-reset' );
|
||||
|
||||
if ( '' !== $rate_remaining ) {
|
||||
set_transient(
|
||||
self::RATE_LIMIT_TRANSIENT,
|
||||
[
|
||||
'remaining' => (int) $rate_remaining,
|
||||
'limit' => (int) $rate_used + (int) $rate_remaining,
|
||||
'resets_at' => gmdate( 'Y-m-d H:i:s', time() + (int) $rate_reset ),
|
||||
],
|
||||
120
|
||||
);
|
||||
}
|
||||
|
||||
$status_code = wp_remote_retrieve_response_code( $response );
|
||||
|
||||
if ( 200 !== $status_code ) {
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
sprintf(
|
||||
'Reddit .json HTTP %d: %s',
|
||||
$status_code,
|
||||
substr( wp_remote_retrieve_body( $response ), 0, 300 )
|
||||
),
|
||||
'reddit'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,12 @@ declare(strict_types=1);
|
||||
* for comment fetching (comments are not available in RSS feeds).
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Reddit_Provider::collect_data().
|
||||
* Dependencies: wp_remote_get(), PRAutoBlogger_Logger.
|
||||
* Dependencies: wp_remote_get(), PRAutoBlogger_Logger, PRAutoBlogger_Reddit_RSS_Parser.
|
||||
*
|
||||
* @see providers/class-reddit-provider.php — Instantiates and calls this class.
|
||||
* @see ARCHITECTURE.md — External API integrations table.
|
||||
* @see providers/class-reddit-provider.php — Instantiates and calls this class.
|
||||
* @see providers/class-reddit-rss-parser.php — Parses Atom XML into post arrays.
|
||||
* @see providers/class-reddit-availability.php — Endpoint health checks.
|
||||
* @see ARCHITECTURE.md — External API integrations table.
|
||||
*/
|
||||
class PRAutoBlogger_Reddit_JSON_Client {
|
||||
|
||||
@@ -39,13 +41,6 @@ class PRAutoBlogger_Reddit_JSON_Client {
|
||||
*/
|
||||
private const TIMEOUT_SECONDS = 30;
|
||||
|
||||
/**
|
||||
* Transient key for rate limit tracking (.json endpoints only).
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private const RATE_LIMIT_TRANSIENT = 'prautoblogger_reddit_json_rate_limit';
|
||||
|
||||
/**
|
||||
* Fetch hot posts from a subreddit. Tries RSS first, .json fallback.
|
||||
*
|
||||
@@ -129,7 +124,8 @@ class PRAutoBlogger_Reddit_JSON_Client {
|
||||
}
|
||||
|
||||
$body = wp_remote_retrieve_body( $response );
|
||||
return $this->parse_atom_feed( $body, $subreddit );
|
||||
$parser = new PRAutoBlogger_Reddit_RSS_Parser();
|
||||
return $parser->parse( $body, $subreddit );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,105 +169,6 @@ class PRAutoBlogger_Reddit_JSON_Client {
|
||||
return $posts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Reddit Atom feed into post-data arrays matching .json format.
|
||||
*
|
||||
* Maps Atom entry fields to the standard Reddit post fields so the rest
|
||||
* of the pipeline can consume RSS data without changes. Fields unavailable
|
||||
* in RSS (score, num_comments, upvote_ratio) are set to reasonable defaults.
|
||||
*
|
||||
* @param string $xml Raw Atom XML string.
|
||||
* @param string $subreddit The subreddit name for context.
|
||||
*
|
||||
* @return array<int, array<string, mixed>> Parsed post data.
|
||||
*/
|
||||
private function parse_atom_feed( string $xml, string $subreddit ): array {
|
||||
// Suppress XML errors for malformed feeds.
|
||||
$prev_errors = libxml_use_internal_errors( true );
|
||||
$feed = simplexml_load_string( $xml );
|
||||
libxml_use_internal_errors( $prev_errors );
|
||||
|
||||
if ( false === $feed ) {
|
||||
PRAutoBlogger_Logger::instance()->error( 'Failed to parse Reddit RSS XML.', 'reddit' );
|
||||
return [];
|
||||
}
|
||||
|
||||
// Register the Atom namespace.
|
||||
$feed->registerXPathNamespace( 'atom', 'http://www.w3.org/2005/Atom' );
|
||||
$entries = $feed->xpath( '//atom:entry' );
|
||||
|
||||
if ( empty( $entries ) ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$posts = [];
|
||||
foreach ( $entries as $entry ) {
|
||||
$title = (string) $entry->title;
|
||||
$link = '';
|
||||
$content = '';
|
||||
|
||||
// Get the HTML link (type="text/html").
|
||||
foreach ( $entry->link as $link_el ) {
|
||||
if ( 'text/html' === (string) $link_el['type'] || 'alternate' === (string) $link_el['rel'] ) {
|
||||
$link = (string) $link_el['href'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Content is in <content> tag as HTML.
|
||||
if ( isset( $entry->content ) ) {
|
||||
$content = wp_strip_all_tags( (string) $entry->content );
|
||||
}
|
||||
|
||||
// Extract post ID from the entry id (format: /r/subreddit/comments/ID/...).
|
||||
$entry_id = (string) $entry->id;
|
||||
$post_id = '';
|
||||
if ( preg_match( '#/comments/([a-z0-9]+)#', $entry_id, $m ) ) {
|
||||
$post_id = $m[1];
|
||||
}
|
||||
|
||||
// Extract author name.
|
||||
$author = '[deleted]';
|
||||
if ( isset( $entry->author->name ) ) {
|
||||
$author = str_replace( '/u/', '', (string) $entry->author->name );
|
||||
}
|
||||
|
||||
// Parse published date to Unix timestamp.
|
||||
$published = (string) ( $entry->published ?? $entry->updated ?? '' );
|
||||
$created_utc = '' !== $published ? (int) strtotime( $published ) : time();
|
||||
|
||||
// Extract permalink (relative path) from full URL.
|
||||
$permalink = '';
|
||||
if ( '' !== $link ) {
|
||||
$parsed = wp_parse_url( $link );
|
||||
$permalink = $parsed['path'] ?? '';
|
||||
}
|
||||
|
||||
$posts[] = [
|
||||
'id' => $post_id,
|
||||
'title' => $title,
|
||||
'selftext' => $content,
|
||||
'author' => $author,
|
||||
'score' => 1, // Not available in RSS.
|
||||
'num_comments' => 0, // Not available in RSS.
|
||||
'permalink' => $permalink,
|
||||
'created_utc' => $created_utc,
|
||||
'is_self' => true,
|
||||
'link_flair_text' => null,
|
||||
'upvote_ratio' => null,
|
||||
'is_original_content' => false,
|
||||
'data_source' => 'reddit_rss',
|
||||
];
|
||||
}
|
||||
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf( 'Parsed %d posts from Reddit RSS for r/%s.', count( $posts ), $subreddit ),
|
||||
'reddit'
|
||||
);
|
||||
|
||||
return $posts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch top comments for a specific post via Reddit's .json endpoint.
|
||||
*
|
||||
@@ -324,145 +221,47 @@ class PRAutoBlogger_Reddit_JSON_Client {
|
||||
* @return bool True if we got a valid response from either endpoint.
|
||||
*/
|
||||
public function is_available(): bool {
|
||||
return $this->is_rss_available() || $this->is_json_available();
|
||||
return ( new PRAutoBlogger_Reddit_Availability() )->is_available();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Reddit RSS endpoints are reachable.
|
||||
*
|
||||
* RSS endpoints are rarely blocked by Reddit, even for datacenter IPs.
|
||||
*
|
||||
* @return bool True if RSS endpoint returns HTTP 200.
|
||||
*/
|
||||
public function is_rss_available(): bool {
|
||||
$url = self::BASE_URL . '/r/all/hot.rss?limit=1';
|
||||
$response = wp_remote_get(
|
||||
$url,
|
||||
[
|
||||
'timeout' => 10,
|
||||
'headers' => [
|
||||
'User-Agent' => self::USER_AGENT,
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return 200 === wp_remote_retrieve_response_code( $response );
|
||||
return ( new PRAutoBlogger_Reddit_Availability() )->is_rss_available();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Reddit .json endpoints are reachable.
|
||||
*
|
||||
* Datacenter IPs are often blocked by Reddit for .json endpoints.
|
||||
*
|
||||
* @return bool True if .json endpoint returns HTTP 200.
|
||||
*/
|
||||
public function is_json_available(): bool {
|
||||
$url = self::BASE_URL . '/r/all/hot.json?limit=1';
|
||||
$response = wp_remote_get(
|
||||
$url,
|
||||
[
|
||||
'timeout' => 10,
|
||||
'headers' => [
|
||||
'User-Agent' => self::USER_AGENT,
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return 200 === wp_remote_retrieve_response_code( $response );
|
||||
return ( new PRAutoBlogger_Reddit_Availability() )->is_json_available();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current rate limit status.
|
||||
*
|
||||
* Reddit .json endpoints return rate limit headers. RSS has no rate limits.
|
||||
*
|
||||
* @return array{remaining: int, limit: int, resets_at: string}
|
||||
*/
|
||||
public function get_rate_limit_status(): array {
|
||||
$status = get_transient( self::RATE_LIMIT_TRANSIENT );
|
||||
|
||||
if ( is_array( $status ) ) {
|
||||
return $status;
|
||||
}
|
||||
|
||||
return [
|
||||
'remaining' => 10,
|
||||
'limit' => 10,
|
||||
'resets_at' => '',
|
||||
];
|
||||
return ( new PRAutoBlogger_Reddit_Availability() )->get_rate_limit_status();
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an HTTP GET request to a Reddit .json endpoint.
|
||||
*
|
||||
* No authentication — just a plain GET with a descriptive User-Agent.
|
||||
* Tracks rate limits from response headers when available.
|
||||
*
|
||||
* Side effects: HTTP request, sets rate limit transient, logs errors.
|
||||
* Delegates to PRAutoBlogger_Reddit_Availability which owns the HTTP
|
||||
* transport layer and rate-limit tracking.
|
||||
*
|
||||
* @param string $url Full URL with .json suffix and query params.
|
||||
*
|
||||
* @return array<string, mixed>|null Decoded JSON, or null on failure.
|
||||
*/
|
||||
private function api_get( string $url ): ?array {
|
||||
$response = wp_remote_get(
|
||||
$url,
|
||||
[
|
||||
'timeout' => self::TIMEOUT_SECONDS,
|
||||
'headers' => [
|
||||
'User-Agent' => self::USER_AGENT,
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
'Reddit .json GET failed: ' . $response->get_error_message(),
|
||||
'reddit'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Track rate limits from response headers.
|
||||
$rate_remaining = wp_remote_retrieve_header( $response, 'x-ratelimit-remaining' );
|
||||
$rate_used = wp_remote_retrieve_header( $response, 'x-ratelimit-used' );
|
||||
$rate_reset = wp_remote_retrieve_header( $response, 'x-ratelimit-reset' );
|
||||
|
||||
if ( '' !== $rate_remaining ) {
|
||||
set_transient(
|
||||
self::RATE_LIMIT_TRANSIENT,
|
||||
[
|
||||
'remaining' => (int) $rate_remaining,
|
||||
'limit' => (int) $rate_used + (int) $rate_remaining,
|
||||
'resets_at' => gmdate( 'Y-m-d H:i:s', time() + (int) $rate_reset ),
|
||||
],
|
||||
120
|
||||
);
|
||||
}
|
||||
|
||||
$status_code = wp_remote_retrieve_response_code( $response );
|
||||
|
||||
if ( 200 !== $status_code ) {
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
sprintf(
|
||||
'Reddit .json HTTP %d: %s',
|
||||
$status_code,
|
||||
substr( wp_remote_retrieve_body( $response ), 0, 300 )
|
||||
),
|
||||
'reddit'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
return ( new PRAutoBlogger_Reddit_Availability() )->api_get( $url );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,10 +128,11 @@ class PRAutoBlogger_Reddit_Provider implements PRAutoBlogger_Source_Provider_Int
|
||||
sprintf( 'Reddit r/%s: fetched %d items', $subreddit, $added ),
|
||||
'reddit'
|
||||
);
|
||||
} catch ( \Exception $e ) {
|
||||
} catch ( \Throwable $e ) {
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
sprintf(
|
||||
'Reddit: Failed to collect from r/%s: %s',
|
||||
'Reddit: %s collecting from r/%s: %s',
|
||||
get_class( $e ),
|
||||
$subreddit,
|
||||
$e->getMessage()
|
||||
),
|
||||
|
||||
137
includes/providers/class-reddit-rss-parser.php
Normal file
137
includes/providers/class-reddit-rss-parser.php
Normal file
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Reddit RSS/Atom feed parser — parses Atom XML into normalized post arrays.
|
||||
*
|
||||
* Handles parsing of Reddit's Atom/RSS feeds and mapping them to the standard
|
||||
* Reddit post data format (same shape as .json responses). This parser is
|
||||
* self-contained: it takes raw XML and produces normalized arrays.
|
||||
*
|
||||
* Fields unavailable in RSS feeds (score, num_comments, upvote_ratio) are set
|
||||
* to sensible defaults so the rest of the pipeline can treat RSS and .json data uniformly.
|
||||
*
|
||||
* Usage: Called by PRAutoBlogger_Reddit_JSON_Client::fetch_posts_via_rss()
|
||||
* to parse the response body.
|
||||
*
|
||||
* Dependencies: wp_strip_all_tags(), PRAutoBlogger_Logger, built-in SimpleXML.
|
||||
*
|
||||
* @see providers/class-reddit-json-client.php — Main client that calls this parser.
|
||||
* @see ARCHITECTURE.md — External API integrations table.
|
||||
*/
|
||||
class PRAutoBlogger_Reddit_RSS_Parser {
|
||||
|
||||
/**
|
||||
* Parse a Reddit Atom feed into post-data arrays matching .json format.
|
||||
*
|
||||
* Maps Atom entry fields to the standard Reddit post fields so the rest
|
||||
* of the pipeline can consume RSS data without changes. Fields unavailable
|
||||
* in RSS (score, num_comments, upvote_ratio) are set to reasonable defaults.
|
||||
*
|
||||
* @param string $xml Raw Atom XML string.
|
||||
* @param string $subreddit The subreddit name for context (logging only).
|
||||
*
|
||||
* @return array<int, array<string, mixed>> Parsed post data.
|
||||
*/
|
||||
public function parse( string $xml, string $subreddit ): array {
|
||||
// Suppress XML errors for malformed feeds.
|
||||
$prev_errors = libxml_use_internal_errors( true );
|
||||
$feed = simplexml_load_string( $xml );
|
||||
libxml_use_internal_errors( $prev_errors );
|
||||
|
||||
if ( false === $feed ) {
|
||||
PRAutoBlogger_Logger::instance()->error( 'Failed to parse Reddit RSS XML.', 'reddit' );
|
||||
return [];
|
||||
}
|
||||
|
||||
// Register the Atom namespace.
|
||||
$feed->registerXPathNamespace( 'atom', 'http://www.w3.org/2005/Atom' );
|
||||
$entries = $feed->xpath( '//atom:entry' );
|
||||
|
||||
if ( empty( $entries ) ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$posts = [];
|
||||
foreach ( $entries as $entry ) {
|
||||
$post = $this->parse_entry( $entry, $subreddit );
|
||||
if ( null !== $post ) {
|
||||
$posts[] = $post;
|
||||
}
|
||||
}
|
||||
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf( 'Parsed %d posts from Reddit RSS for r/%s.', count( $posts ), $subreddit ),
|
||||
'reddit'
|
||||
);
|
||||
|
||||
return $posts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single Atom entry element into a normalized post array.
|
||||
*
|
||||
* @param \SimpleXMLElement $entry An Atom entry element.
|
||||
* @param string $subreddit The subreddit name (for context/logging).
|
||||
*
|
||||
* @return array<string, mixed>|null The parsed post data, or null if parsing fails.
|
||||
*/
|
||||
private function parse_entry( $entry, string $subreddit ): ?array {
|
||||
$title = (string) $entry->title;
|
||||
$link = '';
|
||||
$content = '';
|
||||
|
||||
// Get the HTML link (type="text/html").
|
||||
foreach ( $entry->link as $link_el ) {
|
||||
if ( 'text/html' === (string) $link_el['type'] || 'alternate' === (string) $link_el['rel'] ) {
|
||||
$link = (string) $link_el['href'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Content is in <content> tag as HTML.
|
||||
if ( isset( $entry->content ) ) {
|
||||
$content = wp_strip_all_tags( (string) $entry->content );
|
||||
}
|
||||
|
||||
// Extract post ID from the entry id (format: /r/subreddit/comments/ID/...).
|
||||
$entry_id = (string) $entry->id;
|
||||
$post_id = '';
|
||||
if ( preg_match( '#/comments/([a-z0-9]+)#', $entry_id, $m ) ) {
|
||||
$post_id = $m[1];
|
||||
}
|
||||
|
||||
// Extract author name.
|
||||
$author = '[deleted]';
|
||||
if ( isset( $entry->author->name ) ) {
|
||||
$author = str_replace( '/u/', '', (string) $entry->author->name );
|
||||
}
|
||||
|
||||
// Parse published date to Unix timestamp.
|
||||
$published = (string) ( $entry->published ?? $entry->updated ?? '' );
|
||||
$created_utc = '' !== $published ? (int) strtotime( $published ) : time();
|
||||
|
||||
// Extract permalink (relative path) from full URL.
|
||||
$permalink = '';
|
||||
if ( '' !== $link ) {
|
||||
$parsed = wp_parse_url( $link );
|
||||
$permalink = $parsed['path'] ?? '';
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $post_id,
|
||||
'title' => $title,
|
||||
'selftext' => $content,
|
||||
'author' => $author,
|
||||
'score' => 1, // Not available in RSS.
|
||||
'num_comments' => 0, // Not available in RSS.
|
||||
'permalink' => $permalink,
|
||||
'created_utc' => $created_utc,
|
||||
'is_self' => true,
|
||||
'link_flair_text' => null,
|
||||
'upvote_ratio' => null,
|
||||
'is_original_content' => false,
|
||||
'data_source' => 'reddit_rss',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ declare(strict_types=1);
|
||||
* content generation run, after editorial review, before publish.
|
||||
* 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-cloudflare-image-provider.php — Cloudflare Workers AI implementation.
|
||||
* @see interface-llm-provider.php — Parallel pattern for LLM providers.
|
||||
* @see ARCHITECTURE.md — External API Integrations table.
|
||||
*/
|
||||
@@ -83,6 +83,37 @@ interface PRAutoBlogger_Image_Provider_Interface {
|
||||
*/
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -11,7 +11,7 @@ declare(strict_types=1);
|
||||
* Triggered by: PRAutoBlogger_OpenRouter_Model_Registry::refresh().
|
||||
* 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 ARCHITECTURE.md — Capability vocabulary.
|
||||
*/
|
||||
@@ -17,7 +17,7 @@ declare(strict_types=1);
|
||||
* PRAutoBlogger_OpenRouter_Model_Normalizer.
|
||||
*
|
||||
* @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 ARCHITECTURE.md — AI Model Registries section.
|
||||
*/
|
||||
@@ -4,8 +4,8 @@ declare(strict_types=1);
|
||||
/**
|
||||
* Contract for a provider-specific AI model registry.
|
||||
*
|
||||
* Phase 1 (v1): single implementation for OpenRouter (`class-openrouter-model-registry.php`).
|
||||
* Phase 3: a second implementation wraps Cloudflare Workers AI FLUX model data. A
|
||||
* Phase 1 (v1): single implementation for OpenRouter (`class-open-router-model-registry.php`).
|
||||
* Phase 3: a second implementation wraps Cloudflare Workers AI model data. A
|
||||
* higher-level dispatcher iterates registered registries, calls each with the same
|
||||
* capability, and unions the results — the per-registry interface stays unchanged.
|
||||
*
|
||||
@@ -19,7 +19,7 @@ declare(strict_types=1);
|
||||
* (commit 3), daily cron via the main plugin loader.
|
||||
* 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 ARCHITECTURE.md — AI Model Registries section.
|
||||
*/
|
||||
|
||||
@@ -8,7 +8,7 @@ declare(strict_types=1);
|
||||
* Plugin Name: PRAutoBlogger
|
||||
* Plugin URI: https://peptiderepo.com/prautoblogger
|
||||
* Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics.
|
||||
* Version: 0.2.3
|
||||
* Version: 0.8.0
|
||||
* Requires at least: 6.0
|
||||
* Requires PHP: 7.4
|
||||
* Author: PeptideRepo
|
||||
@@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
| and limits without magic strings.
|
||||
*/
|
||||
|
||||
define( 'PRAUTOBLOGGER_VERSION', '0.2.3' );
|
||||
define( 'PRAUTOBLOGGER_VERSION', '0.8.0' );
|
||||
define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' );
|
||||
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __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.
|
||||
define( 'PRAUTOBLOGGER_MAX_RETRIES', 3 );
|
||||
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 );
|
||||
|
||||
// Default models for OpenRouter (user can override in settings).
|
||||
@@ -53,17 +53,20 @@ 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_EDITOR_MODEL', 'google/gemini-2.5-flash-lite' );
|
||||
|
||||
// Image generation defaults (Cloudflare Workers AI, FLUX family).
|
||||
// Short alias; the Cloudflare image pricing helper expands it to the full
|
||||
// `@cf/black-forest-labs/...` model id when building the Workers AI URL.
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL', 'flux-1-schnell' );
|
||||
// Image generation defaults.
|
||||
// Default provider is OpenRouter; Cloudflare is the fallback.
|
||||
// Default model is Gemini 2.5 Flash Image ($0.005/image, good for comics).
|
||||
// User can override via Settings → Images → Image Model.
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER', 'openrouter' );
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL', 'google/gemini-2.5-flash-image' );
|
||||
|
||||
// CEO-approved image style suffix (decision locked 2026-04-15). Users may
|
||||
// override via Settings → Images → Style Suffix; this constant is the
|
||||
// fallback the setting reads on first install.
|
||||
// Default style suffix appended to every image-gen prompt. Updated
|
||||
// 2026-04-18 v4: single-panel newspaper comic style. Caption is now
|
||||
// inserted as HTML text below the image, NOT rendered inside it.
|
||||
// Users may override via Settings → Images → Style Suffix.
|
||||
define(
|
||||
'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>
|
||||
@@ -21,6 +21,9 @@ class IdeaScorerTest extends BaseTestCase {
|
||||
$this->stub_get_option( [
|
||||
'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 );
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ) );
|
||||
} );
|
||||
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( '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.
|
||||
*
|
||||
* Pipeline now calls generate_image_batch() with keyed requests.
|
||||
*/
|
||||
public function test_generate_and_attach_images_returns_correct_structure(): void {
|
||||
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
|
||||
$provider->method( 'estimate_cost' )->willReturn( 0.05 );
|
||||
$provider->method( 'generate_image' )->willReturn( [
|
||||
$image_result = [
|
||||
'bytes' => 'fake_image_data',
|
||||
'mime_type' => 'image/png',
|
||||
'width' => 1200,
|
||||
'height' => 630,
|
||||
'model' => 'flux-1-schnell',
|
||||
'model' => 'test-model',
|
||||
'seed' => 123,
|
||||
'cost_usd' => 0.05,
|
||||
'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 );
|
||||
@@ -83,7 +97,6 @@ class ImagePipelineTest extends BaseTestCase {
|
||||
|
||||
$pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker );
|
||||
|
||||
// Mock file writing for sideload.
|
||||
Functions\when( 'get_temp_dir' )->justReturn( '/tmp/' );
|
||||
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 {
|
||||
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
|
||||
$provider->method( 'estimate_cost' )->willReturn( 0.05 );
|
||||
$provider->method( 'generate_image' )->willReturn( [
|
||||
$image_result = [
|
||||
'bytes' => 'fake_image_data',
|
||||
'mime_type' => 'image/png',
|
||||
'width' => 1200,
|
||||
'height' => 630,
|
||||
'model' => 'flux-1-schnell',
|
||||
'model' => 'test-model',
|
||||
'cost_usd' => 0.05,
|
||||
'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 );
|
||||
@@ -126,17 +144,60 @@ class ImagePipelineTest extends BaseTestCase {
|
||||
[ 'title' => 'Source' ]
|
||||
);
|
||||
|
||||
// Cost should be accumulated from both images.
|
||||
$this->assertGreaterThan( 0.05, $result['cost_usd'] );
|
||||
// Cost should be accumulated from both images ($0.05 × 2 = $0.10).
|
||||
$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->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->method( 'would_exceed_budget' )->willReturn( false );
|
||||
@@ -145,9 +206,185 @@ class ImagePipelineTest extends BaseTestCase {
|
||||
|
||||
$result = $pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test' ] );
|
||||
|
||||
// Should record error but return valid structure.
|
||||
$this->assertIsArray( $result );
|
||||
$this->assertNotEmpty( $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'] ) );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,21 @@ class ImagePromptBuilderTest extends BaseTestCase {
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 and content.
|
||||
*/
|
||||
@@ -119,4 +134,105 @@ class ImagePromptBuilderTest extends BaseTestCase {
|
||||
// Prompt should be under 500 chars (concept + style suffix).
|
||||
$this->assertLessThan( 500, strlen( $prompt ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'] ?? ''
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ class ContentRequestTest extends BaseTestCase {
|
||||
1000,
|
||||
2500,
|
||||
'Technology and innovation',
|
||||
[ 'politics', 'religion' ]
|
||||
[ 'politics', 'religion' ],
|
||||
'Always open with a hook question.'
|
||||
);
|
||||
|
||||
$this->assertSame( $idea, $request->get_idea() );
|
||||
@@ -35,6 +36,7 @@ class ContentRequestTest extends BaseTestCase {
|
||||
$this->assertSame( 2500, $request->get_max_word_count() );
|
||||
$this->assertSame( 'Technology and innovation', $request->get_niche_description() );
|
||||
$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() );
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@@ -189,6 +189,57 @@ class CloudflareImageProviderTest extends BaseTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A 400 carrying Cloudflare's NSFW code (3030) must throw the typed
|
||||
* PRAutoBlogger_Image_NSFW_Blocked exception, not a generic
|
||||
* RuntimeException. Image_NSFW_Retry keys off the typed exception to
|
||||
* decide whether to rebuild the prompt and retry.
|
||||
*/
|
||||
public function test_generate_image_throws_nsfw_blocked_on_code_3030(): void {
|
||||
$this->with_token( 'test-token' );
|
||||
|
||||
$nsfw_body = '{"errors":[{"message":"AiError: Input prompt contains NSFW content.","code":3030}]}';
|
||||
|
||||
Functions\when( 'wp_remote_post' )->justReturn( [
|
||||
'body' => $nsfw_body,
|
||||
'response' => [ 'code' => 400 ],
|
||||
'headers' => [],
|
||||
] );
|
||||
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 400 );
|
||||
Functions\when( 'wp_remote_retrieve_body' )->justReturn( $nsfw_body );
|
||||
|
||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
||||
$this->expectException( \PRAutoBlogger_Image_NSFW_Blocked::class );
|
||||
$provider->generate_image( 'some peptide prompt', 1080, 1080 );
|
||||
}
|
||||
|
||||
/**
|
||||
* A non-3030 4xx (e.g. 401 bad token) must NOT be tagged NSFW — the
|
||||
* retry path would be wasted work there.
|
||||
*/
|
||||
public function test_generate_image_batch_tags_nsfw_errors_distinctly(): void {
|
||||
$this->with_token( 'test-token' );
|
||||
|
||||
$nsfw_body = '{"errors":[{"message":"AiError: Input prompt contains NSFW content.","code":3030}]}';
|
||||
|
||||
Functions\when( 'wp_remote_post' )->justReturn( [
|
||||
'body' => $nsfw_body,
|
||||
'response' => [ 'code' => 400 ],
|
||||
'headers' => [],
|
||||
] );
|
||||
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 400 );
|
||||
Functions\when( 'wp_remote_retrieve_body' )->justReturn( $nsfw_body );
|
||||
|
||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
||||
$results = $provider->generate_image_batch( [
|
||||
'image_a' => [ 'prompt' => 'some prompt', 'width' => 1080, 'height' => 1080 ],
|
||||
] );
|
||||
|
||||
$this->assertArrayHasKey( 'image_a', $results );
|
||||
$this->assertSame( 'nsfw_blocked', $results['image_a']['error_type'] ?? null );
|
||||
$this->assertArrayHasKey( 'error', $results['image_a'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Response shape must be one of: image/* bytes OR JSON envelope with
|
||||
* base64 image. Anything else is a contract breach — fail loudly.
|
||||
|
||||
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 );
|
||||
}
|
||||
}
|
||||
69
tests/unit/Services/ImageModelRegistryTest.php
Normal file
69
tests/unit/Services/ImageModelRegistryTest.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?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 'openrouter' or 'cloudflare'. 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'],
|
||||
[ 'openrouter', 'cloudflare' ],
|
||||
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(
|
||||
'cloudflare',
|
||||
\PRAutoBlogger_Image_Model_Registry::provider_for( 'flux-1-schnell' )
|
||||
);
|
||||
$this->assertSame(
|
||||
'openrouter',
|
||||
\PRAutoBlogger_Image_Model_Registry::provider_for( 'google/gemini-2.5-flash-image' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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