Compare commits
2 Commits
feature/v0
...
claude/ima
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b071c27780 | ||
|
|
84feb5e98b |
40
.github/workflows/deploy.yml
vendored
40
.github/workflows/deploy.yml
vendored
@@ -6,7 +6,7 @@ on:
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: PHP Lint Gate
|
||||
name: Validate Code Quality
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -17,17 +17,37 @@ jobs:
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.1'
|
||||
tools: composer, phpcs
|
||||
coverage: none
|
||||
|
||||
- name: PHP Lint Check
|
||||
run: |
|
||||
set -e
|
||||
find . -name '*.php' \
|
||||
-not -path './vendor/*' \
|
||||
-not -path './node_modules/*' \
|
||||
-not -path './.git/*' \
|
||||
-not -path './.github/*' \
|
||||
-print0 | xargs -0 -n1 -P4 php -l > /dev/null
|
||||
find . -name "*.php" -not -path "./vendor/*" -not -path "./node_modules/*" -exec php -l {} \; | (grep -v "No syntax errors" || true) | head -20
|
||||
test ${PIPESTATUS[0]} -eq 0
|
||||
|
||||
- name: Install Composer dependencies
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
|
||||
- name: Install WordPress Coding Standards
|
||||
run: |
|
||||
# Composer 2.2+ blocks plugins by default. The WPCS installer needs
|
||||
# dealerdirect/phpcodesniffer-composer-installer to wire standards
|
||||
# into PHPCS. Allow it globally before requiring WPCS.
|
||||
composer global config --no-plugins allow-plugins.dealerdirect/phpcodesniffer-composer-installer true
|
||||
composer global require "wp-coding-standards/wpcs:^3.0" "phpcsstandards/phpcsutils:^1.0" --no-interaction
|
||||
phpcs --config-set installed_paths "$(composer global config home)/vendor/wp-coding-standards/wpcs"
|
||||
|
||||
- name: Run PHPCS (WordPress standards)
|
||||
run: |
|
||||
phpcs --standard=WordPress-Core \
|
||||
--extensions=php \
|
||||
--ignore=vendor/*,tests/*,node_modules/* \
|
||||
--warning-severity=0 \
|
||||
--report=summary \
|
||||
includes/ prautoblogger.php uninstall.php || true
|
||||
|
||||
- name: Run PHPUnit
|
||||
run: vendor/bin/phpunit --testdox
|
||||
|
||||
deploy:
|
||||
needs: validate
|
||||
@@ -65,9 +85,7 @@ jobs:
|
||||
run: |
|
||||
ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
|
||||
${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_HOST }} \
|
||||
"cd ~/domains/peptiderepo.com/public_html && \
|
||||
wp litespeed-purge all 2>/dev/null || \
|
||||
(find wp-content/litespeed/ -type f -delete 2>/dev/null; touch .lscache_purge 2>/dev/null) || true"
|
||||
"cd ~/domains/peptiderepo.com/public_html && find wp-content/litespeed/ -type f -delete 2>/dev/null; touch .lscache_purge 2>/dev/null || true"
|
||||
|
||||
- name: Health check
|
||||
run: |
|
||||
|
||||
142
ARCHITECTURE.md
142
ARCHITECTURE.md
@@ -16,10 +16,9 @@ PRAutoBlogger is a WordPress plugin that monitors social media (starting with Re
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ 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
|
||||
│ 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
|
||||
└────────────┬────────────┘
|
||||
│
|
||||
▼
|
||||
@@ -66,16 +65,10 @@ PRAutoBlogger is a WordPress plugin that monitors social media (starting with Re
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ 6b. Image Pipeline │ Generates two A/B images in parallel:
|
||||
│ 6b. Image Pipeline │ (commit 1b) Generates two A/B images:
|
||||
│ (optional, if enabled) │ - Image A: article-driven (featured)
|
||||
│ │ - Image B: source-driven (post meta)
|
||||
│ │ 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.
|
||||
│ │ Sideloads to media library, logs costs
|
||||
└────────────┬────────────┘
|
||||
│
|
||||
▼
|
||||
@@ -111,24 +104,18 @@ prautoblogger/
|
||||
│ └── posts-widget.js # React component for frontend post cards (wp.element)
|
||||
│
|
||||
├── includes/
|
||||
│ ├── class-prautoblogger.php # Main orchestrator — registers all hooks, delegates execution
|
||||
│ ├── class-executor.php # Cron handlers, generation AJAX (start + status), model registry
|
||||
│ ├── class-ajax-handlers.php # Non-generation AJAX: images, models, test connections
|
||||
│ ├── class-generation-lock.php # DB-level atomic mutex for single-writer generation
|
||||
│ ├── class-prautoblogger.php # Main orchestrator — registers all hooks via loader
|
||||
│ ├── 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: 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-settings-fields.php # Declarative settings definitions (sections + fields)
|
||||
│ │ ├── 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
|
||||
│ │
|
||||
@@ -136,55 +123,40 @@ 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-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-idea-scorer.php # Ranks and deduplicates article ideas
|
||||
│ │ ├── 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-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-pipeline.php # Orchestrates A/B image generation (commit 1b)
|
||||
│ │ ├── 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-article-worker.php # Single-article generation (content + edit + publish)
|
||||
│ │ ├── class-pipeline-runner.php # Orchestrates pipeline; chains per-article cron jobs
|
||||
│ │ ├── class-pipeline-runner.php # Orchestrates the full generation pipeline
|
||||
│ │ ├── 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 (chat completions)
|
||||
│ │ ├── class-open-router-embedding-provider.php # Text embeddings for semantic dedup
|
||||
│ │ ├── class-open-router-provider.php # OpenRouter API implementation
|
||||
│ │ ├── 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 (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-runware-image-provider.php # Runware FLUX.1 (default v0.9.0+)
|
||||
│ │ ├── class-runware-image-pricing.php # FLUX schnell/dev cost table + resolver
|
||||
│ │ ├── class-runware-image-support.php # Key, response parsing, retry, dimension snap
|
||||
│ │ ├── class-runware-image-batch.php # True parallel curl_multi batch dispatcher
|
||||
│ │ ├── 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
|
||||
│ │ └── (new providers go here — see CONVENTIONS.md)
|
||||
│ │
|
||||
│ ├── services/
|
||||
│ │ ├── interface-model-registry.php # Contract for provider-specific model registries
|
||||
│ │ ├── 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-runware-model-registry.php)
|
||||
│ │ ├── class-openrouter-model-registry.php # Fetches, caches, queries the OpenRouter model list
|
||||
│ │ ├── class-openrouter-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/
|
||||
@@ -260,53 +232,6 @@ 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
|
||||
@@ -435,14 +360,9 @@ All prefixed with `prautoblogger_`:
|
||||
| `prautoblogger_log_level` | Logging threshold: error/warning/info/debug |
|
||||
| `prautoblogger_db_version` | Schema version for migrations |
|
||||
| `prautoblogger_schedule_time` | Daily generation time (HH:MM, default: '03:00') |
|
||||
| `prautoblogger_image_model` | Image model slug from `Image_Model_Registry::get_models()`; provider is derived from this on save |
|
||||
| `prautoblogger_image_provider` | Derived from the chosen model on save (v0.8.0+); not editable in admin UI |
|
||||
| `prautoblogger_image_prompt_instructions` | System prompt given to the image rewriter LLM (v0.8.0+); falls back to `Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT` when empty |
|
||||
| `prautoblogger_image_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_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_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 |
|
||||
@@ -476,7 +396,7 @@ Stored on every PRAutoBlogger-generated post:
|
||||
| Reddit RSS | Primary Reddit data source — Atom feeds for subreddit hot posts | None (unauthenticated) | No known rate limit; reliable from datacenter IPs | `providers/class-reddit-json-client.php` |
|
||||
| Reddit .json | Fallback for posts + only source for comments | None (unauthenticated) | ~10 req/min (datacenter IPs often blocked) | `providers/class-reddit-json-client.php` |
|
||||
| Google Analytics 4 | Post performance metrics | OAuth2 service account | Standard GA4 limits | `core/class-ga4-client.php`, `core/class-metrics-collector.php` |
|
||||
| Runware (FLUX.1 via runware.ai) | **Default** image backend (v0.9.0+): schnell ~$0.0006/image, dev ~$0.02/image. True parallel generation via curl_multi. | API key (encrypted in wp_options) | Account-level quotas | `providers/class-runware-image-provider.php`, `providers/class-runware-image-pricing.php`, `providers/class-runware-image-support.php`, `providers/class-runware-image-batch.php` |
|
||||
| Cloudflare Workers AI | Image generation (FLUX.1 schnell / dev) for article hero, thumbnail, and IG placements | API token (encrypted in wp_options) + account ID | Workers AI per-account quotas | `providers/class-cloudflare-image-provider.php`, `providers/class-cloudflare-image-pricing.php`, `providers/class-cloudflare-image-validator.php` |
|
||||
|
||||
---
|
||||
|
||||
@@ -519,25 +439,17 @@ The posts widget uses WordPress-bundled React (`wp.element`) with raw `createEle
|
||||
The widget fetches posts asynchronously from a dedicated REST endpoint rather than rendering server-side. This keeps initial page load fast and allows the endpoint to be cached independently (5-minute Cache-Control). The permission callback uses a filter (`prautoblogger_rest_posts_public`) so sites can restrict access if needed.
|
||||
|
||||
### #13: Run-ID based log linking
|
||||
Each pipeline execution generates a UUID (`run_id`) that tags every `prab_generation_log` entry. When a post is published, `link_generation_logs()` uses `UPDATE WHERE run_id = X` to associate entries with the `post_id`. This is more reliable than the previous timestamp-window approach, especially in batch runs where multiple posts are generated in quick succession. **v0.8.1+**: `run_id` is also persisted on each post as `_prautoblogger_run_id` post_meta, giving the orphan-research reaper a direct path from run to siblings without walking the gen_log table (see #19).
|
||||
|
||||
### #19: Daily orphan-research-row reaper (v0.8.1)
|
||||
When a pipeline dies between post creation and the final `Post_Assembler::amortize_research_costs()` call (Hostinger exec kill, OOM, LiteSpeed cut, fatal), the shared `llm_research` cost row stays in `prab_generation_log` with `post_id = NULL` and sibling articles understate their true cost in the breakdown popover. A new daily WP-Cron event `prautoblogger_reap_orphan_research_rows` (hooked to `PRAutoBlogger_Research_Reaper::on_cron`, 03:15 server time — 15 min after the primary generation cron) scans for orphan rows older than a 1-hour grace window, looks up sibling posts by `_prautoblogger_run_id` post_meta (fallback: distinct `post_id`s in gen_log for the same run_id), and delegates to the existing idempotent `amortize_research_costs()` on match. Orphans that still have no siblings after 7 days are deleted outright as sunk cost. A one-time activator migration (`prautoblogger_migrated_run_id_meta_v081`) back-populates `_prautoblogger_run_id` on existing posts from gen_log so the meta-primary path works on upgrade. Manual ops trigger: `wp prautoblogger reap-research`.
|
||||
Each pipeline execution generates a UUID (`run_id`) that tags every `prab_generation_log` entry. When a post is published, `link_generation_logs()` uses `UPDATE WHERE run_id = X` to associate entries with the `post_id`. This is more reliable than the previous timestamp-window approach, especially in batch runs where multiple posts are generated in quick succession.
|
||||
|
||||
### #14: Reddit RSS replaces PullPush.io (and earlier Reddit OAuth)
|
||||
Reddit rejected our OAuth API application (April 2026). We initially switched to PullPush.io, but its index was frequently stale or unavailable. Reddit's RSS/Atom feeds (`/r/{sub}/hot.rss`) proved the most reliable option — they work from datacenter IPs where .json gets 403, require no auth, and have no apparent rate limit. The .json endpoints are kept as a fallback for posts and as the sole source for comment data. Each collected item's metadata includes a `data_source` field (`reddit_rss` or `reddit_json`) for auditability.
|
||||
|
||||
### #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-open-router-model-registry.php`) takes all config via constructor (option name, transient name, endpoint URL) — no PRAUTOBLOGGER_* constants inside the class body. Phase 2 lifts it into a shared Composer package with zero internal edits. Phase 3 adds a parallel `Cloudflare_WorkersAI_Model_Registry` behind the same interface. Capability vocabulary: `text→text`, `text+image→text`, `text+audio→text`, `text→image`, `text→audio`, `text→video`, `text→embedding`. Cost: $0/month.
|
||||
|
||||
### #17: Runware as default image backend (v0.9.0, Apr 2026)
|
||||
|
||||
After a comic-style A/B round in Apr 2026, FLUX.1 schnell via Runware was chosen as the default image backend. Cost is ~$0.0006/image vs ~$0.039/image for Gemini 2.5 Flash Image via OpenRouter (≈65× cheaper), and the looser schnell fidelity reads as editorial-cartoon style — a feature, not a bug, for our single-panel comic aesthetic. FLUX.1 dev (~$0.02/image, 28 steps) remains available as an opt-in for higher-fidelity runs. The Runware layer mirrors the OpenRouter split (interface + provider + support + pricing + batch), all under the 300-line cap. The batch class dispatches `imageInference` POSTs via `curl_multi_init/exec/select` — wall-clock time for the A/B pair drops to ≈ the slowest single image. A v0.9.0 one-time migration (`prautoblogger_migrated_default_image_v090`) flips sites still on the legacy default; explicit user selections are preserved. Cloudflare Workers AI was removed as an image provider in v0.10.0 (see ADR #16 note below).
|
||||
|
||||
### #16: Image generation via Cloudflare Workers AI (FLUX.1) — SUPERSEDED v0.10.0
|
||||
Kept here for history. Until v0.8.2 the default image backend was FLUX.1 schnell on Cloudflare Workers AI, routed via the Cloudflare AI Gateway for caching + unified telemetry. In v0.9.0 (2026-04-21) Runware FLUX.1 schnell became the default (65× cheaper, see ADR #17). In v0.10.0 (2026-04-21) the Cloudflare Workers AI provider was removed entirely — classes, settings, tests, and registry entries are gone; any CF user is migrated to `runware:100@1` on upgrade via `PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run()`. The Cloudflare AI Gateway in front of OpenRouter (ADR #15) is unaffected — it is a distinct integration.
|
||||
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.
|
||||
|
||||
### #16: Image generation via Cloudflare Workers AI (FLUX.1), direct (not via AI Gateway)
|
||||
The image-generation layer (started 2026-04-15, tracked in `convo/prautoblogger/threads/2026-04-image-pipeline/`) uses FLUX.1 [schnell] on Cloudflare Workers AI as its default model, chosen for its ~$0.0011/MP cost and 2–3 sec latency. FLUX.1 [dev] is a ~4× cost upgrade exposed as a dropdown for specific posts that warrant it. Calls go directly to `https://api.cloudflare.com/client/v4/accounts/{id}/ai/run/@cf/black-forest-labs/...`, *not* through the Cloudflare AI Gateway that we use for OpenRouter — the gateway route to Workers AI currently 403s (pre-existing open issue). The provider lives behind `PRAutoBlogger_Image_Provider_Interface`, so the decision is trivially reversible: switching to a different image API (DALL-E, Replicate, a future AI Gateway route) is a new class implementation plus a one-line swap in the pipeline wiring. Trade-off: we run two different Cloudflare integration paths (gateway for OpenRouter LLMs, direct for Workers AI images) until the gateway route stabilizes — minor cognitive overhead, zero functional downside.
|
||||
|
||||
### #15: Optional Cloudflare AI Gateway in front of OpenRouter
|
||||
We already use Cloudflare for DNS/CDN on peptiderepo.com, so layering AI Gateway in front of OpenRouter is zero marginal infrastructure. It gives us response caching (meaningful for repeated classification/scoring calls), a unified cost/latency dashboard, rate limiting, and provider fallback — all of which we would otherwise have to build ourselves to satisfy the CTO cost-tracking rules. Kept as an opt-in URL setting (`prautoblogger_ai_gateway_base_url`) so the plugin still works unchanged out of the box and can be bypassed instantly if the gateway misbehaves. The gateway is a transparent OpenRouter-compatible proxy; no new provider class is needed, and the response parsing path (`usage`, `choices[0].message.content`) is unchanged.
|
||||
|
||||
504
CHANGELOG.md
504
CHANGELOG.md
@@ -5,506 +5,6 @@ All notable changes to PRAutoBlogger will be documented in this file.
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project uses [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [0.10.0] — 2026-04-21
|
||||
|
||||
### Removed
|
||||
|
||||
- **Cloudflare Workers AI as image provider.** The four `class-cloudflare-image-*`
|
||||
provider files are gone along with their unit tests and the three
|
||||
CF-specific options (`prautoblogger_cloudflare_ai_token`,
|
||||
`prautoblogger_cloudflare_account_id`, `prautoblogger_cf_image_via_gateway`).
|
||||
Runware FLUX.1 schnell produces equivalent-quality output at ~65×
|
||||
less cost and eliminates a duplicated settings surface. The
|
||||
`flux-1-schnell` registry entry that mapped to the CF provider is
|
||||
removed; the legacy slug now resolves to an empty provider (caught
|
||||
by the migration).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Sites on any `cloudflare` image provider auto-migrate to
|
||||
`runware:100@1` on upgrade.** New migration
|
||||
`PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run()` runs from
|
||||
`admin_init` once per site. Detects three legacy shapes: registry-
|
||||
derived provider `cloudflare`, `prautoblogger_image_provider` option
|
||||
set to `cloudflare`, or the legacy slug `flux-1-schnell`. Sets
|
||||
`prautoblogger_image_provider` + `prautoblogger_image_model` to the
|
||||
Runware schnell pair and deletes the three dead CF options. Idempotent
|
||||
via `prautoblogger_migrated_remove_cloudflare_v0100` flag — running
|
||||
twice is a no-op and will not clobber a post-migration manual choice.
|
||||
- **Image provider dispatch fallback is now Runware, not Cloudflare.**
|
||||
`PRAutoBlogger_Image_Pipeline::create_default_provider()` returns
|
||||
`PRAutoBlogger_Runware_Image_Provider` for any unrecognised
|
||||
`prautoblogger_image_provider` value. An unmigrated install still
|
||||
gets a working pipeline instead of a fatal.
|
||||
|
||||
### Note
|
||||
|
||||
- **Cloudflare AI Gateway fronting OpenRouter is unaffected.** The
|
||||
`prautoblogger_ai_gateway_base_url` + `prautoblogger_ai_gateway_cache_ttl`
|
||||
options, their settings fields, `class-open-router-config.php`
|
||||
gateway resolution, and `class-open-router-request-builder.php`
|
||||
`cf-aig-cache-ttl` headers are all intentionally untouched. The AI
|
||||
Gateway is a proxy/cache layer in front of OpenRouter and is
|
||||
orthogonal to the CF Workers AI image provider removal. The
|
||||
migration test asserts this invariant explicitly.
|
||||
|
||||
### Cost impact
|
||||
|
||||
Zero recurring spend from CF Workers AI image generation (the
|
||||
peptiderepo.com install was still on CF as recently as v0.8.2 — see
|
||||
commit `80a7e11`). No new prompt tokens, no change in model mix for
|
||||
sites already on Runware or OpenRouter.
|
||||
|
||||
### Migration checklist (for anyone else running this plugin)
|
||||
|
||||
1. On first admin_init after upgrade, the migration will flip
|
||||
`prautoblogger_image_provider` to `runware` and
|
||||
`prautoblogger_image_model` to `runware:100@1` if you were on CF.
|
||||
2. **Set `prautoblogger_runware_api_key`** in Settings → Images if you
|
||||
hadn't already — image generation will fail until it is set.
|
||||
3. The CF API token + account ID options are deleted by the migration;
|
||||
you don't need to do anything.
|
||||
4. If you want to switch back to OpenRouter (Gemini) instead of
|
||||
Runware, just change the Image Model picker after the migration
|
||||
has run.
|
||||
|
||||
## [0.9.0] — 2026-04-21
|
||||
|
||||
### Added
|
||||
|
||||
- **Runware (FLUX.1 schnell) as the default image provider.** New
|
||||
provider at `includes/providers/class-runware-image-*` generates
|
||||
~$0.0006/image on FLUX.1 schnell (`runware:100@1`) — roughly 65× cheaper
|
||||
than the previous default (Gemini 2.5 Flash Image via OpenRouter at
|
||||
~$0.039/image). FLUX.1 dev (`runware:101@1`, ~$0.02/image) is also
|
||||
available for higher-fidelity runs. The provider mirrors the
|
||||
OpenRouter split: interface + provider + support + pricing + batch,
|
||||
all under the 300-line cap.
|
||||
- **True parallel image generation.** `PRAutoBlogger_Runware_Image_Batch`
|
||||
uses `curl_multi_init/exec/select` to fire all imageInference POSTs
|
||||
concurrently, then downloads the signed URLs concurrently. Wall-clock
|
||||
for the A/B pair drops to ≈ the slowest single image instead of the
|
||||
sum of both.
|
||||
- **v0.9.0 one-time migration.** Sites where the image model is empty
|
||||
or still pinned to `google/gemini-2.5-flash-image` are auto-flipped
|
||||
to `runware:100@1` on upgrade, with the provider re-derived from the
|
||||
registry. Flag: `prautoblogger_migrated_default_image_v090`. The
|
||||
Runware API key option (`prautoblogger_runware_api_key`) was added
|
||||
to the `enc:`-prefix migration list so Settings writes go through
|
||||
the same encryption-at-rest path as the other provider keys.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Image prompt builder steered away from abstract/chemical subjects.**
|
||||
The rewriter system prompt now explicitly refuses to personify
|
||||
molecules, peptides, hormones, or proteins — FLUX (and most diffusion
|
||||
image models) render "anthropomorphic peptide" as an incoherent blob.
|
||||
Default scene guidance is now "concrete, human-scale scenes: people
|
||||
reacting to things, doctors and patients, gym bros with vials, etc."
|
||||
Style suffix unchanged — single-panel comic aesthetic preserved. See
|
||||
`includes/core/class-image-prompt-builder.php::REWRITER_SYSTEM_PROMPT`.
|
||||
- **Default image model flipped to `runware:100@1` (FLUX.1 schnell).**
|
||||
`PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER` and
|
||||
`PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL` constants in `prautoblogger.php`
|
||||
updated. Cloudflare flux-1-schnell remains in the registry as a legacy
|
||||
fallback for any operators who set it explicitly. Rationale:
|
||||
`image-style` memory (2026-04-21) — schnell passed the comic A/B
|
||||
round and the cost delta funds more generations for the same monthly
|
||||
budget.
|
||||
|
||||
### Cost impact
|
||||
|
||||
At 50 articles/day × 2 image A/B = 100 images/day:
|
||||
|
||||
- Before (Gemini 2.5 Flash Image via OpenRouter): ≈ $3.90/day, ≈ $117/mo
|
||||
- After (FLUX.1 schnell via Runware): ≈ $0.06/day, ≈ $1.80/mo
|
||||
|
||||
Net saving: ~$115/mo at current traffic, funded by a single provider
|
||||
swap with no user-visible change in article quality.
|
||||
|
||||
## [0.8.2] — 2026-04-21
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Daily generation schedule now honors site timezone.** Previously
|
||||
the time configured in Settings → Schedule was interpreted as UTC
|
||||
(WordPress core forces `date_default_timezone_set('UTC')`), causing
|
||||
runs to fire N hours off from user intent where N is the site's UTC
|
||||
offset. On peptiderepo.com (Asia/Singapore, UTC+8), a `schedule_time`
|
||||
of `06:00` fired at 14:00 local. An on-upgrade one-shot migration
|
||||
(`prautoblogger_migrated_schedule_tz_v082`) reschedules the existing
|
||||
daily cron in the site's configured timezone — no user action
|
||||
required.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Cloudflare Workers AI image calls now route through the AI Gateway
|
||||
by default.** The 2026-04-15 regression that caused the gateway route
|
||||
to 403 on Workers AI has been resolved upstream. Routing via the
|
||||
gateway gives us response caching, unified cost/latency dashboard,
|
||||
and rate limiting for free, mirroring the OpenRouter LLM path. A new
|
||||
admin toggle `prautoblogger_cf_image_via_gateway` (default on) lets
|
||||
operators force direct-API if the gateway route regresses again,
|
||||
without clearing the gateway URL setting the LLM provider depends on.
|
||||
- **"Generation Time" settings description** now names the site's
|
||||
configured timezone explicitly (from `wp_timezone_string()`) instead
|
||||
of the ambiguous "server timezone" label — operators can see at a
|
||||
glance which zone their input is being interpreted in.
|
||||
|
||||
## [0.8.1] — 2026-04-21
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Orphaned LLM research costs now reaped daily.** When a pipeline died
|
||||
between post creation and the final cost-amortization step (e.g.
|
||||
execution-time kill on shared hosting), the `llm_research` row stayed
|
||||
unlinked and the affected articles' cost popovers understated true spend.
|
||||
A new daily cron (`prautoblogger_reap_orphan_research_rows`, 03:15
|
||||
server time) retroactively amortizes orphan rows against posts from
|
||||
the same run. Includes a one-time activator migration to back-populate
|
||||
`_prautoblogger_run_id` post_meta for existing posts.
|
||||
- **GA4 Property ID admin description corrected.** The field description
|
||||
previously instructed users to enter `properties/XXXXXXXXX`, but the
|
||||
GA4 client already prepends the `properties/` URL segment internally —
|
||||
a double-prefixed value would 404. Description now asks for the digits
|
||||
only, matching actual code behavior.
|
||||
|
||||
### Added
|
||||
|
||||
- New WP-CLI command `wp prautoblogger reap-research` for manual reaping
|
||||
and ops debugging.
|
||||
- `Publisher::build_meta()` now writes `_prautoblogger_run_id` on every
|
||||
generated post, so the reaper has a direct meta path from run_id to
|
||||
sibling posts without walking `wp_prautoblogger_generation_log`.
|
||||
|
||||
## [0.8.0] — 2026-04-21
|
||||
|
||||
### Changed
|
||||
|
||||
- **Single Image Model dropdown in admin → Images.** The separate Image
|
||||
Provider select has been removed. The provider (OpenRouter or
|
||||
Cloudflare Workers AI) is now derived from the chosen model's entry
|
||||
in `PRAutoBlogger_Image_Model_Registry` on save, so mismatched
|
||||
provider/model pairs — the root cause of posts 650 and 657 silently
|
||||
missing their featured image on 2026-04-20 — are no longer possible.
|
||||
A one-shot migration runs on first admin_init after upgrade and
|
||||
auto-heals any existing site where the saved provider and model
|
||||
disagreed. The `prautoblogger_image_provider` option key is
|
||||
preserved; all existing reads in the pipeline continue to work.
|
||||
|
||||
### Added
|
||||
|
||||
- **Image Prompt Instructions setting.** The system prompt used to
|
||||
rewrite articles into SCENE + CAPTION is now editable from admin →
|
||||
Images. Default preserves prior behavior; edit to reshape the
|
||||
creative direction without a code change.
|
||||
- **Retry NSFW-Blocked Images setting** (default on). When the image
|
||||
provider rejects a prompt as NSFW — Cloudflare Workers AI returns
|
||||
HTTP 400 with `errors[].code === 3030` on FLUX.1 schnell —
|
||||
PRAutoBlogger now retries the slot once with a rule-based fallback
|
||||
prompt (article title + style suffix, no LLM rewrite). On a second
|
||||
block it logs a WARNING and publishes without that image, matching
|
||||
existing behaviour for any unrecoverable image failure.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Silent per-image failures in the event log.** The OpenRouter batch
|
||||
provider now emits `Logger::error` with the request key for every
|
||||
per-slot HTTP/cURL/parse failure (previously logged the error but
|
||||
not which slot it was), and `Image_Pipeline::process_image_a/_b`
|
||||
emit `Logger::warning` on `['error' => …]` results whether or not
|
||||
the outer catch fires. Operators can now see NSFW blocks, 4xx
|
||||
bodies, and timeouts in the admin Event Log without cross-
|
||||
referencing PHP error_log timestamps.
|
||||
|
||||
## [0.7.3] — 2026-04-20
|
||||
|
||||
### Fixed
|
||||
- **Table colors broken in dark mode.** Replaced hardcoded hex colors
|
||||
with theme CSS custom properties (`--color-border-default`,
|
||||
`--color-bg-secondary`, `--color-text-primary`) so table borders,
|
||||
header backgrounds, and text adapt to light/dark mode automatically.
|
||||
|
||||
## [0.7.2] — 2026-04-20
|
||||
|
||||
### Fixed
|
||||
- **Bullet points missing in articles.** Theme's global CSS reset strips
|
||||
`list-style` from all `ul`/`ol`. Typography CSS now restores disc/decimal
|
||||
bullets with proper padding inside `.entry-content`.
|
||||
- **Tables break on mobile.** Tables now wrapped in a horizontally
|
||||
scrollable container (`prab-table-wrap`) via `the_content` filter.
|
||||
Added responsive media query for tighter padding and smaller font at
|
||||
narrow viewports.
|
||||
|
||||
## [0.7.1] — 2026-04-20
|
||||
|
||||
### Fixed
|
||||
- **Typography CSS not applying.** Two bugs: CSS targeted `.post-content`
|
||||
but the theme template uses `.entry-content`, and the
|
||||
`_prautoblogger_generated` meta gate excluded posts that predate the
|
||||
meta flag. Display settings now apply to all single post pages and
|
||||
target the correct `.entry-content` selector.
|
||||
|
||||
## [0.7.0] — 2026-04-20
|
||||
|
||||
### Added
|
||||
- **Display settings section.** New "Display" tab in admin with three
|
||||
settings that control how generated articles render on the frontend:
|
||||
- **Article Font** — choose from 7 font families (Inter, Georgia,
|
||||
Merriweather, Lora, Open Sans, Roboto, System) or keep theme default.
|
||||
Google Fonts are loaded only on PRAutoBlogger posts that need them.
|
||||
- **Article Font Size** — set body text size in pixels (0 = theme
|
||||
default). Recommended 16–18px for comfortable reading.
|
||||
- **Table Borders** — adds visible borders, cell padding, header
|
||||
background, and alternating row colors to tables. Enabled by default.
|
||||
- **Article_Typography frontend class.** Injects inline CSS only on
|
||||
singular post pages with `_prautoblogger_generated` meta — zero
|
||||
impact on non-PRAutoBlogger pages.
|
||||
|
||||
## [0.6.3] — 2026-04-20
|
||||
|
||||
### Fixed
|
||||
- **Unchecking all sources still used Reddit.** Browser omits checkbox fields
|
||||
from POST when none are checked, so the `prautoblogger_enabled_sources`
|
||||
option never updated. Added a hidden input so an empty value is always
|
||||
submitted — unchecking all sources now correctly saves `[]`.
|
||||
- **Pipeline status hardcoded "Reddit".** The "Collecting sources…" stage
|
||||
message now reflects the actually-enabled sources instead of always
|
||||
saying "Reddit".
|
||||
|
||||
## [0.6.2] — 2026-04-20
|
||||
|
||||
### Added
|
||||
- **Deterministic peptide auto-linker.** New `Peptide_Linker` class scans
|
||||
generated HTML and wraps every mention of a known peptide in a hyperlink
|
||||
to its `/peptides/{slug}/` database page. Runs as a post-processing step
|
||||
in Publisher before `wp_insert_post` — no LLM involvement, 100% reliable.
|
||||
Handles case-insensitive matching, hyphen/space variants (e.g., "BPC-157"
|
||||
and "BPC 157"), and skips text already inside `<a>` tags. Gracefully
|
||||
no-ops if PR Core is not active.
|
||||
|
||||
### Changed
|
||||
- **Removed prompt-based peptide linking.** The system prompt no longer asks
|
||||
the LLM to link peptides (unreliable). Peptide links are now injected
|
||||
deterministically by `Peptide_Linker` after content generation. The prompt
|
||||
still provides article links and the "never fabricate URLs" rule.
|
||||
|
||||
## [0.6.1] — 2026-04-20
|
||||
|
||||
### Added
|
||||
- **Peptide auto-linking.** When any peptide name from the PR Core database is
|
||||
mentioned in a generated article, the first mention is automatically linked to
|
||||
the corresponding `/peptides/{slug}/` page. The system prompt now includes a
|
||||
complete peptide database reference list alongside the existing article links.
|
||||
Gracefully degrades if PR Core is not active (`post_type_exists()` guard).
|
||||
|
||||
### Changed
|
||||
- **Extracted prompt builders into Content_Prompts class.** Moved all prompt
|
||||
construction (system prompt, stage prompts, linking rules) from
|
||||
`Content_Generator` into a new `Content_Prompts` static helper. Generator
|
||||
dropped from 324 → 162 lines; prompts class is 252 lines. Both under 300.
|
||||
- **Internal link rules strengthened.** Linking rules section now explicitly
|
||||
instructs the model to link every peptide's first mention and never fabricate
|
||||
URLs, with real published article and peptide page URLs provided.
|
||||
|
||||
## [0.6.0] — 2026-04-20
|
||||
|
||||
### Added
|
||||
- **Generate article from idea.** New "Generate" column in the Ideas browser.
|
||||
Click any idea's Generate button to produce an article directly, bypassing
|
||||
the scheduled pipeline's collect → analyze → score steps. The button shows
|
||||
real-time stage updates (drafting, editing, publishing) via per-idea status
|
||||
polling, then swaps to Edit/View links with cost on completion.
|
||||
- `Ideas_Browser::on_ajax_generate_from_idea()` — AJAX trigger that stores
|
||||
the idea and schedules a one-shot WP-Cron event.
|
||||
- `Ideas_Browser::on_cron_generate_from_idea()` — Background handler that
|
||||
runs Article_Worker for the selected idea.
|
||||
- `Ideas_Browser::on_ajax_idea_gen_status()` — Per-idea status polling.
|
||||
- `assets/js/ideas-browser.js` — Button click, AJAX, polling, and UI state.
|
||||
- Per-idea transients (`prab_idea_gen_{id}`) for independent status tracking.
|
||||
- Page-load polling resume for ideas that were mid-generation.
|
||||
- Generation lock respected — only one generation at a time.
|
||||
|
||||
## [0.5.3] — 2026-04-20
|
||||
|
||||
### Fixed
|
||||
- **Writing instructions not obeyed by LLM.** The user-configured writing
|
||||
instructions (bullet points, hyperlinks, style rules) were appended to the
|
||||
system prompt as a weak "Additional instructions:" afterthought. Models
|
||||
prioritized competing format directives in the user prompts and ignored
|
||||
the style guide.
|
||||
- System prompt now frames writing instructions as a "MANDATORY STYLE GUIDE"
|
||||
with explicit override language.
|
||||
- Every stage's user prompt (outline, draft, single-pass) now includes a
|
||||
reinforcement line: "Follow EVERY requirement from your system prompt
|
||||
style guide."
|
||||
- Polish stage explicitly preserves bullet points, numbered lists, and
|
||||
hyperlinks instead of flattening them.
|
||||
- Chief editor's revision rules now preserve structural formatting (lists,
|
||||
links) from the original draft.
|
||||
|
||||
## [0.5.2] — 2026-04-20
|
||||
|
||||
### Added
|
||||
- **LLM research cost amortization.** The research LLM call (which runs once
|
||||
per pipeline execution) is now divided evenly across all articles produced
|
||||
in that run. Each article's cost breakdown popover shows its amortized share
|
||||
of the research overhead. Previously, research costs were orphaned with no
|
||||
post attribution.
|
||||
- `Post_Assembler::amortize_research_costs()` — Queries the unlinked
|
||||
research log entry, divides cost and tokens by article count, inserts one
|
||||
row per article, and removes the original.
|
||||
- `link_generation_logs()` now excludes `llm_research` stage from per-article
|
||||
linking so the cost isn't grabbed wholesale by the first article.
|
||||
- `Source_Collector::set_cost_tracker()` — Accepts the pipeline's cost
|
||||
tracker so research costs are tagged with the pipeline's `run_id`.
|
||||
- `LLM_Research_Provider` now uses the pipeline's cost tracker when available,
|
||||
ensuring research log entries share the same `run_id` as article entries.
|
||||
- Amortization runs after all articles complete (both single-article and
|
||||
chained-job paths in `Pipeline_Runner`).
|
||||
|
||||
## [0.5.1] — 2026-04-20
|
||||
|
||||
### Added
|
||||
- **Ideas browser admin page.** New "Ideas" submenu under PRAutoBlogger showing
|
||||
all article ideas collected by the content analyzer. Displays suggested title,
|
||||
topic, type (question/complaint/comparison/news/guide), relevance score bar,
|
||||
frequency, key points, and target keywords. Filterable by type with count
|
||||
badges. Paginated at 30 per page, newest first.
|
||||
|
||||
## [0.5.0] — 2026-04-20
|
||||
|
||||
### Added
|
||||
- **LLM Deep Research source provider.** New data source that sends a configurable
|
||||
research prompt to a reasoning-capable model (e.g. Grok 4.1 Fast, DeepSeek-R1)
|
||||
to identify trending topics, emerging questions, misconceptions, and content gaps.
|
||||
Findings feed into the content analyzer alongside Reddit data, giving the pipeline
|
||||
both real-time community signals AND deep knowledge-base research.
|
||||
- `includes/providers/class-llm-research-provider.php` — Source provider implementation.
|
||||
- New admin settings in Sources tab:
|
||||
- "LLM Deep Research" checkbox in Enabled Sources.
|
||||
- "Research Model" model picker — pick a reasoning-capable model.
|
||||
- "Research Prompt" textarea — customizable research brief with `{niche}` placeholder.
|
||||
- Each research run produces 8-12 findings stored as `llm_research` source data.
|
||||
- Deduplication via date-based source IDs (one fresh run per day, refreshes on re-run).
|
||||
- Cost tracked as `llm_research` stage in generation logs.
|
||||
- Content analyzer now formats LLM research findings with a cleaner label
|
||||
(`[LLM Research] Relevance: N`) instead of the Reddit-specific `r/subreddit` format.
|
||||
|
||||
## [0.4.9] — 2026-04-19
|
||||
|
||||
### Added
|
||||
- **OpenRouter reasoning mode support.** New "Enable Reasoning" toggle and
|
||||
"Reasoning Effort" selector in AI Models settings. When enabled, sends
|
||||
`reasoning: {enabled: true, effort: "<level>"}` to models that support it
|
||||
(Grok 4.1 Fast, DeepSeek-R1, etc.). Effort levels: Extra High, High,
|
||||
Medium, Low, Minimal. Reasoning tokens are billed as output tokens — cost
|
||||
is automatically tracked. Models that don't support reasoning ignore the
|
||||
parameter. Per-call override available via the `reasoning` key in the
|
||||
provider options array.
|
||||
- Response parser now captures `reasoning_tokens` and `reasoning_content`
|
||||
from the OpenRouter response for downstream logging and debugging.
|
||||
|
||||
## [0.4.8] — 2026-04-19
|
||||
|
||||
### Fixed
|
||||
- **GLM 4.7 Flash timeout during analysis.** The model was timing out at 120s on
|
||||
large analysis prompts (especially with the new custom instructions). Bumped
|
||||
API timeout from 120s to 180s.
|
||||
- **Empty raw response in error logs.** Parse failure errors now include the raw
|
||||
LLM response inline (first 500 chars) at error level, not debug level, so
|
||||
the actual failure reason is always visible in the Activity Log.
|
||||
|
||||
## [0.4.7] — 2026-04-19
|
||||
|
||||
### Added
|
||||
- **Cost breakdown popover.** The Cost column in the post list is now clickable.
|
||||
Hovering or clicking shows a per-step breakdown: stage name, model used, token
|
||||
count, and cost for every LLM call that produced the article.
|
||||
|
||||
## [0.4.6] — 2026-04-19
|
||||
|
||||
### Added
|
||||
- **Image B toggle.** New "Generate Second Image (B)" toggle in Images settings.
|
||||
Disabling saves one image generation call + one LLM prompt rewrite per article.
|
||||
Defaults to enabled for backwards compatibility.
|
||||
|
||||
## [0.4.5] — 2026-04-19
|
||||
|
||||
### Added
|
||||
- **Analysis Instructions setting.** Custom instructions appended to the analysis
|
||||
LLM's system prompt. Steers how source data is evaluated and which topic ideas
|
||||
get surfaced.
|
||||
- **Editor Instructions setting.** Custom instructions appended to the chief
|
||||
editor's system prompt. Controls what the editorial review looks for and how
|
||||
it decides to approve, revise, or reject articles.
|
||||
|
||||
## [0.4.4] — 2026-04-19
|
||||
|
||||
### Added
|
||||
- **Writing Instructions setting.** New textarea in Content settings lets you
|
||||
provide custom instructions appended to the LLM's system prompt when writing
|
||||
articles. Use it to steer style, structure, voice, and formatting without
|
||||
editing code.
|
||||
|
||||
## [0.4.3] — 2026-04-19
|
||||
|
||||
### Fixed
|
||||
- **"Analysis response was not valid JSON" with some models.** Models like
|
||||
GPT 5.1 Nano ignore `response_format: json_object` and wrap JSON in markdown
|
||||
fences or preamble text. New `PRAutoBlogger_Json_Extractor` utility strips
|
||||
fences and extracts the outermost JSON object. Applied to both the content
|
||||
analyzer and chief editor parsers. Raw response now logged on failure for
|
||||
debugging.
|
||||
|
||||
## [0.4.2] — 2026-04-19
|
||||
|
||||
### Fixed
|
||||
- **Posts list Title column unreadable.** Title was compressed to ~30px by the
|
||||
three custom columns. Title now gets 35% minimum width with word-wrap enabled.
|
||||
Model columns trimmed to 100px with text-overflow ellipsis for long model names.
|
||||
|
||||
## [0.4.1] — 2026-04-19
|
||||
|
||||
### Added
|
||||
- **Posts list columns: Writing Model, Image Model, Cost.** Three new columns
|
||||
on the WordPress Posts admin page. Writing Model shows the LLM that wrote the
|
||||
article, Image Model shows the model that generated the featured image, and
|
||||
Cost shows the total API spend for generating that article (summed from the
|
||||
generation log). All columns show "—" for non-generated posts. Writing Model
|
||||
is sortable. Model names are shortened (provider prefix stripped) with full
|
||||
ID in a tooltip.
|
||||
|
||||
## [0.4.0] — 2026-04-19
|
||||
|
||||
### Added
|
||||
- **Semantic dedup via embedding cosine similarity.** Replaces keyword-overlap
|
||||
dedup (60% word match) with MiniLM-L12-v2 embeddings via OpenRouter. Catches
|
||||
rephrasings like "BPC-157 dosing" vs "how much BPC-157 to take" that keyword
|
||||
matching misses. Automatic fallback to keywords if embedding API unavailable.
|
||||
Cost: ~$0.00001 per generation run.
|
||||
- `includes/core/class-semantic-dedup.php` — Dedup engine with embedding +
|
||||
keyword fallback.
|
||||
- `includes/providers/class-open-router-embedding-provider.php` — OpenRouter
|
||||
`/embeddings` client with batch support and cosine similarity helper.
|
||||
|
||||
- **LLM-aware topic avoidance.** Analysis prompt now includes the last 30 days
|
||||
of published article titles with explicit "do not suggest similar topics"
|
||||
instruction. Zero additional cost — appended to the existing analysis call.
|
||||
|
||||
### Changed
|
||||
- Dedup window widened from 7 days to 30 days (semantic similarity is precise
|
||||
enough to avoid over-blocking).
|
||||
- `class-idea-scorer.php` refactored to delegate dedup to `Semantic_Dedup`.
|
||||
|
||||
### Fixed
|
||||
- Featured images displayed at 300×300 (WordPress "medium" size) instead of full
|
||||
width. Changed theme to use `the_post_thumbnail('full')` with responsive CSS.
|
||||
- Race condition causing duplicate article generation when the AJAX status poller
|
||||
re-scheduled a cron event for an article already being generated.
|
||||
- False stall detection on multi-article runs (measured elapsed time from start
|
||||
instead of idle time since last progress update).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
@@ -540,8 +40,8 @@ swap with no user-visible change in article quality.
|
||||
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-open-router-model-registry.php` — fetch + cache + query.
|
||||
- `includes/services/class-open-router-model-normalizer.php` — raw → standardized shape.
|
||||
- `includes/services/class-openrouter-model-registry.php` — fetch + cache + query.
|
||||
- `includes/services/class-openrouter-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.
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
/**
|
||||
* 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,14 +36,6 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
@@ -63,37 +55,6 @@
|
||||
$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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
@@ -165,76 +126,29 @@
|
||||
* Shows pipeline stages while the request is in-flight so the user
|
||||
* knows it hasn't hung.
|
||||
*/
|
||||
/*
|
||||
* 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.
|
||||
}
|
||||
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 = [];
|
||||
|
||||
$(document).on('click', '#prautoblogger-generate-now', function () {
|
||||
var $btn = $(this);
|
||||
if ($btn.prop('disabled')) return;
|
||||
|
||||
setButtonLoading($btn, config.generatingText || 'Generating…');
|
||||
showProgress('Starting generation…');
|
||||
|
||||
// Start pipeline stage messages.
|
||||
stageTimers = [];
|
||||
$.each(generateStages, function (i, stage) {
|
||||
var t = setTimeout(function () { showProgress(stage.text); }, stage.delay);
|
||||
stageTimers.push(t);
|
||||
});
|
||||
|
||||
$.ajax({
|
||||
url: config.ajaxUrl,
|
||||
@@ -244,19 +158,21 @@
|
||||
nonce: config.generateNonce,
|
||||
force: '1' // Always clear stale locks on manual runs.
|
||||
},
|
||||
timeout: 30000, // 30s is plenty — this just schedules the cron.
|
||||
timeout: 300000, // 5 minutes — generation can be slow.
|
||||
success: function (response) {
|
||||
if (response.success) {
|
||||
// Generation kicked off in background — start polling.
|
||||
showProgress(response.data.message || 'Generation started…');
|
||||
pollGenerationStatus($btn);
|
||||
var d = response.data;
|
||||
showStatus(
|
||||
'Generation complete: ' + d.generated + ' generated, ' +
|
||||
d.published + ' published, ' + d.rejected + ' rejected. ' +
|
||||
'Cost: $' + parseFloat(d.cost).toFixed(4),
|
||||
'success'
|
||||
);
|
||||
} else {
|
||||
showStatus(
|
||||
'Generation failed: ' + (response.data && response.data.message || 'Unknown error'),
|
||||
'error'
|
||||
);
|
||||
hideProgress();
|
||||
resetButton($btn, config.generateText || 'Generate Now');
|
||||
}
|
||||
},
|
||||
error: function (xhr, status) {
|
||||
@@ -264,6 +180,11 @@
|
||||
'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');
|
||||
}
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
@@ -1,285 +0,0 @@
|
||||
/**
|
||||
* 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,6 +8,7 @@
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9.5",
|
||||
"wp-phpunit/wp-phpunit": "^6.0",
|
||||
"brain/monkey": "^2.6"
|
||||
},
|
||||
"autoload": {
|
||||
|
||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||
* Displays admin notices for onboarding, errors, and budget warnings.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger::register_admin_hooks() on `admin_notices`.
|
||||
* Dependencies: PRAutoBlogger_Cost_Reporter (for budget warnings).
|
||||
* Dependencies: PRAutoBlogger_Cost_Tracker (for budget warnings).
|
||||
*
|
||||
* @see class-prautoblogger.php — Registers the hook.
|
||||
*/
|
||||
@@ -51,8 +51,8 @@ class PRAutoBlogger_Admin_Notices {
|
||||
* @return void
|
||||
*/
|
||||
private function check_budget_notice(): void {
|
||||
$cost_reporter = new PRAutoBlogger_Cost_Reporter();
|
||||
$utilization = $cost_reporter->get_budget_utilization();
|
||||
$cost_tracker = new PRAutoBlogger_Cost_Tracker();
|
||||
$utilization = $cost_tracker->get_budget_utilization();
|
||||
|
||||
if ( $utilization >= 100.0 ) {
|
||||
printf(
|
||||
|
||||
@@ -58,7 +58,6 @@ 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 ) ) {
|
||||
@@ -66,32 +65,18 @@ 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). */
|
||||
@@ -158,11 +143,6 @@ 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' : '';
|
||||
@@ -171,10 +151,6 @@ 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;
|
||||
@@ -197,25 +173,58 @@ class PRAutoBlogger_Admin_Page {
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegate source status rendering to the dedicated field class.
|
||||
* Render the Reddit source status indicator.
|
||||
*
|
||||
* @see admin/fields/class-source-status-field.php
|
||||
* 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
|
||||
*/
|
||||
private function render_source_status_field(): void {
|
||||
PRAutoBlogger_Source_Status_Field::render();
|
||||
}
|
||||
$last_collection = get_option( 'prautoblogger_last_collection_time', '' );
|
||||
$last_source_used = get_option( 'prautoblogger_last_source_used', '' );
|
||||
|
||||
/**
|
||||
* 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 );
|
||||
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>';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -231,7 +240,7 @@ class PRAutoBlogger_Admin_Page {
|
||||
$option_name = substr( $filter, strlen( 'sanitize_option_' ) );
|
||||
}
|
||||
|
||||
$encrypted = [ 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json', 'prautoblogger_runware_api_key' ];
|
||||
$encrypted = [ 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json' ];
|
||||
if ( in_array( $option_name, $encrypted, true ) ) {
|
||||
// Empty value means password field wasn't touched — keep existing.
|
||||
if ( '' === $value ) {
|
||||
@@ -253,47 +262,19 @@ class PRAutoBlogger_Admin_Page {
|
||||
|
||||
$json_fields = [ 'prautoblogger_target_subreddits', 'prautoblogger_topic_exclusions', 'prautoblogger_enabled_sources' ];
|
||||
if ( in_array( $option_name, $json_fields, true ) ) {
|
||||
// PHP array from checkboxes — sanitize each item and re-encode.
|
||||
if ( is_array( $value ) ) {
|
||||
return wp_json_encode( array_values( array_map( 'sanitize_text_field', $value ) ) );
|
||||
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 ) );
|
||||
}
|
||||
|
||||
$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 ) ) );
|
||||
return sanitize_text_field( $value );
|
||||
}
|
||||
|
||||
$numeric = [ 'prautoblogger_daily_article_target', 'prautoblogger_monthly_budget_usd', 'prautoblogger_min_word_count', 'prautoblogger_max_word_count', 'prautoblogger_default_author', 'prautoblogger_default_category', 'prautoblogger_pullpush_cache_ttl', 'prautoblogger_reddit_posts_per_subreddit' ];
|
||||
if ( in_array( $option_name, $numeric, true ) ) {
|
||||
return is_numeric( $value ) ? $value : 0;
|
||||
}
|
||||
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 );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ declare(strict_types=1);
|
||||
* and budget utilization.
|
||||
*
|
||||
* Triggered by: Could be registered via wp_dashboard_setup hook.
|
||||
* Dependencies: PRAutoBlogger_Cost_Reporter.
|
||||
* Dependencies: PRAutoBlogger_Cost_Tracker.
|
||||
*
|
||||
* @see class-prautoblogger.php — Can register this on wp_dashboard_setup.
|
||||
*/
|
||||
@@ -37,10 +37,10 @@ class PRAutoBlogger_Dashboard_Widget {
|
||||
* @return void
|
||||
*/
|
||||
public function render_widget(): void {
|
||||
$cost_reporter = new PRAutoBlogger_Cost_Reporter();
|
||||
$monthly_spend = $cost_reporter->get_monthly_spend();
|
||||
$cost_tracker = new PRAutoBlogger_Cost_Tracker();
|
||||
$monthly_spend = $cost_tracker->get_monthly_spend();
|
||||
$budget = (float) get_option( 'prautoblogger_monthly_budget_usd', 50.00 );
|
||||
$utilization = $cost_reporter->get_budget_utilization();
|
||||
$utilization = $cost_tracker->get_budget_utilization();
|
||||
|
||||
$next_run = wp_next_scheduled( 'prautoblogger_daily_generation' );
|
||||
|
||||
|
||||
@@ -1,299 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Static registry of available image generation models.
|
||||
*
|
||||
* What: Returns a hardcoded list of image models for the admin model picker.
|
||||
* No API discovery — updated manually when providers add/remove models.
|
||||
* Who calls it: PRAutoBlogger_Admin_Page (model picker UI + save-time provider
|
||||
* derivation) and PRAutoBlogger_Image_Pipeline (provider lookup).
|
||||
* Dependencies: None.
|
||||
*
|
||||
* @see admin/class-settings-fields-extended.php — Image settings reference this registry.
|
||||
* @see core/class-image-pipeline.php — Derives provider from picked model.
|
||||
* @see providers/class-runware-image-provider.php — Runware FLUX backend.
|
||||
*/
|
||||
class PRAutoBlogger_Image_Model_Registry {
|
||||
|
||||
/**
|
||||
* Get all available image generation models.
|
||||
*
|
||||
* Each entry contains:
|
||||
* - id: Model identifier used in API calls.
|
||||
* - name: Human-readable display name.
|
||||
* - provider: 'runware' | 'openrouter'.
|
||||
* - cost_per_image: Estimated USD cost per generation.
|
||||
* - capabilities: Array of capability tags.
|
||||
* - description: Short description for the admin UI.
|
||||
*
|
||||
* Ordered cheapest-to-most-expensive within each provider, with the
|
||||
* recommended default (Runware schnell) at the top.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function get_models(): array {
|
||||
return [
|
||||
[
|
||||
'id' => 'runware:100@1',
|
||||
'name' => 'FLUX.1 schnell (Runware)',
|
||||
'provider' => 'runware',
|
||||
'cost_per_image' => 0.0006,
|
||||
'capabilities' => [ 'image_generation' ],
|
||||
'description' => __( 'Default. Fast, very cheap, comic-friendly fidelity.', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
'id' => 'runware:101@1',
|
||||
'name' => 'FLUX.1 dev (Runware)',
|
||||
'provider' => 'runware',
|
||||
'cost_per_image' => 0.02,
|
||||
'capabilities' => [ 'image_generation' ],
|
||||
'description' => __( 'Higher fidelity FLUX. ~30x schnell cost.', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
'id' => 'google/gemini-2.5-flash-image',
|
||||
'name' => 'Gemini 2.5 Flash Image (Nano Banana)',
|
||||
'provider' => 'openrouter',
|
||||
'cost_per_image' => 0.005,
|
||||
'capabilities' => [ 'image_generation' ],
|
||||
'description' => __( 'Mid-tier quality, proven.', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
'id' => 'google/gemini-3.1-flash-image-preview',
|
||||
'name' => 'Gemini 3.1 Flash Image (Nano Banana 2)',
|
||||
'provider' => 'openrouter',
|
||||
'cost_per_image' => 0.008,
|
||||
'capabilities' => [ 'image_generation' ],
|
||||
'description' => __( 'Latest Google. Better quality.', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
'id' => 'google/gemini-3-pro-image-preview',
|
||||
'name' => 'Gemini 3 Pro Image (Nano Banana Pro)',
|
||||
'provider' => 'openrouter',
|
||||
'cost_per_image' => 0.03,
|
||||
'capabilities' => [ 'image_generation' ],
|
||||
'description' => __( 'Highest quality Google. Premium.', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
'id' => 'openai/gpt-5-image-mini',
|
||||
'name' => 'GPT-5 Image Mini',
|
||||
'provider' => 'openrouter',
|
||||
'cost_per_image' => 0.02,
|
||||
'capabilities' => [ 'image_generation' ],
|
||||
'description' => __( 'OpenAI budget image model.', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
'id' => 'openai/gpt-5-image',
|
||||
'name' => 'GPT-5 Image',
|
||||
'provider' => 'openrouter',
|
||||
'cost_per_image' => 0.08,
|
||||
'capabilities' => [ 'image_generation' ],
|
||||
'description' => __( 'OpenAI premium. Photorealistic.', 'prautoblogger' ),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the provider id for a known model, or empty string if the
|
||||
* model id is not in the registry.
|
||||
*
|
||||
* @param string $model_id Model slug from the admin UI.
|
||||
* @return string Provider id ('runware' | 'openrouter' | '').
|
||||
*/
|
||||
public static function provider_for( string $model_id ): string {
|
||||
foreach ( self::get_models() as $model ) {
|
||||
if ( ( $model['id'] ?? '' ) === $model_id ) {
|
||||
return (string) ( $model['provider'] ?? '' );
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Extended settings fields: schedule, publishing, analytics, display, and images.
|
||||
*
|
||||
* What: Declarative field definitions for operational settings sections.
|
||||
* Who calls it: PRAutoBlogger_Settings_Fields::get_fields() merges these in.
|
||||
* Dependencies: PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL, PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX constants,
|
||||
* PRAutoBlogger_Article_Typography (font choices), PRAutoBlogger_Image_Model_Registry.
|
||||
*
|
||||
* @see admin/class-settings-fields.php — Core fields and sections; calls get_extended_fields().
|
||||
* @see admin/class-admin-page.php — Renders fields registered from these definitions.
|
||||
* @see admin/class-image-model-registry.php — Static image model list extracted for 300-line compliance.
|
||||
* @see frontend/class-article-typography.php — Reads Display settings on the frontend.
|
||||
* @see CONVENTIONS.md — "How To: Add a New Admin Setting".
|
||||
*/
|
||||
class PRAutoBlogger_Settings_Fields_Extended {
|
||||
|
||||
/**
|
||||
* Static image model list for the admin model picker.
|
||||
* Delegates to Image_Model_Registry (extracted for 300-line file limit).
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function get_image_models(): array {
|
||||
return PRAutoBlogger_Image_Model_Registry::get_models();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get schedule, publishing, analytics, and image settings fields.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function get_fields(): array {
|
||||
return [
|
||||
// ── Sources (extended) ──────────────────────────────────────
|
||||
[
|
||||
'id' => 'prautoblogger_research_model',
|
||||
'label' => __( 'Research Model', 'prautoblogger' ),
|
||||
'type' => 'model_select',
|
||||
'section' => 'prautoblogger_sources',
|
||||
'default' => PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL,
|
||||
'capability' => 'text→text',
|
||||
'description' => __( 'Model for LLM Deep Research. Pick a reasoning-capable model (e.g. Grok 4.1 Fast, DeepSeek-R1) for best results. Only used when LLM Deep Research is enabled above.', 'prautoblogger' ),
|
||||
'badge' => __( 'Reasoning', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
'id' => 'prautoblogger_research_prompt',
|
||||
'label' => __( 'Research Prompt', 'prautoblogger' ),
|
||||
'type' => 'textarea',
|
||||
'section' => 'prautoblogger_sources',
|
||||
'default' => 'Conduct a deep research sweep of the {niche} space and identify the most substantive, actionable topics that practitioners and curious newcomers are searching for or actively discussing.' . "\n\n" . 'Focus on:' . "\n" . '- Practical questions real readers would search for (not theoretical debates)' . "\n" . '- Emerging trends gaining momentum in the community' . "\n" . '- Common misconceptions that deserve clarification' . "\n" . '- Comparative analysis (product vs. product, protocol vs. protocol)' . "\n" . '- Safety and efficacy topics that drive concern or curiosity' . "\n" . '- Beginner-to-advanced mix — cover entry-level and depth-focused topics' . "\n\n" . 'Aim for topics that feel timely, underserved by existing content, and likely to drive reader engagement. Return 8-12 findings with detailed analysis in each.',
|
||||
'description' => __( 'The research brief sent to the LLM. Use {niche} as a placeholder for your niche description. The system prompt handles output format — this controls WHAT to research.', 'prautoblogger' ),
|
||||
],
|
||||
|
||||
// ── AI Models (extended) ────────────────────────────────────
|
||||
[
|
||||
'id' => 'prautoblogger_reasoning_enabled',
|
||||
'label' => __( 'Enable Reasoning', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_models',
|
||||
'default' => '0',
|
||||
'description' => __( 'Send reasoning instructions to models that support it (e.g. Grok, DeepSeek-R1). Reasoning tokens are billed as output tokens — expect higher costs per call. Models that don\'t support reasoning will ignore this.', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
'id' => 'prautoblogger_reasoning_effort',
|
||||
'label' => __( 'Reasoning Effort', 'prautoblogger' ),
|
||||
'type' => 'select',
|
||||
'section' => 'prautoblogger_models',
|
||||
'default' => 'medium',
|
||||
'options' => [
|
||||
'xhigh' => __( 'Extra High — maximum depth, highest cost', 'prautoblogger' ),
|
||||
'high' => __( 'High — thorough reasoning', 'prautoblogger' ),
|
||||
'medium' => __( 'Medium — balanced (recommended)', 'prautoblogger' ),
|
||||
'low' => __( 'Low — light reasoning, lower cost', 'prautoblogger' ),
|
||||
'minimal' => __( 'Minimal — barely any reasoning', 'prautoblogger' ),
|
||||
],
|
||||
'description' => __( 'How much effort the model spends reasoning before answering. Higher effort = more reasoning tokens = higher cost. Only applies when reasoning is enabled above.', 'prautoblogger' ),
|
||||
],
|
||||
|
||||
// ── Schedule & Budget ───────────────────────────────────────
|
||||
[
|
||||
'id' => 'prautoblogger_daily_article_target',
|
||||
'label' => __( 'Articles per Day', 'prautoblogger' ),
|
||||
'type' => 'number',
|
||||
'section' => 'prautoblogger_schedule',
|
||||
'default' => 1,
|
||||
'min' => 1,
|
||||
'max' => 10,
|
||||
],
|
||||
[
|
||||
'id' => 'prautoblogger_schedule_time',
|
||||
'label' => __( 'Generation Time', 'prautoblogger' ),
|
||||
'type' => 'time',
|
||||
'section' => 'prautoblogger_schedule',
|
||||
'default' => '03:00',
|
||||
'description' => sprintf(
|
||||
/* translators: %s: site timezone string, e.g. "Asia/Singapore". */
|
||||
esc_html__( "Daily generation runs at this time in your site's configured timezone (Settings → General → Timezone). Current site timezone: %s.", 'prautoblogger' ),
|
||||
esc_html( function_exists( 'wp_timezone_string' ) ? wp_timezone_string() : 'UTC' )
|
||||
),
|
||||
],
|
||||
[
|
||||
'id' => 'prautoblogger_monthly_budget_usd',
|
||||
'label' => __( 'Monthly Budget (USD)', 'prautoblogger' ),
|
||||
'type' => 'number',
|
||||
'section' => 'prautoblogger_schedule',
|
||||
'default' => 50,
|
||||
'min' => 0,
|
||||
'step' => '0.01',
|
||||
'description' => __( 'Hard stop when reached. 0 = unlimited.', 'prautoblogger' ),
|
||||
],
|
||||
|
||||
// ── Publishing ──────────────────────────────────────────────
|
||||
[
|
||||
'id' => 'prautoblogger_auto_publish',
|
||||
'label' => __( 'Auto-Publish', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_publishing',
|
||||
'default' => '0',
|
||||
'description' => __( 'Automatically publish editor-approved posts. When off, all posts go to the Review Queue as drafts.', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
'id' => 'prautoblogger_default_author',
|
||||
'label' => __( 'Default Author', 'prautoblogger' ),
|
||||
'type' => 'author_select',
|
||||
'section' => 'prautoblogger_publishing',
|
||||
'default' => 0,
|
||||
'description' => __( 'WordPress user assigned as author of generated posts.', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
'id' => 'prautoblogger_default_category',
|
||||
'label' => __( 'Default Category', 'prautoblogger' ),
|
||||
'type' => 'category_select',
|
||||
'section' => 'prautoblogger_publishing',
|
||||
'default' => 0,
|
||||
'description' => __( 'Fallback category for generated posts.', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
'id' => 'prautoblogger_log_level',
|
||||
'label' => __( 'Log Level', 'prautoblogger' ),
|
||||
'type' => 'select',
|
||||
'section' => 'prautoblogger_publishing',
|
||||
'default' => 'info',
|
||||
'options' => [
|
||||
'error' => __( 'Error — only failures', 'prautoblogger' ),
|
||||
'warning' => __( 'Warning — errors + warnings', 'prautoblogger' ),
|
||||
'info' => __( 'Info — key events (recommended)', 'prautoblogger' ),
|
||||
'debug' => __( 'Debug — everything (verbose)', 'prautoblogger' ),
|
||||
],
|
||||
'description' => __( 'Controls detail level in the Activity Log.', 'prautoblogger' ),
|
||||
],
|
||||
|
||||
// ── Analytics ───────────────────────────────────────────────
|
||||
[
|
||||
'id' => 'prautoblogger_ga4_property_id',
|
||||
'label' => __( 'GA4 Property ID', 'prautoblogger' ),
|
||||
'type' => 'text',
|
||||
'section' => 'prautoblogger_analytics',
|
||||
'description' => __( 'Your GA4 Property ID — digits only, e.g. 123456789 (the code adds the "properties/" prefix internally). Find it in GA4 → Admin → Property details. Leave blank to skip GA4.', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
'id' => 'prautoblogger_ga4_credentials_json',
|
||||
'label' => __( 'Service Account JSON', 'prautoblogger' ),
|
||||
'type' => 'password',
|
||||
'section' => 'prautoblogger_analytics',
|
||||
'description' => __( 'Paste the full JSON key file for a service account with Analytics read access.', 'prautoblogger' ),
|
||||
],
|
||||
|
||||
// ── Display ────────────────────────────────────────────────
|
||||
[
|
||||
'id' => 'prautoblogger_article_font_family',
|
||||
'label' => __( 'Article Font', 'prautoblogger' ),
|
||||
'type' => 'select',
|
||||
'section' => 'prautoblogger_display',
|
||||
'default' => 'default',
|
||||
'options' => PRAutoBlogger_Article_Typography::get_font_choices(),
|
||||
'description' => __( 'Font family used for generated article body text. Serif fonts improve long-form readability.', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
'id' => 'prautoblogger_article_font_size',
|
||||
'label' => __( 'Article Font Size (px)', 'prautoblogger' ),
|
||||
'type' => 'number',
|
||||
'section' => 'prautoblogger_display',
|
||||
'default' => 0,
|
||||
'min' => 0,
|
||||
'max' => 32,
|
||||
'description' => __( 'Base font size for article body text. 0 uses the theme default (13px). Recommended: 16–18px for comfortable reading.', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
'id' => 'prautoblogger_table_borders',
|
||||
'label' => __( 'Table Borders', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_display',
|
||||
'default' => '1',
|
||||
'description' => __( 'Add visible borders, padding, and alternating row colors to tables in generated articles.', 'prautoblogger' ),
|
||||
],
|
||||
|
||||
// ── Images ─────────────────────────────────────────────────
|
||||
[
|
||||
'id' => 'prautoblogger_image_enabled',
|
||||
'label' => __( 'Enable Image Generation', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => '0',
|
||||
'description' => __( 'Generate images for each published article.', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
'id' => 'prautoblogger_image_b_enabled',
|
||||
'label' => __( 'Generate Second Image (B)', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => '1',
|
||||
'description' => __( 'Generate a second image from source data for A/B testing. Disabling saves one image generation + one LLM prompt rewrite per article.', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
'id' => 'prautoblogger_image_model',
|
||||
'label' => __( 'Image Model', 'prautoblogger' ),
|
||||
'type' => 'model_select',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL,
|
||||
'capability' => 'image_generation',
|
||||
'description' => __( 'Pick an image model. The provider (Runware or OpenRouter) is derived from the model registry on save, so mismatched pairs are no longer possible.', 'prautoblogger' ),
|
||||
'badge' => __( 'Quality', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
'id' => 'prautoblogger_runware_api_key',
|
||||
'label' => __( 'Runware API Key', 'prautoblogger' ),
|
||||
'type' => 'password',
|
||||
'section' => 'prautoblogger_images',
|
||||
'description' => __( 'Required when using a Runware FLUX model. Get your key at runware.ai/signup.', 'prautoblogger' ),
|
||||
'icon' => '🔑',
|
||||
],
|
||||
[
|
||||
'id' => 'prautoblogger_image_style_suffix',
|
||||
'label' => __( 'Style Suffix', 'prautoblogger' ),
|
||||
'type' => 'textarea',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX,
|
||||
'description' => __( 'Appended to every image prompt. Controls visual look. Changing mid-run causes visible style drift.', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
'id' => 'prautoblogger_image_prompt_instructions',
|
||||
'label' => __( 'Image Prompt Instructions', 'prautoblogger' ),
|
||||
'type' => 'textarea',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => PRAutoBlogger_Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT,
|
||||
'description' => __( 'System prompt given to the rewriter LLM that turns each article into a SCENE + CAPTION for the image generator. Changing this reshapes the look of all future images. Leave blank to use the default.', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
'id' => 'prautoblogger_image_nsfw_retry',
|
||||
'label' => __( 'Retry NSFW-Blocked Images', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => '1',
|
||||
'description' => __( 'When the provider rejects an image prompt as NSFW, retry once with a generic fallback scene built from the article title. Disable to fail fast if the filter gets trigger-happy.', 'prautoblogger' ),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -6,15 +6,12 @@ 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: PRAutoBlogger_Settings_Fields_Extended for operational field definitions.
|
||||
* Dependencies: None — contains only data definitions and static methods.
|
||||
*
|
||||
* @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".
|
||||
* @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 {
|
||||
|
||||
@@ -60,34 +57,20 @@ 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' => __( 'Image provider, model, and style controls. OpenRouter reuses your existing API key.', 'prautoblogger' ),
|
||||
'description' => __( 'Cloudflare Workers AI credentials and image style controls.', 'prautoblogger' ),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all settings fields. Core fields here, operational fields merged from Extended.
|
||||
* Get all settings fields. Adding a new setting = adding one entry to this array.
|
||||
*
|
||||
* @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 ────────────────────────────────────────────────
|
||||
[
|
||||
@@ -130,30 +113,27 @@ class PRAutoBlogger_Settings_Fields {
|
||||
[
|
||||
'id' => 'prautoblogger_analysis_model',
|
||||
'label' => __( 'Analysis Model', 'prautoblogger' ),
|
||||
'type' => 'model_select',
|
||||
'type' => 'text',
|
||||
'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' => 'model_select',
|
||||
'type' => 'text',
|
||||
'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' => 'model_select',
|
||||
'type' => 'text',
|
||||
'section' => 'prautoblogger_models',
|
||||
'default' => PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL,
|
||||
'capability' => 'text→text',
|
||||
'description' => __( 'Used for the chief editor review pass.', 'prautoblogger' ),
|
||||
'badge' => __( 'Quality', 'prautoblogger' ),
|
||||
],
|
||||
@@ -166,14 +146,6 @@ 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' ),
|
||||
@@ -215,22 +187,6 @@ 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' ),
|
||||
@@ -253,8 +209,7 @@ class PRAutoBlogger_Settings_Fields {
|
||||
'type' => 'checkboxes',
|
||||
'section' => 'prautoblogger_sources',
|
||||
'options' => [
|
||||
'reddit' => __( 'Reddit (RSS + .json)', 'prautoblogger' ),
|
||||
'llm_research' => __( 'LLM Deep Research (reasoning model)', 'prautoblogger' ),
|
||||
'reddit' => __( 'Reddit (RSS + .json)', 'prautoblogger' ),
|
||||
],
|
||||
'default' => '["reddit"]',
|
||||
'description' => __( 'Select which platforms to monitor for topics.', 'prautoblogger' ),
|
||||
@@ -292,6 +247,140 @@ 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' ),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
<?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 );
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Renders the Reddit source status indicator in the admin settings.
|
||||
*
|
||||
* What: shows RSS (primary) and .json (fallback) availability with
|
||||
* last-collection timestamp.
|
||||
* Who calls it: PRAutoBlogger_Admin_Page::render_field() delegates here
|
||||
* for fields with type 'source_status'.
|
||||
* Dependencies: None — reads wp_options only.
|
||||
*
|
||||
* @see admin/class-admin-page.php — Calls render() from the main field switch.
|
||||
*/
|
||||
class PRAutoBlogger_Source_Status_Field {
|
||||
|
||||
/**
|
||||
* Render the Reddit source status indicator.
|
||||
*
|
||||
* Side effects: reads wp_options for last collection time.
|
||||
*/
|
||||
public static function render(): void {
|
||||
$last_collection = get_option( 'prautoblogger_last_collection_time', '' );
|
||||
$last_source_used = get_option( 'prautoblogger_last_source_used', '' );
|
||||
|
||||
echo '<div class="ab-source-status">';
|
||||
|
||||
// Reddit RSS — primary source.
|
||||
printf(
|
||||
'<div class="ab-source-row">'
|
||||
. '<span class="ab-source-dot ab-status-ok"></span>'
|
||||
. '<strong>%s</strong> <span class="ab-source-badge">%s</span>'
|
||||
. '<span class="ab-source-label">%s</span>'
|
||||
. '</div>',
|
||||
esc_html__( 'Reddit RSS', 'prautoblogger' ),
|
||||
esc_html__( 'Primary', 'prautoblogger' ),
|
||||
esc_html__( 'Reliable from all IPs', 'prautoblogger' )
|
||||
);
|
||||
|
||||
// Reddit .json — fallback source.
|
||||
printf(
|
||||
'<div class="ab-source-row">'
|
||||
. '<span class="ab-source-dot ab-status-ok"></span>'
|
||||
. '<strong>%s</strong> <span class="ab-source-badge ab-badge-secondary">%s</span>'
|
||||
. '<span class="ab-source-label">%s</span>'
|
||||
. '</div>',
|
||||
esc_html__( 'Reddit .json', 'prautoblogger' ),
|
||||
esc_html__( 'Fallback + Comments', 'prautoblogger' ),
|
||||
esc_html__( 'May be IP-blocked on some hosts', 'prautoblogger' )
|
||||
);
|
||||
|
||||
// Last collection info.
|
||||
if ( '' !== $last_collection ) {
|
||||
$time_ago = human_time_diff( (int) $last_collection, time() );
|
||||
printf(
|
||||
'<div class="ab-source-meta">%s <strong>%s</strong> %s</div>',
|
||||
esc_html__( 'Last collection:', 'prautoblogger' ),
|
||||
/* translators: %s is a human-readable time difference like "2 hours" */
|
||||
esc_html( sprintf( __( '%s ago', 'prautoblogger' ), $time_ago ) ),
|
||||
'' !== $last_source_used ? esc_html( sprintf( __( 'via %s', 'prautoblogger' ), $last_source_used ) ) : ''
|
||||
);
|
||||
}
|
||||
|
||||
echo '</div>';
|
||||
}
|
||||
}
|
||||
@@ -28,15 +28,121 @@ class PRAutoBlogger_Activator {
|
||||
self::create_tables();
|
||||
self::set_default_options();
|
||||
self::schedule_cron();
|
||||
self::backpopulate_run_id_meta_v081();
|
||||
|
||||
// Store the DB version so we can run migrations on future updates.
|
||||
update_option( 'prautoblogger_db_version', PRAUTOBLOGGER_DB_VERSION, false );
|
||||
}
|
||||
|
||||
/** Delegate to `PRAutoBlogger_Schema_Installer::install()` — see that class. */
|
||||
/**
|
||||
* Create all custom database tables using dbDelta.
|
||||
*
|
||||
* Side effects: creates/updates four database tables.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function create_tables(): void {
|
||||
PRAutoBlogger_Schema_Installer::install();
|
||||
global $wpdb;
|
||||
|
||||
$charset_collate = $wpdb->get_charset_collate();
|
||||
$prefix = $wpdb->prefix . 'prautoblogger_';
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
|
||||
// Source data — raw posts/comments from social platforms.
|
||||
$sql_source_data = "CREATE TABLE {$prefix}source_data (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
source_type VARCHAR(50) NOT NULL,
|
||||
source_id VARCHAR(255) NOT NULL,
|
||||
subreddit VARCHAR(255) DEFAULT NULL,
|
||||
title TEXT DEFAULT NULL,
|
||||
content LONGTEXT DEFAULT NULL,
|
||||
author VARCHAR(255) DEFAULT NULL,
|
||||
score INT DEFAULT 0,
|
||||
comment_count INT DEFAULT 0,
|
||||
permalink VARCHAR(500) DEFAULT NULL,
|
||||
collected_at DATETIME NOT NULL,
|
||||
metadata_json LONGTEXT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY source_unique (source_type, source_id),
|
||||
KEY source_collected (source_type, collected_at)
|
||||
) {$charset_collate};";
|
||||
|
||||
// Analysis results — detected patterns from source data.
|
||||
$sql_analysis = "CREATE TABLE {$prefix}analysis_results (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
analysis_type VARCHAR(50) NOT NULL,
|
||||
topic VARCHAR(500) NOT NULL,
|
||||
summary TEXT DEFAULT NULL,
|
||||
frequency INT DEFAULT 1,
|
||||
relevance_score FLOAT DEFAULT 0,
|
||||
source_ids_json LONGTEXT DEFAULT NULL,
|
||||
analyzed_at DATETIME NOT NULL,
|
||||
metadata_json LONGTEXT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY type_relevance (analysis_type, relevance_score),
|
||||
KEY analyzed_at (analyzed_at)
|
||||
) {$charset_collate};";
|
||||
|
||||
// Generation log — every API call with cost tracking.
|
||||
// run_id groups all log entries from a single pipeline execution so
|
||||
// link_generation_logs() can accurately attribute costs to a post.
|
||||
$sql_generation_log = "CREATE TABLE {$prefix}generation_log (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
post_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
run_id VARCHAR(36) DEFAULT NULL,
|
||||
stage VARCHAR(50) NOT NULL,
|
||||
provider VARCHAR(50) NOT NULL,
|
||||
model VARCHAR(100) NOT NULL,
|
||||
prompt_tokens INT DEFAULT 0,
|
||||
completion_tokens INT DEFAULT 0,
|
||||
estimated_cost DECIMAL(10,6) DEFAULT 0,
|
||||
request_json LONGTEXT DEFAULT NULL,
|
||||
response_status VARCHAR(20) NOT NULL DEFAULT 'success',
|
||||
error_message TEXT DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY post_id (post_id),
|
||||
KEY run_id (run_id),
|
||||
KEY created_at (created_at),
|
||||
KEY stage (stage)
|
||||
) {$charset_collate};";
|
||||
|
||||
// Content scores — post performance metrics for self-improvement.
|
||||
$sql_content_scores = "CREATE TABLE {$prefix}content_scores (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
post_id BIGINT UNSIGNED NOT NULL,
|
||||
pageviews INT DEFAULT 0,
|
||||
avg_time_on_page FLOAT DEFAULT 0,
|
||||
bounce_rate FLOAT DEFAULT 0,
|
||||
comment_count INT DEFAULT 0,
|
||||
composite_score FLOAT DEFAULT 0,
|
||||
score_factors_json LONGTEXT DEFAULT NULL,
|
||||
measured_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY post_id (post_id),
|
||||
KEY measured_at (measured_at),
|
||||
KEY composite_score (composite_score)
|
||||
) {$charset_collate};";
|
||||
|
||||
// Event log — structured application log entries for the admin log viewer.
|
||||
$sql_event_log = "CREATE TABLE {$prefix}event_log (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
level VARCHAR(10) NOT NULL DEFAULT 'info',
|
||||
context VARCHAR(100) DEFAULT NULL,
|
||||
message TEXT NOT NULL,
|
||||
meta_json LONGTEXT DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY level (level),
|
||||
KEY created_at (created_at),
|
||||
KEY level_created (level, created_at)
|
||||
) {$charset_collate};";
|
||||
|
||||
dbDelta( $sql_source_data );
|
||||
dbDelta( $sql_analysis );
|
||||
dbDelta( $sql_generation_log );
|
||||
dbDelta( $sql_content_scores );
|
||||
dbDelta( $sql_event_log );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,12 +169,8 @@ 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',
|
||||
'prautoblogger_image_nsfw_retry' => '1',
|
||||
];
|
||||
|
||||
foreach ( $defaults as $key => $value ) {
|
||||
@@ -90,8 +192,14 @@ class PRAutoBlogger_Activator {
|
||||
*/
|
||||
private static function schedule_cron(): void {
|
||||
if ( ! wp_next_scheduled( 'prautoblogger_daily_generation' ) ) {
|
||||
$timestamp = self::next_daily_generation_timestamp();
|
||||
if ( $timestamp > 0 ) {
|
||||
// Schedule for the configured time tomorrow.
|
||||
$time_str = get_option( 'prautoblogger_schedule_time', '03:00' );
|
||||
$parts = explode( ':', $time_str );
|
||||
$hour = isset( $parts[0] ) ? absint( $parts[0] ) : 3;
|
||||
$minute = isset( $parts[1] ) ? absint( $parts[1] ) : 0;
|
||||
$timestamp = strtotime( "tomorrow {$hour}:{$minute}" );
|
||||
|
||||
if ( false !== $timestamp ) {
|
||||
wp_schedule_event( $timestamp, 'daily', 'prautoblogger_daily_generation' );
|
||||
}
|
||||
}
|
||||
@@ -114,156 +222,5 @@ class PRAutoBlogger_Activator {
|
||||
if ( ! wp_next_scheduled( 'prautoblogger_collect_metrics' ) ) {
|
||||
wp_schedule_event( time() + HOUR_IN_SECONDS, 'prautoblogger_six_hours', 'prautoblogger_collect_metrics' );
|
||||
}
|
||||
|
||||
// v0.8.1: daily reaper for orphan `llm_research` rows. Pinned to
|
||||
// 03:15 so it runs 15 min after the primary generation cron, giving
|
||||
// any live pipeline time to finish and call amortize_research_costs
|
||||
// cleanly before the reaper scans.
|
||||
if ( ! wp_next_scheduled( 'prautoblogger_reap_orphan_research_rows' ) ) {
|
||||
$tomorrow = strtotime( 'tomorrow 03:15' );
|
||||
if ( false !== $tomorrow ) {
|
||||
wp_schedule_event( $tomorrow, 'daily', 'prautoblogger_reap_orphan_research_rows' );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the next daily-generation timestamp in the site's configured
|
||||
* timezone. Fixes the v<0.8.2 bug where `strtotime("tomorrow HH:MM")`
|
||||
* evaluated in UTC (WP core sets `date_default_timezone_set('UTC')`),
|
||||
* firing the cron N hours off from user intent where N is the site's
|
||||
* UTC offset.
|
||||
*
|
||||
* @return int Unix timestamp, or 0 on any failure.
|
||||
*/
|
||||
public static function next_daily_generation_timestamp(): int {
|
||||
$time_str = (string) get_option( 'prautoblogger_schedule_time', '03:00' );
|
||||
$parts = explode( ':', $time_str );
|
||||
$hour = isset( $parts[0] ) ? absint( $parts[0] ) : 3;
|
||||
$minute = isset( $parts[1] ) ? absint( $parts[1] ) : 0;
|
||||
|
||||
try {
|
||||
$site_tz = function_exists( 'wp_timezone' )
|
||||
? wp_timezone()
|
||||
: new \DateTimeZone( 'UTC' );
|
||||
$local = ( new \DateTimeImmutable( 'tomorrow', $site_tz ) )
|
||||
->setTime( $hour, $minute, 0 );
|
||||
return (int) $local->getTimestamp();
|
||||
} catch ( \Exception $e ) {
|
||||
// Defensive: never leave the site without any schedule because
|
||||
// of a bad tz string. Fall back to the pre-fix behaviour.
|
||||
$timestamp = strtotime( "tomorrow {$hour}:{$minute}" );
|
||||
return false === $timestamp ? 0 : (int) $timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.8.2 one-shot migration — reschedule `prautoblogger_daily_generation`
|
||||
* in the site's timezone.
|
||||
*
|
||||
* Existing installs had their daily cron scheduled with the v<0.8.2
|
||||
* UTC-interpretation bug. This clears the stale event and reschedules it
|
||||
* using the timezone-aware helper, exactly once per site (gated by
|
||||
* `prautoblogger_migrated_schedule_tz_v082`). Logs an INFO line with the
|
||||
* before/after next-run times so the outcome is auditable.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function reschedule_daily_in_site_timezone_v082(): void {
|
||||
if ( get_option( 'prautoblogger_migrated_schedule_tz_v082' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$old_timestamp = wp_next_scheduled( 'prautoblogger_daily_generation' );
|
||||
wp_clear_scheduled_hook( 'prautoblogger_daily_generation' );
|
||||
|
||||
$new_timestamp = self::next_daily_generation_timestamp();
|
||||
if ( $new_timestamp > 0 ) {
|
||||
wp_schedule_event( $new_timestamp, 'daily', 'prautoblogger_daily_generation' );
|
||||
}
|
||||
|
||||
if ( class_exists( 'PRAutoBlogger_Logger', false ) ) {
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf(
|
||||
'Rescheduled daily generation in site timezone (was UTC). Old next_run: %s, new next_run: %s.',
|
||||
false !== $old_timestamp ? gmdate( 'Y-m-d H:i:s \\U\\T\\C', (int) $old_timestamp ) : 'unscheduled',
|
||||
$new_timestamp > 0 ? gmdate( 'Y-m-d H:i:s \\U\\T\\C', $new_timestamp ) : 'unscheduled'
|
||||
),
|
||||
'activator'
|
||||
);
|
||||
}
|
||||
|
||||
update_option( 'prautoblogger_migrated_schedule_tz_v082', '1' );
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot v0.8.1 migration — back-populate `_prautoblogger_run_id`
|
||||
* post_meta for existing posts from their gen-log rows.
|
||||
*
|
||||
* Lets the orphan-research-row reaper (v0.8.1+) attribute historic
|
||||
* orphan rows to sibling posts via post_meta lookup, without having
|
||||
* to walk the generation_log table. Gated by
|
||||
* `prautoblogger_migrated_run_id_meta_v081` so it runs exactly once
|
||||
* per site.
|
||||
*
|
||||
* Side effects: adds post_meta rows for existing PRAutoBlogger posts
|
||||
* that don't already have `_prautoblogger_run_id`.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function backpopulate_run_id_meta_v081(): void {
|
||||
if ( get_option( 'prautoblogger_migrated_run_id_meta_v081' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$gen_log = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||
|
||||
// For each (post_id, run_id) pair present in the gen-log, write
|
||||
// the run_id meta if it's not already set. Uses the first run_id
|
||||
// seen per post — in practice each post is linked to exactly one run.
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$pairs = $wpdb->get_results(
|
||||
"SELECT post_id, MIN(run_id) AS run_id FROM {$gen_log}
|
||||
WHERE post_id IS NOT NULL AND run_id IS NOT NULL AND run_id != ''
|
||||
GROUP BY post_id"
|
||||
);
|
||||
|
||||
if ( is_array( $pairs ) ) {
|
||||
foreach ( $pairs as $row ) {
|
||||
$post_id = (int) ( $row->post_id ?? 0 );
|
||||
$run_id = (string) ( $row->run_id ?? '' );
|
||||
if ( $post_id > 0 && '' !== $run_id && ! metadata_exists( 'post', $post_id, '_prautoblogger_run_id' ) ) {
|
||||
update_post_meta( $post_id, '_prautoblogger_run_id', $run_id );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
update_option( 'prautoblogger_migrated_run_id_meta_v081', '1' );
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.9.0 migration: flip legacy default image model to Runware schnell
|
||||
* and re-derive provider. CEO decision 2026-04-21 — 65× cheaper than
|
||||
* Gemini Nano Banana. Explicit user selections are preserved.
|
||||
*
|
||||
* Side effects: may update `prautoblogger_image_model`,
|
||||
* `prautoblogger_image_provider`, and sets the
|
||||
* `prautoblogger_migrated_default_image_v090` flag.
|
||||
*/
|
||||
public static function migrate_default_image_v090(): void {
|
||||
if ( get_option( 'prautoblogger_migrated_default_image_v090' ) ) {
|
||||
return;
|
||||
}
|
||||
$curr = (string) get_option( 'prautoblogger_image_model', '' );
|
||||
if ( in_array( $curr, [ '', 'google/gemini-2.5-flash-image' ], true ) ) {
|
||||
update_option( 'prautoblogger_image_model', 'runware:100@1' );
|
||||
}
|
||||
$final = (string) get_option( 'prautoblogger_image_model', 'runware:100@1' );
|
||||
$prov = PRAutoBlogger_Image_Model_Registry::provider_for( $final );
|
||||
if ( '' !== $prov ) {
|
||||
update_option( 'prautoblogger_image_provider', $prov );
|
||||
}
|
||||
update_option( 'prautoblogger_migrated_default_image_v090', '1' );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* AJAX endpoint handlers for PRAutoBlogger admin actions.
|
||||
*
|
||||
* What: Handles image generation, model registry, and connection test AJAX requests.
|
||||
* Who calls it: PRAutoBlogger registers wp_ajax hooks that delegate to methods here.
|
||||
* Dependencies: PRAutoBlogger_Image_Pipeline, PRAutoBlogger_Source_Collector,
|
||||
* PRAutoBlogger_OpenRouter_Provider, PRAutoBlogger_Reddit_Provider,
|
||||
* PRAutoBlogger_OpenRouter_Model_Registry, PRAutoBlogger_Logger.
|
||||
*
|
||||
* @see class-prautoblogger.php — Hook registration that wires these handlers.
|
||||
* @see class-executor.php — Generation-related AJAX lives there (generate_now, status).
|
||||
*/
|
||||
class PRAutoBlogger_Ajax_Handlers {
|
||||
|
||||
/** @var PRAutoBlogger_OpenRouter_Model_Registry Model registry instance (shared with Executor). */
|
||||
private PRAutoBlogger_OpenRouter_Model_Registry $model_registry;
|
||||
|
||||
/**
|
||||
* @param PRAutoBlogger_OpenRouter_Model_Registry $model_registry Shared model registry.
|
||||
*/
|
||||
public function __construct( PRAutoBlogger_OpenRouter_Model_Registry $model_registry ) {
|
||||
$this->model_registry = $model_registry;
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX handler: generate images for existing posts that lack them.
|
||||
*
|
||||
* Accepts a `post_id` parameter. Generates Image A (article-driven) and
|
||||
* sets it as the featured image. Useful for retroactively adding images
|
||||
* to posts published before image generation was enabled/fixed.
|
||||
*
|
||||
* Side effects: image provider API call, media library write, post meta update.
|
||||
*/
|
||||
public function on_ajax_generate_image(): void {
|
||||
check_ajax_referer( 'prautoblogger_generate_image', 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 );
|
||||
return;
|
||||
}
|
||||
|
||||
// Image generation can take 20-30s (Runware FLUX + OpenRouter image models).
|
||||
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
@set_time_limit( 120 );
|
||||
|
||||
$post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0;
|
||||
if ( 0 === $post_id ) {
|
||||
wp_send_json_error( [ 'message' => __( 'Missing post_id parameter.', 'prautoblogger' ) ] );
|
||||
return;
|
||||
}
|
||||
|
||||
$post = get_post( $post_id );
|
||||
if ( ! $post ) {
|
||||
wp_send_json_error( [ 'message' => sprintf( __( 'Post %d not found.', 'prautoblogger' ), $post_id ) ] );
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$pipeline = new PRAutoBlogger_Image_Pipeline();
|
||||
$article_data = [
|
||||
'post_title' => $post->post_title,
|
||||
'post_content' => $post->post_content,
|
||||
];
|
||||
|
||||
// Retrieve original source IDs from post meta so Image B can generate
|
||||
// a source-driven prompt even on retroactive regeneration.
|
||||
$source_ids_json = get_post_meta( $post_id, '_prautoblogger_source_ids', true );
|
||||
$source_ids = is_string( $source_ids_json ) ? json_decode( $source_ids_json, true ) : [];
|
||||
$source_data = ( new PRAutoBlogger_Source_Collector() )->get_source_data_for_image(
|
||||
is_array( $source_ids ) ? array_map( 'absint', $source_ids ) : []
|
||||
);
|
||||
|
||||
// The pipeline sets featured image and Image B meta internally.
|
||||
$result = $pipeline->generate_and_attach_images( $post_id, $article_data, $source_data );
|
||||
|
||||
wp_send_json_success( $result );
|
||||
} catch ( \Throwable $e ) {
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
sprintf( 'Retroactive image gen %s for post %d: %s', get_class( $e ), $post_id, $e->getMessage() ),
|
||||
'image_pipeline'
|
||||
);
|
||||
wp_send_json_error( [ 'message' => $e->getMessage() ] );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX handler: return the model registry for the admin model picker.
|
||||
*
|
||||
* Returns the full cached model list. If the registry is empty, triggers
|
||||
* a refresh first. Called by the model-picker.js popup.
|
||||
*
|
||||
* Side effects: may trigger an OpenRouter API call if registry is empty.
|
||||
*/
|
||||
public function on_ajax_get_models(): void {
|
||||
check_ajax_referer( 'prautoblogger_get_models', 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 );
|
||||
return;
|
||||
}
|
||||
|
||||
$models = $this->model_registry->get_models();
|
||||
|
||||
// Auto-refresh if the registry has never been populated.
|
||||
if ( empty( $models ) ) {
|
||||
$this->model_registry->refresh( true );
|
||||
$models = $this->model_registry->get_models();
|
||||
}
|
||||
|
||||
wp_send_json_success( $models );
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX handler: test API connections (OpenRouter, Reddit).
|
||||
*
|
||||
* Accepts a `service` parameter: 'openrouter', 'reddit', or 'all'.
|
||||
*
|
||||
* Side effects: API calls to OpenRouter and/or Reddit to validate credentials.
|
||||
*/
|
||||
public function on_ajax_test_connection(): void {
|
||||
check_ajax_referer( 'prautoblogger_test_connection', 'nonce' );
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 );
|
||||
return;
|
||||
}
|
||||
|
||||
$service = isset( $_POST['service'] ) ? sanitize_text_field( wp_unslash( $_POST['service'] ) ) : '';
|
||||
$results = [];
|
||||
|
||||
if ( 'openrouter' === $service || 'all' === $service ) {
|
||||
$results['openrouter'] = ( new PRAutoBlogger_OpenRouter_Provider() )->validate_credentials_detailed();
|
||||
}
|
||||
if ( 'reddit' === $service || 'all' === $service ) {
|
||||
$reddit = new PRAutoBlogger_Reddit_Provider();
|
||||
$is_ok = $reddit->validate_credentials();
|
||||
$results['reddit'] = $is_ok
|
||||
? [ 'status' => 'ok', 'message' => __( 'Reddit source available (RSS primary, .json fallback).', 'prautoblogger' ) ]
|
||||
: [ 'status' => 'error', 'message' => __( 'Reddit sources unreachable (both RSS and .json failed).', 'prautoblogger' ) ];
|
||||
}
|
||||
wp_send_json_success( $results );
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,6 @@ class PRAutoBlogger_Autoloader {
|
||||
private static array $directories = [
|
||||
'', // includes/ root
|
||||
'admin/',
|
||||
'admin/fields/',
|
||||
'core/',
|
||||
'providers/',
|
||||
'models/',
|
||||
|
||||
@@ -36,7 +36,6 @@ class PRAutoBlogger_Deactivator {
|
||||
$hooks = [
|
||||
'prautoblogger_daily_generation',
|
||||
'prautoblogger_collect_metrics',
|
||||
'prautoblogger_reap_orphan_research_rows',
|
||||
];
|
||||
|
||||
foreach ( $hooks as $hook ) {
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
<?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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* One-shot v0.10.0 migration — remove Cloudflare Workers AI as image provider.
|
||||
*
|
||||
* Cloudflare Workers AI was removed as an image provider in v0.10.0.
|
||||
* Runware FLUX.1 schnell is 65× cheaper at equivalent quality, so any user
|
||||
* still on a `cloudflare` provider must be migrated off — otherwise the
|
||||
* image pipeline throws "no provider for id cloudflare" at the first
|
||||
* generation run after upgrade.
|
||||
*
|
||||
* Split out of class-activator.php so that file stays under the 300-line
|
||||
* cap. The activator's maybe_run_db_migrations() delegates here.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger::maybe_run_db_migrations() once per admin_init
|
||||
* until the `prautoblogger_migrated_remove_cloudflare_v0100`
|
||||
* flag is set.
|
||||
* Dependencies: PRAutoBlogger_Image_Model_Registry::provider_for() for
|
||||
* current-model provider derivation.
|
||||
*
|
||||
* @see class-activator.php — Parallel v0.9.0 migration.
|
||||
* @see class-prautoblogger.php — Calls run() during admin_init.
|
||||
* @see class-image-model-registry.php — Provider derivation source.
|
||||
* @see convo/prautoblogger/threads/2026-04-cloudflare-workers-ai-removal/
|
||||
*/
|
||||
class PRAutoBlogger_Migrate_Remove_Cloudflare_V0100 {
|
||||
|
||||
/**
|
||||
* Migration flag option key. Presence = already migrated.
|
||||
*/
|
||||
private const FLAG = 'prautoblogger_migrated_remove_cloudflare_v0100';
|
||||
|
||||
/**
|
||||
* Run the migration. Idempotent — second + later calls are no-ops.
|
||||
*
|
||||
* Side effects: may update `prautoblogger_image_model` +
|
||||
* `prautoblogger_image_provider`; always deletes
|
||||
* `prautoblogger_cloudflare_ai_token`,
|
||||
* `prautoblogger_cloudflare_account_id`,
|
||||
* `prautoblogger_cf_image_via_gateway`; sets the
|
||||
* migration flag option.
|
||||
*
|
||||
* Explicitly does NOT touch `prautoblogger_ai_gateway_base_url` —
|
||||
* that option is shared with the OpenRouter AI Gateway route and
|
||||
* must stay intact.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function run(): void {
|
||||
if ( get_option( self::FLAG ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( self::is_user_on_cloudflare() ) {
|
||||
// Closest equivalent — FLUX.1 schnell, Runware-hosted, ~$0.0006/image.
|
||||
update_option( 'prautoblogger_image_model', 'runware:100@1' );
|
||||
update_option( 'prautoblogger_image_provider', 'runware' );
|
||||
}
|
||||
|
||||
// Clean up orphaned CF Workers AI credentials + the dead gateway toggle.
|
||||
delete_option( 'prautoblogger_cloudflare_ai_token' );
|
||||
delete_option( 'prautoblogger_cloudflare_account_id' );
|
||||
delete_option( 'prautoblogger_cf_image_via_gateway' );
|
||||
|
||||
update_option( self::FLAG, '1' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether the site is currently configured to use Cloudflare
|
||||
* Workers AI for image generation.
|
||||
*
|
||||
* Because the v0.10.0 image-model registry no longer contains
|
||||
* Cloudflare entries, `provider_for()` returns '' for legacy
|
||||
* `flux-1-schnell` slugs. We therefore look at three signals and
|
||||
* treat any of them as "on Cloudflare":
|
||||
* 1. Current registry-derived provider === 'cloudflare'.
|
||||
* 2. The legacy `prautoblogger_image_provider` option is 'cloudflare'.
|
||||
* 3. The legacy CF-specific slug `flux-1-schnell` is still persisted
|
||||
* as the image model (pre-registry-rename installs).
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private static function is_user_on_cloudflare(): bool {
|
||||
$curr = (string) get_option( 'prautoblogger_image_model', '' );
|
||||
$curr_provider = PRAutoBlogger_Image_Model_Registry::provider_for( $curr );
|
||||
|
||||
if ( 'cloudflare' === $curr_provider ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$legacy_provider = (string) get_option( 'prautoblogger_image_provider', '' );
|
||||
if ( 'cloudflare' === $legacy_provider ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return 'flux-1-schnell' === $curr;
|
||||
}
|
||||
}
|
||||
@@ -9,160 +9,144 @@ 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_Executor.
|
||||
* Dependencies: All other plugin classes (loaded via autoloader), PRAutoBlogger_Pipeline_Runner.
|
||||
*
|
||||
* @see prautoblogger.php — Plugin bootstrap that instantiates this class.
|
||||
* @see class-executor.php — Cron/AJAX handlers for generation, model registry.
|
||||
* @see class-ajax-handlers.php — Non-generation AJAX endpoints (images, models, test).
|
||||
* @see class-generation-lock.php — DB mutex for single-writer generation.
|
||||
* @see core/class-pipeline-runner.php — Executes the generation pipeline.
|
||||
* @see ARCHITECTURE.md — Data flow diagram showing how classes interact.
|
||||
* @see CONVENTIONS.md — Hook naming conventions.
|
||||
* @see prautoblogger.php — Plugin bootstrap that instantiates this class.
|
||||
* @see core/class-pipeline-runner.php — Executes the generation pipeline.
|
||||
* @see ARCHITECTURE.md — Data flow diagram showing how classes interact.
|
||||
* @see CONVENTIONS.md — Hook naming conventions.
|
||||
*/
|
||||
class PRAutoBlogger {
|
||||
|
||||
private bool $initialized = false;
|
||||
|
||||
/** @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;
|
||||
/** @var PRAutoBlogger_OpenRouter_Model_Registry|null Lazy-loaded singleton. */
|
||||
private ?PRAutoBlogger_OpenRouter_Model_Registry $model_registry = null;
|
||||
|
||||
/**
|
||||
* Register all hooks and initialize the plugin.
|
||||
* Called once on `plugins_loaded`. Idempotent.
|
||||
*
|
||||
* Called once on `plugins_loaded`. Idempotent — calling it twice is a no-op.
|
||||
*
|
||||
* Side effects: registers WordPress actions and filters.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function run(): void {
|
||||
if ( $this->initialized ) {
|
||||
return;
|
||||
}
|
||||
$this->initialized = true;
|
||||
$this->executor = new PRAutoBlogger_Executor();
|
||||
$this->ajax_handlers = new PRAutoBlogger_Ajax_Handlers( $this->executor->get_model_registry() );
|
||||
$this->initialized = true;
|
||||
|
||||
// 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. */
|
||||
/**
|
||||
* Fires after PRAutoBlogger has finished registering all hooks.
|
||||
*
|
||||
* Useful for add-ons that need to hook into PRAutoBlogger's lifecycle.
|
||||
*/
|
||||
do_action( 'prautoblogger_loaded' );
|
||||
}
|
||||
|
||||
/** Register admin-only hooks (settings, notices, metabox, dashboard widget). */
|
||||
/**
|
||||
* Register admin-only hooks (settings page, notices, metabox, dashboard widget).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function register_admin_hooks(): void {
|
||||
$admin_page = new PRAutoBlogger_Admin_Page();
|
||||
add_action( 'admin_menu', [ $admin_page, 'on_register_menu' ] );
|
||||
add_action( 'admin_init', [ $admin_page, 'on_register_settings' ] );
|
||||
add_action( 'admin_enqueue_scripts', [ $admin_page, 'on_enqueue_assets' ] );
|
||||
|
||||
add_action( 'admin_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' ] );
|
||||
$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' ] );
|
||||
|
||||
$review_queue = new PRAutoBlogger_Review_Queue();
|
||||
add_action( 'admin_menu', [ $review_queue, 'on_register_menu' ] );
|
||||
|
||||
$ideas_browser = new PRAutoBlogger_Ideas_Browser();
|
||||
add_action( 'admin_menu', [ $ideas_browser, 'on_register_menu' ] );
|
||||
add_action( 'admin_menu', [ new PRAutoBlogger_Log_Viewer(), 'on_register_menu' ] );
|
||||
$log_viewer = new PRAutoBlogger_Log_Viewer();
|
||||
add_action( 'admin_menu', [ $log_viewer, 'on_register_menu' ] );
|
||||
|
||||
( new PRAutoBlogger_Post_List_Columns() )->register();
|
||||
|
||||
// Block false update notifications — our slug collides with another plugin.
|
||||
// Block false update notifications from wordpress.org — our plugin name
|
||||
// collides with a different "PRAutoBlogger" in the plugin directory.
|
||||
add_filter( 'site_transient_update_plugins', [ $this, 'filter_block_false_updates' ] );
|
||||
}
|
||||
|
||||
/** Register frontend hooks (shortcode, REST, typography). */
|
||||
/**
|
||||
* Register frontend hooks (shortcode and REST API for posts widget).
|
||||
*
|
||||
* The shortcode renders a mount-point div; the React component hydrates it
|
||||
* on the client side by fetching posts from the REST endpoint.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function register_frontend_hooks(): void {
|
||||
$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->executor, 'on_daily_generation' ] );
|
||||
add_action( 'prautoblogger_collect_metrics', [ $this->executor, 'on_collect_metrics' ] );
|
||||
add_action( 'prautoblogger_daily_generation', [ $this, 'on_daily_generation' ] );
|
||||
add_action( 'prautoblogger_collect_metrics', [ $this, 'on_collect_metrics' ] );
|
||||
|
||||
// Manual "Generate Now" runs as a one-shot cron event to avoid
|
||||
// Hostinger's 120-second web-server connection timeout.
|
||||
add_action( 'prautoblogger_manual_generation', [ $this->executor, 'on_manual_generation' ] );
|
||||
|
||||
// Chained article generation — each queued article fires as its own
|
||||
// cron event so it gets a fresh PHP process and execution time budget.
|
||||
add_action(
|
||||
PRAutoBlogger_Pipeline_Runner::CRON_ACTION,
|
||||
[ $this->executor, 'on_process_article_queue' ]
|
||||
);
|
||||
|
||||
$registry = $this->executor->get_model_registry();
|
||||
// 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();
|
||||
add_action( 'prautoblogger_refresh_model_registry', [ $registry, 'refresh' ] );
|
||||
|
||||
// Single-idea generation from the Ideas browser page.
|
||||
add_action( 'prautoblogger_generate_from_idea', [ 'PRAutoBlogger_Ideas_Browser', 'on_cron_generate_from_idea' ] );
|
||||
|
||||
// v0.8.1: daily reaper for orphan `llm_research` rows (when a
|
||||
// pipeline dies before amortize_research_costs runs).
|
||||
add_action( 'prautoblogger_reap_orphan_research_rows', [ 'PRAutoBlogger_Research_Reaper', 'on_cron' ] );
|
||||
|
||||
// v0.8.1: WP-CLI manual trigger for the reaper. Only registers when
|
||||
// WP-CLI is present; no-op in normal HTTP requests.
|
||||
if ( defined( 'WP_CLI' ) && WP_CLI ) {
|
||||
\WP_CLI::add_command( 'prautoblogger reap-research', static function (): void {
|
||||
$stats = PRAutoBlogger_Research_Reaper::reap();
|
||||
\WP_CLI::success( sprintf(
|
||||
'Reaped %d, deleted %d (stale), skipped %d.',
|
||||
(int) $stats['reaped'],
|
||||
(int) $stats['deleted'],
|
||||
(int) $stats['skipped']
|
||||
) );
|
||||
} );
|
||||
}
|
||||
}
|
||||
|
||||
/** Register AJAX handlers for admin actions. */
|
||||
private function register_ajax_hooks(): void {
|
||||
// Generation AJAX (start + status polling) stays on executor.
|
||||
add_action( 'wp_ajax_prautoblogger_generate_now', [ $this->executor, 'on_ajax_generate_now' ] );
|
||||
add_action( 'wp_ajax_prautoblogger_generation_status', [ $this->executor, 'on_ajax_generation_status' ] );
|
||||
|
||||
// Non-generation AJAX (images, models, test) on dedicated handler.
|
||||
add_action( 'wp_ajax_prautoblogger_generate_image', [ $this->ajax_handlers, 'on_ajax_generate_image' ] );
|
||||
add_action( 'wp_ajax_prautoblogger_test_connection', [ $this->ajax_handlers, 'on_ajax_test_connection' ] );
|
||||
add_action( 'wp_ajax_prautoblogger_get_models', [ $this->ajax_handlers, 'on_ajax_get_models' ] );
|
||||
add_action( 'wp_ajax_prautoblogger_generate_now', [ $this, 'on_ajax_generate_now' ] );
|
||||
add_action( 'wp_ajax_prautoblogger_test_connection', [ $this, 'on_ajax_test_connection' ] );
|
||||
|
||||
$review_queue = new PRAutoBlogger_Review_Queue();
|
||||
add_action( 'wp_ajax_prautoblogger_approve_post', [ $review_queue, 'on_ajax_approve_post' ] );
|
||||
add_action( 'wp_ajax_prautoblogger_reject_post', [ $review_queue, 'on_ajax_reject_post' ] );
|
||||
|
||||
add_action( 'wp_ajax_prautoblogger_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' ] );
|
||||
$log_viewer = new PRAutoBlogger_Log_Viewer();
|
||||
add_action( 'wp_ajax_prautoblogger_clear_logs', [ $log_viewer, 'on_ajax_clear_logs' ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if database needs migration after plugin update.
|
||||
* Compares stored DB version with PRAUTOBLOGGER_DB_VERSION; re-runs activation if different.
|
||||
*
|
||||
* Compares stored DB version with current PRAUTOBLOGGER_DB_VERSION.
|
||||
* If different, re-runs activation to apply schema changes via dbDelta.
|
||||
*
|
||||
* Side effects: may update database schema and db_version option.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function on_check_db_version(): void {
|
||||
$stored_version = get_option( 'prautoblogger_db_version', '0' );
|
||||
@@ -178,68 +162,15 @@ class PRAutoBlogger {
|
||||
update_option( 'prautoblogger_migrated_gemini_flash_lite', '1' );
|
||||
}
|
||||
|
||||
// One-time migration (v0.8.0): the admin no longer has an independent
|
||||
// Image Provider dropdown; provider is derived from the image model on
|
||||
// save. Auto-heal any existing site where the saved provider doesn't
|
||||
// match the saved model's provider (the root cause of posts 650/657
|
||||
// silently missing their featured image on 2026-04-20). Runs once.
|
||||
if ( ! get_option( 'prautoblogger_migrated_image_provider_v080' ) ) {
|
||||
$saved_model = (string) get_option( 'prautoblogger_image_model', '' );
|
||||
$provider = PRAutoBlogger_Image_Model_Registry::provider_for( $saved_model );
|
||||
if ( '' !== $provider ) {
|
||||
update_option( 'prautoblogger_image_provider', $provider );
|
||||
}
|
||||
update_option( 'prautoblogger_migrated_image_provider_v080', '1' );
|
||||
}
|
||||
|
||||
// One-time migration (v0.8.2): reschedule the daily-generation cron in
|
||||
// the site's configured timezone. Pre-v0.8.2 activator interpreted the
|
||||
// admin "Generation Time" input as UTC; after v0.8.2 it honours the
|
||||
// site timezone. Clears the stale UTC-scheduled event and re-queues
|
||||
// using the timezone-aware helper. See class-activator.php.
|
||||
PRAutoBlogger_Activator::reschedule_daily_in_site_timezone_v082();
|
||||
|
||||
// One-time migration (v3): switch to single-panel newspaper comic style.
|
||||
// Replaces both the old infomercial pastiche and the short-lived premium
|
||||
// photography style. Force-update unless the user has a truly custom value.
|
||||
if ( ! get_option( 'prautoblogger_migrated_style_suffix_v3' ) ) {
|
||||
$known_old_prefixes = [
|
||||
'Style: a screengrab from a 1995', // v1: infomercial.
|
||||
'Style: premium scientific lifestyle', // v2: photography.
|
||||
];
|
||||
$current = get_option( 'prautoblogger_image_style_suffix', '' );
|
||||
$is_old = ( '' === $current );
|
||||
foreach ( $known_old_prefixes as $prefix ) {
|
||||
if ( false !== strpos( $current, $prefix ) ) {
|
||||
$is_old = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ( $is_old ) {
|
||||
update_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
|
||||
}
|
||||
update_option( 'prautoblogger_migrated_style_suffix_v3', '1' );
|
||||
}
|
||||
// One-time migration (v4): remove caption-in-image instruction from style suffix.
|
||||
// Captions are now inserted as HTML below the image, not baked into the image.
|
||||
if ( ! get_option( 'prautoblogger_migrated_style_suffix_v4' ) ) {
|
||||
$current = get_option( 'prautoblogger_image_style_suffix', '' );
|
||||
// Detect v3 style suffix (contains "caption text" instruction).
|
||||
if ( false !== strpos( $current, 'caption text in a clean sans-serif font below the panel' ) ) {
|
||||
update_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
|
||||
}
|
||||
update_option( 'prautoblogger_migrated_style_suffix_v4', '1' );
|
||||
}
|
||||
|
||||
// v0.9.0 — Runware as default image model. v0.10.0 — remove CF Workers AI.
|
||||
PRAutoBlogger_Activator::migrate_default_image_v090();
|
||||
PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
|
||||
|
||||
// One-time migration: re-wrap existing encrypted values with "enc:" prefix.
|
||||
// One-time migration: re-wrap existing encrypted values with "enc:" prefix
|
||||
// to prevent the double-encryption bug permanently.
|
||||
if ( ! get_option( 'prautoblogger_migrated_enc_prefix' ) ) {
|
||||
$enc_options = [ 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json', 'prautoblogger_runware_api_key' ];
|
||||
$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 );
|
||||
}
|
||||
@@ -252,7 +183,8 @@ 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}>
|
||||
*
|
||||
* @return array<string, array{interval: int, display: string}> Modified schedules.
|
||||
*/
|
||||
public function filter_add_cron_schedules( array $schedules ): array {
|
||||
$schedules['prautoblogger_six_hours'] = [
|
||||
@@ -264,37 +196,209 @@ 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.
|
||||
*
|
||||
* @return object Modified transient with our plugin removed from updates.
|
||||
*/
|
||||
public function filter_block_false_updates( $transient ) {
|
||||
if ( isset( $transient->response[ PRAUTOBLOGGER_PLUGIN_BASENAME ] ) ) {
|
||||
unset( $transient->response[ PRAUTOBLOGGER_PLUGIN_BASENAME ] );
|
||||
$plugin_basename = PRAUTOBLOGGER_PLUGIN_BASENAME;
|
||||
if ( isset( $transient->response[ $plugin_basename ] ) ) {
|
||||
unset( $transient->response[ $plugin_basename ] );
|
||||
}
|
||||
return $transient;
|
||||
}
|
||||
|
||||
/** Expose the executor for external access (e.g., model registry). */
|
||||
public function get_executor(): PRAutoBlogger_Executor {
|
||||
return $this->executor;
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Backward-compatible proxies ──
|
||||
/**
|
||||
* Handle the metrics collection cron event.
|
||||
*
|
||||
* Side effects: API calls to GA4, database writes to ab_content_scores.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function on_collect_metrics(): void {
|
||||
try {
|
||||
$metrics = new PRAutoBlogger_Metrics_Collector();
|
||||
$metrics->collect_all();
|
||||
} catch ( \Exception $e ) {
|
||||
PRAutoBlogger_Logger::instance()->error( 'Metrics collection FAILED: ' . $e->getMessage(), 'metrics' );
|
||||
}
|
||||
}
|
||||
|
||||
/** @see PRAutoBlogger_Executor::on_daily_generation() */
|
||||
public function on_daily_generation(): void { $this->executor->on_daily_generation(); }
|
||||
/**
|
||||
* 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_collect_metrics() */
|
||||
public function on_collect_metrics(): void { $this->executor->on_collect_metrics(); }
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 );
|
||||
return;
|
||||
}
|
||||
|
||||
/** @see PRAutoBlogger_Executor::on_ajax_generate_now() */
|
||||
public function on_ajax_generate_now(): void { $this->executor->on_ajax_generate_now(); }
|
||||
// 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_Ajax_Handlers::on_ajax_test_connection() */
|
||||
public function on_ajax_test_connection(): void { $this->ajax_handlers->on_ajax_test_connection(); }
|
||||
// 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_Executor::get_model_registry() */
|
||||
public function get_model_registry(): PRAutoBlogger_OpenRouter_Model_Registry { return $this->executor->get_model_registry(); }
|
||||
}
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Creates / updates the plugin's custom database tables via `dbDelta`.
|
||||
*
|
||||
* Extracted from `PRAutoBlogger_Activator` in v0.8.2 to keep that class
|
||||
* under the 300-line cap once the timezone-aware cron migration landed.
|
||||
* The Activator delegates to `install()` on activate and on db-version
|
||||
* upgrade; schema definitions live here and only here.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Activator::activate().
|
||||
* Dependencies: WordPress $wpdb, dbDelta() from wp-admin/includes/upgrade.php.
|
||||
*
|
||||
* @see class-activator.php — Sole caller; delegates on activate and db-version bump.
|
||||
* @see ARCHITECTURE.md — Data model section documents each table's purpose.
|
||||
*/
|
||||
class PRAutoBlogger_Schema_Installer {
|
||||
|
||||
/**
|
||||
* Create (or upgrade) all plugin tables.
|
||||
*
|
||||
* `dbDelta` is idempotent and will create new tables, add missing
|
||||
* columns on existing tables, and add missing indexes — but it will
|
||||
* not drop columns or indexes. That matches the plugin's forward-only
|
||||
* schema-migration policy.
|
||||
*
|
||||
* Side effects: up to five `CREATE TABLE` / `ALTER TABLE` statements.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function install(): void {
|
||||
global $wpdb;
|
||||
|
||||
$charset_collate = $wpdb->get_charset_collate();
|
||||
$prefix = $wpdb->prefix . 'prautoblogger_';
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
|
||||
// Source data — raw posts/comments from social platforms.
|
||||
$sql_source_data = "CREATE TABLE {$prefix}source_data (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
source_type VARCHAR(50) NOT NULL,
|
||||
source_id VARCHAR(255) NOT NULL,
|
||||
subreddit VARCHAR(255) DEFAULT NULL,
|
||||
title TEXT DEFAULT NULL,
|
||||
content LONGTEXT DEFAULT NULL,
|
||||
author VARCHAR(255) DEFAULT NULL,
|
||||
score INT DEFAULT 0,
|
||||
comment_count INT DEFAULT 0,
|
||||
permalink VARCHAR(500) DEFAULT NULL,
|
||||
collected_at DATETIME NOT NULL,
|
||||
metadata_json LONGTEXT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY source_unique (source_type, source_id),
|
||||
KEY source_collected (source_type, collected_at)
|
||||
) {$charset_collate};";
|
||||
|
||||
// Analysis results — detected patterns from source data.
|
||||
$sql_analysis = "CREATE TABLE {$prefix}analysis_results (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
analysis_type VARCHAR(50) NOT NULL,
|
||||
topic VARCHAR(500) NOT NULL,
|
||||
summary TEXT DEFAULT NULL,
|
||||
frequency INT DEFAULT 1,
|
||||
relevance_score FLOAT DEFAULT 0,
|
||||
source_ids_json LONGTEXT DEFAULT NULL,
|
||||
analyzed_at DATETIME NOT NULL,
|
||||
metadata_json LONGTEXT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY type_relevance (analysis_type, relevance_score),
|
||||
KEY analyzed_at (analyzed_at)
|
||||
) {$charset_collate};";
|
||||
|
||||
// Generation log — every API call with cost tracking. `run_id`
|
||||
// groups all entries from a single pipeline execution so
|
||||
// `link_generation_logs()` can accurately attribute costs to a post.
|
||||
$sql_generation_log = "CREATE TABLE {$prefix}generation_log (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
post_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
run_id VARCHAR(36) DEFAULT NULL,
|
||||
stage VARCHAR(50) NOT NULL,
|
||||
provider VARCHAR(50) NOT NULL,
|
||||
model VARCHAR(100) NOT NULL,
|
||||
prompt_tokens INT DEFAULT 0,
|
||||
completion_tokens INT DEFAULT 0,
|
||||
estimated_cost DECIMAL(10,6) DEFAULT 0,
|
||||
request_json LONGTEXT DEFAULT NULL,
|
||||
response_status VARCHAR(20) NOT NULL DEFAULT 'success',
|
||||
error_message TEXT DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY post_id (post_id),
|
||||
KEY run_id (run_id),
|
||||
KEY created_at (created_at),
|
||||
KEY stage (stage)
|
||||
) {$charset_collate};";
|
||||
|
||||
// Content scores — post performance metrics for self-improvement.
|
||||
$sql_content_scores = "CREATE TABLE {$prefix}content_scores (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
post_id BIGINT UNSIGNED NOT NULL,
|
||||
pageviews INT DEFAULT 0,
|
||||
avg_time_on_page FLOAT DEFAULT 0,
|
||||
bounce_rate FLOAT DEFAULT 0,
|
||||
comment_count INT DEFAULT 0,
|
||||
composite_score FLOAT DEFAULT 0,
|
||||
score_factors_json LONGTEXT DEFAULT NULL,
|
||||
measured_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY post_id (post_id),
|
||||
KEY measured_at (measured_at),
|
||||
KEY composite_score (composite_score)
|
||||
) {$charset_collate};";
|
||||
|
||||
// Event log — structured application log entries for the admin log viewer.
|
||||
$sql_event_log = "CREATE TABLE {$prefix}event_log (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
level VARCHAR(10) NOT NULL DEFAULT 'info',
|
||||
context VARCHAR(100) DEFAULT NULL,
|
||||
message TEXT NOT NULL,
|
||||
meta_json LONGTEXT DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY level (level),
|
||||
KEY created_at (created_at),
|
||||
KEY level_created (level, created_at)
|
||||
) {$charset_collate};";
|
||||
|
||||
dbDelta( $sql_source_data );
|
||||
dbDelta( $sql_analysis );
|
||||
dbDelta( $sql_generation_log );
|
||||
dbDelta( $sql_content_scores );
|
||||
dbDelta( $sql_event_log );
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
<?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,7 +12,6 @@ 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.
|
||||
*/
|
||||
@@ -109,13 +108,6 @@ 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;
|
||||
}
|
||||
@@ -153,14 +145,10 @@ class PRAutoBlogger_Chief_Editor {
|
||||
* @return PRAutoBlogger_Editorial_Review
|
||||
*/
|
||||
private function parse_review_response( string $content ): PRAutoBlogger_Editorial_Review {
|
||||
$data = PRAutoBlogger_Json_Extractor::decode( $content );
|
||||
$data = json_decode( $content, true );
|
||||
|
||||
if ( ! is_array( $data ) || ! isset( $data['verdict'] ) ) {
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
'Chief editor response was not valid JSON. Defaulting to rejected. Raw (first 500 chars): '
|
||||
. mb_substr( $content, 0, 500 ),
|
||||
'editor'
|
||||
);
|
||||
PRAutoBlogger_Logger::instance()->error( 'Chief editor response was not valid JSON. Defaulting to rejected.', 'editor' );
|
||||
return new PRAutoBlogger_Editorial_Review( [
|
||||
'verdict' => 'rejected',
|
||||
'notes' => 'Editor response could not be parsed.',
|
||||
|
||||
@@ -7,17 +7,14 @@ 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, PRAutoBlogger_Analysis_Prompts, WordPress $wpdb.
|
||||
* Dependencies: LLM_Provider_Interface, Cost_Tracker, WordPress $wpdb.
|
||||
*
|
||||
* @see core/class-analysis-prompts.php — System/user prompt builders + performance context.
|
||||
* @see providers/interface-llm-provider.php — LLM used for analysis.
|
||||
* @see 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.
|
||||
* @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.
|
||||
*/
|
||||
class PRAutoBlogger_Content_Analyzer {
|
||||
|
||||
@@ -40,9 +37,11 @@ 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();
|
||||
@@ -55,10 +54,14 @@ class PRAutoBlogger_Content_Analyzer {
|
||||
$niche = get_option( 'prautoblogger_niche_description', '' );
|
||||
$model = get_option( 'prautoblogger_analysis_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL );
|
||||
|
||||
$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 );
|
||||
// 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 );
|
||||
|
||||
$response = $this->llm->send_chat_completion(
|
||||
[
|
||||
@@ -73,6 +76,7 @@ class PRAutoBlogger_Content_Analyzer {
|
||||
]
|
||||
);
|
||||
|
||||
// Log the cost.
|
||||
$this->cost_tracker->log_api_call(
|
||||
null,
|
||||
'analysis',
|
||||
@@ -82,38 +86,15 @@ 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.
|
||||
*
|
||||
@@ -139,6 +120,7 @@ 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 {
|
||||
@@ -147,14 +129,12 @@ 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\nTitle: %s\n%s\n---",
|
||||
$source_label,
|
||||
"[%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'],
|
||||
$item['title'] ?? '(comment)',
|
||||
mb_substr( $item['content'] ?? '', 0, 500 )
|
||||
);
|
||||
@@ -170,21 +150,115 @@ 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 = PRAutoBlogger_Json_Extractor::decode( $content );
|
||||
$data = json_decode( $content, true );
|
||||
if ( ! isset( $data['patterns'] ) || ! is_array( $data['patterns'] ) ) {
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
'Analysis response was not valid JSON or missing patterns key. Raw (first 500 chars): '
|
||||
. mb_substr( $content, 0, 500 ),
|
||||
'analyzer'
|
||||
);
|
||||
PRAutoBlogger_Logger::instance()->error( 'Analysis response was not valid JSON or missing patterns key.', 'analyzer' );
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -217,6 +291,8 @@ 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,12 +9,11 @@ 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, Content_Prompts.
|
||||
* Dependencies: LLM_Provider_Interface, Cost_Tracker.
|
||||
*
|
||||
* @see core/class-content-prompts.php — Builds all LLM prompts used here.
|
||||
* @see core/class-chief-editor.php — Reviews output of this class.
|
||||
* @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 {
|
||||
|
||||
@@ -33,10 +32,13 @@ 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 {
|
||||
@@ -49,8 +51,7 @@ 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 ) ?: [],
|
||||
get_option( 'prautoblogger_writing_instructions', '' )
|
||||
json_decode( get_option( 'prautoblogger_topic_exclusions', '[]' ), true ) ?: []
|
||||
);
|
||||
|
||||
if ( 'single_pass' === $mode ) {
|
||||
@@ -63,48 +64,94 @@ class PRAutoBlogger_Content_Generator {
|
||||
/**
|
||||
* Single-pass generation: one LLM call produces the complete article.
|
||||
*
|
||||
* @param PRAutoBlogger_Content_Request $request Content request with settings.
|
||||
* @param PRAutoBlogger_Content_Request $request
|
||||
*
|
||||
* @return string Generated HTML content.
|
||||
*/
|
||||
private function generate_single_pass( PRAutoBlogger_Content_Request $request ): string {
|
||||
$model = get_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
|
||||
$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' => PRAutoBlogger_Content_Prompts::build_system( $request ) ],
|
||||
[ 'role' => 'user', 'content' => PRAutoBlogger_Content_Prompts::build_single_pass( $request ) ],
|
||||
[ 'role' => 'system', 'content' => $system ],
|
||||
[ 'role' => 'user', 'content' => $user ],
|
||||
],
|
||||
$model,
|
||||
[ 'temperature' => 0.7, 'max_tokens' => 4000 ]
|
||||
[
|
||||
'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']
|
||||
null,
|
||||
'draft',
|
||||
$this->llm->get_provider_name(),
|
||||
$response['model'],
|
||||
$response['prompt_tokens'],
|
||||
$response['completion_tokens']
|
||||
);
|
||||
|
||||
return $response['content'];
|
||||
}
|
||||
|
||||
/** Multi-step generation: Outline → Draft → Polish. */
|
||||
/**
|
||||
* Multi-step 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 );
|
||||
|
||||
$outline = $this->stage_outline( $request, $model );
|
||||
$draft = $this->stage_draft( $request, $outline, $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. */
|
||||
/**
|
||||
* 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 {
|
||||
$system = PRAutoBlogger_Content_Prompts::build_system( $request );
|
||||
$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' => $system ],
|
||||
[ 'role' => 'user', 'content' => PRAutoBlogger_Content_Prompts::build_outline( $request ) ],
|
||||
[ 'role' => 'system', 'content' => $this->build_writer_system_prompt( $request ) ],
|
||||
[ 'role' => 'user', 'content' => $prompt ],
|
||||
],
|
||||
$model,
|
||||
[ 'temperature' => 0.5, 'max_tokens' => 1500 ]
|
||||
@@ -118,14 +165,38 @@ class PRAutoBlogger_Content_Generator {
|
||||
return $response['content'];
|
||||
}
|
||||
|
||||
/** Multi-step stage 2: Write the full draft from the outline. */
|
||||
/**
|
||||
* 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 {
|
||||
$system = PRAutoBlogger_Content_Prompts::build_system( $request );
|
||||
$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' => $system ],
|
||||
[ 'role' => 'user', 'content' => PRAutoBlogger_Content_Prompts::build_draft( $request, $outline ) ],
|
||||
[ 'role' => 'system', 'content' => $this->build_writer_system_prompt( $request ) ],
|
||||
[ 'role' => 'user', 'content' => $prompt ],
|
||||
],
|
||||
$model,
|
||||
[ 'temperature' => 0.7, 'max_tokens' => 4000 ]
|
||||
@@ -139,14 +210,29 @@ class PRAutoBlogger_Content_Generator {
|
||||
return $response['content'];
|
||||
}
|
||||
|
||||
/** Multi-step stage 3: Polish and refine the draft. */
|
||||
/**
|
||||
* 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 {
|
||||
$system = PRAutoBlogger_Content_Prompts::build_system( $request );
|
||||
$prompt = "Review and polish this blog post draft. Improve:\n" .
|
||||
"1. Flow and readability\n" .
|
||||
"2. SEO optimization (headings, keyword placement)\n" .
|
||||
"3. Engagement (hooks, transitions, call-to-action)\n" .
|
||||
"4. Accuracy and clarity\n" .
|
||||
"5. Remove any filler or redundant sentences\n\n" .
|
||||
"Return the polished HTML content only. Do not add commentary.\n\n" .
|
||||
"DRAFT:\n" . $draft;
|
||||
|
||||
$response = $this->llm->send_chat_completion(
|
||||
[
|
||||
[ 'role' => 'system', 'content' => $system ],
|
||||
[ 'role' => 'user', 'content' => PRAutoBlogger_Content_Prompts::build_polish( $draft ) ],
|
||||
[ 'role' => 'system', 'content' => $this->build_writer_system_prompt( $request ) ],
|
||||
[ 'role' => 'user', 'content' => $prompt ],
|
||||
],
|
||||
$model,
|
||||
[ 'temperature' => 0.4, 'max_tokens' => 4000 ]
|
||||
@@ -159,4 +245,53 @@ 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
<?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";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Provides historical cost reporting and analysis methods.
|
||||
*
|
||||
* Extracted from PRAutoBlogger_Cost_Tracker, this class handles all read-only
|
||||
* reporting queries: monthly spend, daily spend trends, spend-by-stage breakdowns,
|
||||
* and budget utilization percentage.
|
||||
*
|
||||
* Called by: Metrics dashboard, admin notices, and dashboard widget.
|
||||
* Dependencies: WordPress $wpdb, get_option() for budget configuration.
|
||||
*
|
||||
* @see core/class-cost-tracker.php — Complementary class for logging and budget enforcement.
|
||||
* @see admin/class-metrics-page.php — Displays cost reports.
|
||||
* @see admin/class-admin-notices.php — Shows budget warnings via get_budget_utilization().
|
||||
* @see admin/class-dashboard-widget.php — Displays current month costs.
|
||||
*/
|
||||
class PRAutoBlogger_Cost_Reporter {
|
||||
|
||||
/**
|
||||
* Get total estimated spend for the current calendar month.
|
||||
*
|
||||
* @return float Total USD spend this month.
|
||||
*/
|
||||
public function get_monthly_spend(): float {
|
||||
global $wpdb;
|
||||
if ( null === $wpdb ) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||
|
||||
$first_of_month = gmdate( 'Y-m-01 00:00:00' );
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$result = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COALESCE(SUM(estimated_cost), 0) FROM {$table} WHERE created_at >= %s AND response_status = 'success'",
|
||||
$first_of_month
|
||||
)
|
||||
);
|
||||
|
||||
return (float) $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get daily spend for the last N days (for the metrics dashboard chart).
|
||||
*
|
||||
* @param int $days Number of days to look back.
|
||||
*
|
||||
* @return array<string, float> Associative array of date => total_cost.
|
||||
*/
|
||||
public function get_daily_spend( int $days = 30 ): array {
|
||||
global $wpdb;
|
||||
if ( null === $wpdb ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||
|
||||
$start_date = gmdate( 'Y-m-d', time() - ( $days * DAY_IN_SECONDS ) );
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$results = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT DATE(created_at) as day, SUM(estimated_cost) as total_cost
|
||||
FROM {$table}
|
||||
WHERE created_at >= %s AND response_status = 'success'
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY day ASC",
|
||||
$start_date . ' 00:00:00'
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
$daily = [];
|
||||
foreach ( ( $results ?: [] ) as $row ) {
|
||||
$daily[ $row['day'] ] = (float) $row['total_cost'];
|
||||
}
|
||||
|
||||
return $daily;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get spend breakdown by pipeline stage for a given period.
|
||||
*
|
||||
* @param string $start_date Start date (Y-m-d).
|
||||
* @param string $end_date End date (Y-m-d).
|
||||
*
|
||||
* @return array<string, array{cost: float, tokens: int, calls: int}> Breakdown by stage.
|
||||
*/
|
||||
public function get_spend_by_stage( string $start_date, string $end_date ): array {
|
||||
global $wpdb;
|
||||
if ( null === $wpdb ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$results = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT stage,
|
||||
SUM(estimated_cost) as total_cost,
|
||||
SUM(prompt_tokens + completion_tokens) as total_tokens,
|
||||
COUNT(*) as call_count
|
||||
FROM {$table}
|
||||
WHERE created_at BETWEEN %s AND %s AND response_status = 'success'
|
||||
GROUP BY stage",
|
||||
$start_date . ' 00:00:00',
|
||||
$end_date . ' 23:59:59'
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
$breakdown = [];
|
||||
foreach ( ( $results ?: [] ) as $row ) {
|
||||
$breakdown[ $row['stage'] ] = [
|
||||
'cost' => (float) $row['total_cost'],
|
||||
'tokens' => (int) $row['total_tokens'],
|
||||
'calls' => (int) $row['call_count'],
|
||||
];
|
||||
}
|
||||
|
||||
return $breakdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get budget utilization percentage for the current month.
|
||||
*
|
||||
* @return float Percentage (0-100+). Can exceed 100 if overspent.
|
||||
*/
|
||||
public function get_budget_utilization(): float {
|
||||
$budget = (float) get_option( 'prautoblogger_monthly_budget_usd', 50.00 );
|
||||
if ( $budget <= 0 ) {
|
||||
return 0.0;
|
||||
}
|
||||
return ( $this->get_monthly_spend() / $budget ) * 100.0;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Tracks all API costs and enforces monthly budget limits.
|
||||
* Tracks all API costs, enforces monthly budget limits, and provides
|
||||
* cost reporting data for the admin dashboard.
|
||||
*
|
||||
* Every LLM API call in the plugin is logged through this class. The budget
|
||||
* enforcement is a hard stop — if the monthly budget is exceeded, no further
|
||||
@@ -15,8 +16,7 @@ declare(strict_types=1);
|
||||
* @see core/class-content-analyzer.php — Calls log_api_call() after analysis.
|
||||
* @see core/class-content-generator.php — Calls log_api_call() after each stage.
|
||||
* @see core/class-chief-editor.php — Calls log_api_call() after review.
|
||||
* @see core/class-cost-reporter.php — Extracted reporting methods (read-only queries).
|
||||
* @see admin/class-metrics-page.php — Displays cost data via Cost_Reporter.
|
||||
* @see admin/class-metrics-page.php — Displays cost data from this class.
|
||||
* @see ARCHITECTURE.md — prab_generation_log table schema.
|
||||
*/
|
||||
class PRAutoBlogger_Cost_Tracker {
|
||||
@@ -127,8 +127,7 @@ class PRAutoBlogger_Cost_Tracker {
|
||||
return false;
|
||||
}
|
||||
|
||||
$reporter = new PRAutoBlogger_Cost_Reporter();
|
||||
$monthly_spend = $reporter->get_monthly_spend();
|
||||
$monthly_spend = $this->get_monthly_spend();
|
||||
|
||||
// Round to 4 decimal places (0.01 cent precision) to avoid
|
||||
// floating-point representation artifacts triggering false positives.
|
||||
@@ -136,63 +135,25 @@ class PRAutoBlogger_Cost_Tracker {
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an image generation API call with a known cost.
|
||||
* Get total estimated spend for the current calendar month.
|
||||
*
|
||||
* Unlike log_api_call() which calculates cost from token counts, image
|
||||
* generation uses per-megapixel pricing that the image provider already
|
||||
* computed. This method accepts the pre-calculated cost directly.
|
||||
*
|
||||
* @param float $cost_usd Estimated cost in USD (from image provider pricing).
|
||||
* @param string $model Image model alias (e.g. 'flux-1-schnell').
|
||||
* @param int $post_id WordPress post ID the image is attached to.
|
||||
* @param string $stage Pipeline stage ('image_a' or 'image_b').
|
||||
* @return void
|
||||
* @return float Total USD spend this month.
|
||||
*/
|
||||
public function log_image_generation( float $cost_usd, string $model, int $post_id, string $stage ): void {
|
||||
$this->current_run_cost += $cost_usd;
|
||||
|
||||
public function get_monthly_spend(): float {
|
||||
global $wpdb;
|
||||
if ( null === $wpdb ) {
|
||||
return;
|
||||
}
|
||||
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||
|
||||
$first_of_month = gmdate( 'Y-m-01 00:00:00' );
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->insert( $table, [
|
||||
'post_id' => $post_id,
|
||||
'run_id' => $this->run_id,
|
||||
'stage' => $stage,
|
||||
'provider' => 'cloudflare-workers-ai',
|
||||
'model' => $model,
|
||||
'prompt_tokens' => 0,
|
||||
'completion_tokens' => 0,
|
||||
'estimated_cost' => $cost_usd,
|
||||
'response_status' => 'success',
|
||||
'error_message' => '',
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
] );
|
||||
}
|
||||
$result = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COALESCE(SUM(estimated_cost), 0) FROM {$table} WHERE created_at >= %s AND response_status = 'success'",
|
||||
$first_of_month
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Check if adding an estimated cost would exceed the monthly budget.
|
||||
*
|
||||
* Unlike is_budget_exceeded() which checks current state, this method
|
||||
* proactively checks whether a planned expenditure would push spend
|
||||
* over the budget. Used by the image pipeline to pre-check before
|
||||
* making an API call.
|
||||
*
|
||||
* @param float $estimated_cost_usd Estimated cost in USD for the planned operation.
|
||||
* @return bool True if (current spend + estimated cost) >= configured budget.
|
||||
*/
|
||||
public function would_exceed_budget( float $estimated_cost_usd ): bool {
|
||||
$budget = (float) get_option( 'prautoblogger_monthly_budget_usd', 50.00 );
|
||||
if ( $budget <= 0 ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$reporter = new PRAutoBlogger_Cost_Reporter();
|
||||
$projected = $reporter->get_monthly_spend() + $estimated_cost_usd;
|
||||
return round( $projected, 4 ) >= round( $budget, 4 );
|
||||
return (float) $result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -203,4 +164,91 @@ class PRAutoBlogger_Cost_Tracker {
|
||||
public function get_current_run_cost(): float {
|
||||
return $this->current_run_cost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get daily spend for the last N days (for the metrics dashboard chart).
|
||||
*
|
||||
* @param int $days Number of days to look back.
|
||||
*
|
||||
* @return array<string, float> Associative array of date => total_cost.
|
||||
*/
|
||||
public function get_daily_spend( int $days = 30 ): array {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||
|
||||
$start_date = gmdate( 'Y-m-d', time() - ( $days * DAY_IN_SECONDS ) );
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$results = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT DATE(created_at) as day, SUM(estimated_cost) as total_cost
|
||||
FROM {$table}
|
||||
WHERE created_at >= %s AND response_status = 'success'
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY day ASC",
|
||||
$start_date . ' 00:00:00'
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
$daily = [];
|
||||
foreach ( ( $results ?: [] ) as $row ) {
|
||||
$daily[ $row['day'] ] = (float) $row['total_cost'];
|
||||
}
|
||||
|
||||
return $daily;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get spend breakdown by pipeline stage for a given period.
|
||||
*
|
||||
* @param string $start_date Start date (Y-m-d).
|
||||
* @param string $end_date End date (Y-m-d).
|
||||
*
|
||||
* @return array<string, array{cost: float, tokens: int, calls: int}> Breakdown by stage.
|
||||
*/
|
||||
public function get_spend_by_stage( string $start_date, string $end_date ): array {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$results = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT stage,
|
||||
SUM(estimated_cost) as total_cost,
|
||||
SUM(prompt_tokens + completion_tokens) as total_tokens,
|
||||
COUNT(*) as call_count
|
||||
FROM {$table}
|
||||
WHERE created_at BETWEEN %s AND %s AND response_status = 'success'
|
||||
GROUP BY stage",
|
||||
$start_date . ' 00:00:00',
|
||||
$end_date . ' 23:59:59'
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
$breakdown = [];
|
||||
foreach ( ( $results ?: [] ) as $row ) {
|
||||
$breakdown[ $row['stage'] ] = [
|
||||
'cost' => (float) $row['total_cost'],
|
||||
'tokens' => (int) $row['total_tokens'],
|
||||
'calls' => (int) $row['call_count'],
|
||||
];
|
||||
}
|
||||
|
||||
return $breakdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get budget utilization percentage for the current month.
|
||||
*
|
||||
* @return float Percentage (0-100+). Can exceed 100 if overspent.
|
||||
*/
|
||||
public function get_budget_utilization(): float {
|
||||
$budget = (float) get_option( 'prautoblogger_monthly_budget_usd', 50.00 );
|
||||
if ( $budget <= 0 ) {
|
||||
return 0.0;
|
||||
}
|
||||
return ( $this->get_monthly_spend() / $budget ) * 100.0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,25 +4,25 @@ declare(strict_types=1);
|
||||
/**
|
||||
* Ranks and deduplicates article ideas from analysis results.
|
||||
*
|
||||
* Uses PRAutoBlogger_Semantic_Dedup for similarity detection — embedding-based
|
||||
* cosine similarity with automatic fallback to keyword overlap if the embedding
|
||||
* API is unavailable.
|
||||
* 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.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Pipeline_Runner (step 3).
|
||||
* Dependencies: Semantic_Dedup, WP_Query.
|
||||
* Triggered by: PRAutoBlogger::run_generation_pipeline() (step 3).
|
||||
* Dependencies: WordPress $wpdb, 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 deduplication entirely. */
|
||||
/** @var bool Whether to skip the has_similar_post() check. */
|
||||
private bool $skip_dedup = false;
|
||||
|
||||
/**
|
||||
* @param bool $skip True to skip deduplication.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function set_skip_dedup( bool $skip ): self {
|
||||
@@ -33,60 +33,29 @@ class PRAutoBlogger_Idea_Scorer {
|
||||
/**
|
||||
* Score, deduplicate, and rank article ideas.
|
||||
*
|
||||
* Initializes the semantic dedup engine once, then checks each candidate
|
||||
* against (1) intra-batch duplicates and (2) recently published posts.
|
||||
*
|
||||
* 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.
|
||||
* @param int $target_count Number of ideas to return.
|
||||
*
|
||||
* @return PRAutoBlogger_Article_Idea[] Top-ranked ideas, sorted by score desc.
|
||||
* @return PRAutoBlogger_Article_Idea[] Top-ranked ideas, sorted by score descending.
|
||||
*/
|
||||
public function score_and_rank( array $analysis_results, int $target_count ): array {
|
||||
$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 = [];
|
||||
$exclusions = json_decode( get_option( 'prautoblogger_topic_exclusions', '[]' ), true ) ?: [];
|
||||
$ideas = [];
|
||||
|
||||
foreach ( $analysis_results as $result ) {
|
||||
// Skip excluded topics.
|
||||
if ( $this->is_excluded( $result->get_topic(), $exclusions ) ) {
|
||||
$excluded_count++;
|
||||
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 );
|
||||
// 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;
|
||||
}
|
||||
|
||||
$metadata = $result->get_metadata() ?? [];
|
||||
@@ -104,18 +73,7 @@ class PRAutoBlogger_Idea_Scorer {
|
||||
] );
|
||||
}
|
||||
|
||||
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'
|
||||
);
|
||||
|
||||
// Sort by score descending.
|
||||
usort( $ideas, static function ( PRAutoBlogger_Article_Idea $a, PRAutoBlogger_Article_Idea $b ): int {
|
||||
return $b->get_score() <=> $a->get_score();
|
||||
} );
|
||||
@@ -129,12 +87,14 @@ 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 ) );
|
||||
|
||||
@@ -146,6 +106,7 @@ 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 {
|
||||
@@ -159,41 +120,33 @@ class PRAutoBlogger_Idea_Scorer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract meaningful keywords from a text string (for fallback dedup).
|
||||
* Check if a substantially similar post already exists.
|
||||
*
|
||||
* @param string $text Input text.
|
||||
* @return string[] Unique lowercase keywords.
|
||||
* Uses a simple keyword overlap check against existing published post titles.
|
||||
*
|
||||
* @param string $topic Topic to check.
|
||||
*
|
||||
* @return bool True if a similar post exists.
|
||||
*/
|
||||
private function 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',
|
||||
];
|
||||
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',
|
||||
] );
|
||||
|
||||
$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 ) );
|
||||
return $query->found_posts > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map analysis type to a reader-friendly article type.
|
||||
*
|
||||
* @param string $analysis_type 'question', 'complaint', 'comparison', etc.
|
||||
* @param string $analysis_type 'question', 'complaint', 'comparison'.
|
||||
*
|
||||
* @return string Article type label.
|
||||
*/
|
||||
private function map_type( string $analysis_type ): string {
|
||||
|
||||
@@ -38,17 +38,7 @@ 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';
|
||||
}
|
||||
|
||||
public function sideload_image( array $image_data, int $post_id, string $alt_text = '' ): int | \WP_Error {
|
||||
// Validate input.
|
||||
if ( empty( $image_data['bytes'] ) ) {
|
||||
return new \WP_Error( 'invalid_image_data', 'Image data (bytes) is empty.' );
|
||||
@@ -107,7 +97,7 @@ class PRAutoBlogger_Image_Media_Sideloader {
|
||||
*
|
||||
* @return string|\WP_Error Path to temporary file on success, WP_Error on failure.
|
||||
*/
|
||||
private function create_temp_file( string $bytes, string $mime_type ) {
|
||||
private function create_temp_file( string $bytes, string $mime_type ): string | \WP_Error {
|
||||
$temp_dir = get_temp_dir();
|
||||
$filename = 'prautoblogger_img_' . wp_generate_uuid4() . $this->get_extension_for_mime( $mime_type );
|
||||
$temp_path = $temp_dir . $filename;
|
||||
@@ -145,7 +135,7 @@ class PRAutoBlogger_Image_Media_Sideloader {
|
||||
gmdate( 'Y-m-d_H-i-s' )
|
||||
);
|
||||
|
||||
return $base . '.png'; // Default to PNG — most image models output PNG.
|
||||
return $base . '.png'; // Default to PNG since FLUX.1 outputs PNG.
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Single-shot retry of NSFW-blocked image slots with a sanitized prompt.
|
||||
*
|
||||
* When an image provider rejects a prompt via its content filter, its
|
||||
* batch result carries
|
||||
* `['error_type' => 'nsfw_blocked']`. This helper reruns just the blocked
|
||||
* slot once with the rule-based fallback prompt (article title + style
|
||||
* suffix, no LLM rewrite) and merges the new result back into the batch.
|
||||
*
|
||||
* Intentionally single-retry: if the fallback is also blocked, we log a
|
||||
* WARNING and leave the `['error' => ...]` entry in place so the pipeline
|
||||
* proceeds to publish without a featured image (current behaviour for any
|
||||
* unrecoverable image failure).
|
||||
*
|
||||
* Gated behind the `prautoblogger_image_nsfw_retry` setting (default on).
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Image_Pipeline::generate_and_attach_images.
|
||||
* Dependencies: PRAutoBlogger_Image_Provider_Interface (retry HTTP call),
|
||||
* PRAutoBlogger_Image_Prompt_Builder (fallback prompt),
|
||||
* PRAutoBlogger_Logger (warning + info lines).
|
||||
*
|
||||
* @see core/class-image-pipeline.php — Sole caller.
|
||||
* @see core/class-image-prompt-builder.php — Supplies the fallback prompt.
|
||||
* @see providers/class-image-nsfw-blocked.php — Typed exception raised by providers.
|
||||
*/
|
||||
class PRAutoBlogger_Image_NSFW_Retry {
|
||||
|
||||
/**
|
||||
* Option key for the admin toggle.
|
||||
*/
|
||||
private const SETTING_KEY = 'prautoblogger_image_nsfw_retry';
|
||||
|
||||
/** @var PRAutoBlogger_Image_Provider_Interface */
|
||||
private PRAutoBlogger_Image_Provider_Interface $provider;
|
||||
|
||||
/** @var PRAutoBlogger_Image_Prompt_Builder */
|
||||
private PRAutoBlogger_Image_Prompt_Builder $prompt_builder;
|
||||
|
||||
/**
|
||||
* @param PRAutoBlogger_Image_Provider_Interface $provider Provider to retry against.
|
||||
* @param PRAutoBlogger_Image_Prompt_Builder $prompt_builder Fallback prompt source.
|
||||
*/
|
||||
public function __construct(
|
||||
PRAutoBlogger_Image_Provider_Interface $provider,
|
||||
PRAutoBlogger_Image_Prompt_Builder $prompt_builder
|
||||
) {
|
||||
$this->provider = $provider;
|
||||
$this->prompt_builder = $prompt_builder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the admin setting currently allows NSFW retries.
|
||||
*
|
||||
* Defaults to on. Kept as a static so callers can short-circuit before
|
||||
* constructing a retry object when nothing needs retrying anyway.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_enabled(): bool {
|
||||
return '1' === (string) get_option( self::SETTING_KEY, '1' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace every NSFW-blocked slot in $batch_results with a fresh result
|
||||
* from a sanitized fallback prompt.
|
||||
*
|
||||
* Mutates $batch_results and $captions in place. Also updates the
|
||||
* per-slot entry in $captions so the caller writes the fallback
|
||||
* caption under the post if the retry succeeds.
|
||||
*
|
||||
* Side effects: up to one HTTP call per blocked slot; Logger lines
|
||||
* for every retry attempt (info on success, warning on second block).
|
||||
*
|
||||
* @param int $post_id Target post ID (for logs).
|
||||
* @param array $batch_requests Original per-slot request map.
|
||||
* @param array<string, array|null> $batch_results Provider result map (mutated).
|
||||
* @param array<string, string> $captions Caption map (mutated).
|
||||
* @param array<string, mixed> $article_data Article data (for title).
|
||||
* @param array<string, mixed>|null $source_data Source data (for title on image_b).
|
||||
* @return void
|
||||
*/
|
||||
public function retry_blocked_slots(
|
||||
int $post_id,
|
||||
array $batch_requests,
|
||||
array &$batch_results,
|
||||
array &$captions,
|
||||
array $article_data,
|
||||
?array $source_data
|
||||
): void {
|
||||
foreach ( $batch_results as $key => $entry ) {
|
||||
if ( ! is_array( $entry ) || 'nsfw_blocked' !== ( $entry['error_type'] ?? '' ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$title = 'image_b' === $key
|
||||
? (string) ( $source_data['title'] ?? '' )
|
||||
: (string) ( $article_data['post_title'] ?? $article_data['suggested_title'] ?? '' );
|
||||
|
||||
$fallback = $this->prompt_builder->build_fallback_prompt( $title );
|
||||
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf( 'NSFW retry for post %d slot "%s" with sanitized fallback prompt.', $post_id, $key ),
|
||||
'image_pipeline'
|
||||
);
|
||||
|
||||
$retry_request = [
|
||||
'prompt' => $fallback['prompt'],
|
||||
'width' => (int) ( $batch_requests[ $key ]['width'] ?? 0 ),
|
||||
'height' => (int) ( $batch_requests[ $key ]['height'] ?? 0 ),
|
||||
'options' => $batch_requests[ $key ]['options'] ?? [],
|
||||
];
|
||||
|
||||
$retry_results = $this->provider->generate_image_batch( [ $key => $retry_request ] );
|
||||
$retry_entry = $retry_results[ $key ] ?? [ 'error' => 'Retry provider returned no result.' ];
|
||||
|
||||
if ( isset( $retry_entry['error'] ) ) {
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf(
|
||||
'NSFW retry for post %d slot "%s" also failed: %s',
|
||||
$post_id,
|
||||
$key,
|
||||
(string) $retry_entry['error']
|
||||
),
|
||||
'image_pipeline'
|
||||
);
|
||||
// Leave the original ['error' => ...] entry so the pipeline
|
||||
// publishes without this image, matching current behaviour
|
||||
// for any unrecoverable image failure.
|
||||
continue;
|
||||
}
|
||||
|
||||
$batch_results[ $key ] = $retry_entry;
|
||||
$captions[ $key ] = $fallback['caption'];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,295 +4,273 @@ declare(strict_types=1);
|
||||
/**
|
||||
* Orchestrates image generation for published articles.
|
||||
*
|
||||
* Generates two images per article (A/B): Image A (article-driven) as featured
|
||||
* image, Image B (source-driven) in post meta. Each is independently fallible.
|
||||
* 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).
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Pipeline_Runner after publisher creates the post.
|
||||
* Dependencies: Image provider, Prompt builder, Sideloader, Cost tracker, Logger.
|
||||
* Dependencies: PRAutoBlogger_Image_Provider_Interface (concrete provider),
|
||||
* PRAutoBlogger_Image_Prompt_Builder, PRAutoBlogger_Image_Media_Sideloader,
|
||||
* PRAutoBlogger_Cost_Tracker, PRAutoBlogger_Logger.
|
||||
*
|
||||
* @see core/class-pipeline-runner.php — Calls this after publisher.
|
||||
* @see core/class-image-prompt-builder.php — Prompt generation (scene + caption).
|
||||
* @see core/class-publisher.php — Sets featured image + post meta.
|
||||
* @see core/class-image-prompt-builder.php — Prompt generation.
|
||||
* @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). 1200×632 is the closest
|
||||
* 8-aligned pair to the standard OG image size (1200×630). Some
|
||||
* providers require dimensions divisible by 8.
|
||||
* Default image dimensions (landscape).
|
||||
*/
|
||||
private const DEFAULT_WIDTH = 1200;
|
||||
private const DEFAULT_HEIGHT = 632;
|
||||
private const DEFAULT_HEIGHT = 630;
|
||||
|
||||
/** @var PRAutoBlogger_Image_Provider_Interface Image gen provider. */
|
||||
/**
|
||||
* The image provider (FLUX.1 via Cloudflare or another).
|
||||
*
|
||||
* @var PRAutoBlogger_Image_Provider_Interface
|
||||
*/
|
||||
private PRAutoBlogger_Image_Provider_Interface $provider;
|
||||
/** @var PRAutoBlogger_Image_Prompt_Builder Builds scene + caption from article data. */
|
||||
|
||||
/**
|
||||
* Prompt builder.
|
||||
*
|
||||
* @var PRAutoBlogger_Image_Prompt_Builder
|
||||
*/
|
||||
private PRAutoBlogger_Image_Prompt_Builder $prompt_builder;
|
||||
/** @var PRAutoBlogger_Image_Media_Sideloader Downloads images into WP media library. */
|
||||
|
||||
/**
|
||||
* Media library sideloader.
|
||||
*
|
||||
* @var PRAutoBlogger_Image_Media_Sideloader
|
||||
*/
|
||||
private PRAutoBlogger_Image_Media_Sideloader $sideloader;
|
||||
/** @var PRAutoBlogger_Cost_Tracker Logs image generation spend. */
|
||||
|
||||
/**
|
||||
* Cost tracker for logging spend.
|
||||
*
|
||||
* @var PRAutoBlogger_Cost_Tracker
|
||||
*/
|
||||
private PRAutoBlogger_Cost_Tracker $cost_tracker;
|
||||
|
||||
/**
|
||||
* Construct with dependencies.
|
||||
*
|
||||
* When no provider is injected, the constructor reads the
|
||||
* `prautoblogger_image_provider` setting and instantiates the
|
||||
* matching concrete class ('runware' or 'openrouter').
|
||||
*
|
||||
* @param PRAutoBlogger_Image_Provider_Interface|null $provider Optional provider override.
|
||||
* @param PRAutoBlogger_Image_Provider_Interface|null $provider Optional provider (defaults to Cloudflare).
|
||||
* @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 ?? self::create_default_provider();
|
||||
$this->provider = $provider ?? new PRAutoBlogger_Cloudflare_Image_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. Defaults
|
||||
* to Runware on unrecognised values (CF was removed in v0.10.0).
|
||||
*
|
||||
* @return PRAutoBlogger_Image_Provider_Interface
|
||||
*/
|
||||
private static function create_default_provider(): PRAutoBlogger_Image_Provider_Interface {
|
||||
$provider_id = (string) get_option( 'prautoblogger_image_provider', PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER );
|
||||
if ( 'openrouter' === $provider_id ) {
|
||||
return new PRAutoBlogger_OpenRouter_Image_Provider();
|
||||
}
|
||||
return new PRAutoBlogger_Runware_Image_Provider();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and attach images to a published post.
|
||||
*
|
||||
* 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.
|
||||
* Generates Image A (article-driven) and Image B (source-driven). If enabled
|
||||
* in settings. Each image is independently fallible.
|
||||
*
|
||||
* @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.
|
||||
* @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.
|
||||
*
|
||||
* @return array{image_a_id?: int, image_b_id?: int, cost_usd: float, errors: string[]}
|
||||
* @return array{
|
||||
* image_a_id?: int,
|
||||
* image_b_id?: int,
|
||||
* cost_usd: float,
|
||||
* errors: string[],
|
||||
* } Attachment IDs (if successful) and cost/errors.
|
||||
*/
|
||||
public function generate_and_attach_images(
|
||||
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;
|
||||
}
|
||||
|
||||
// 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 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.';
|
||||
}
|
||||
$result['cost_usd'] += $image_a_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'
|
||||
);
|
||||
// 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.';
|
||||
}
|
||||
$result['cost_usd'] += $image_b_result['cost_usd'] ?? 0.0;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process Image A batch result: sideload, set featured image, insert caption.
|
||||
* Generate Image A from article content.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param array $batch_results Keyed results from generate_image_batch().
|
||||
* @param array $captions Keyed captions from prompt builder.
|
||||
* @param array $result Pipeline result array (modified by reference).
|
||||
* @param int $post_id Post ID.
|
||||
* @param array $article_data Article data.
|
||||
*
|
||||
* @return array{
|
||||
* attachment_id?: int,
|
||||
* cost_usd: float,
|
||||
* }|\WP_Error
|
||||
*/
|
||||
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 ),
|
||||
private function generate_image_a( int $post_id, array $article_data ): array | \WP_Error {
|
||||
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'] ),
|
||||
'image_pipeline'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
$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'] );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process Image B batch result: sideload, store meta.
|
||||
*
|
||||
* @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 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 ),
|
||||
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'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
$attachment_id = $this->sideload_and_log( $image_data, $post_id, 'image_b', 'Image B' );
|
||||
if ( is_wp_error( $attachment_id ) ) {
|
||||
$result['errors'][] = $attachment_id->get_error_message();
|
||||
return;
|
||||
}
|
||||
|
||||
$result['image_b_id'] = $attachment_id;
|
||||
$result['cost_usd'] += $image_data['cost_usd'];
|
||||
update_post_meta( $post_id, '_prautoblogger_image_b_id', $attachment_id );
|
||||
|
||||
if ( '' !== ( $captions['image_b'] ?? '' ) ) {
|
||||
update_post_meta( $attachment_id, '_prautoblogger_image_caption', $captions['image_b'] );
|
||||
return new \WP_Error( 'image_generation_failed', $e->getMessage() );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sideload image bytes into the media library and log the cost.
|
||||
* Generate Image B from source data.
|
||||
*
|
||||
* @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.
|
||||
* @param int $post_id Post ID.
|
||||
* @param array $source_data Source Reddit data.
|
||||
*
|
||||
* @return array{
|
||||
* attachment_id?: int,
|
||||
* cost_usd: float,
|
||||
* }|\WP_Error
|
||||
*/
|
||||
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 )
|
||||
);
|
||||
private function generate_image_b( int $post_id, array $source_data ): array | \WP_Error {
|
||||
try {
|
||||
// Build the source-driven prompt.
|
||||
$prompt = $this->prompt_builder->build_source_prompt( $source_data );
|
||||
|
||||
if ( is_wp_error( $attachment_id ) ) {
|
||||
return $attachment_id;
|
||||
// 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'] ),
|
||||
'image_pipeline'
|
||||
);
|
||||
|
||||
return [
|
||||
'attachment_id' => $attachment_id,
|
||||
'cost_usd' => $image_data['cost_usd'],
|
||||
];
|
||||
} catch ( \Exception $e ) {
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
'Image B generation failed: ' . $e->getMessage(),
|
||||
'image_pipeline'
|
||||
);
|
||||
|
||||
return new \WP_Error( 'image_generation_failed', $e->getMessage() );
|
||||
}
|
||||
|
||||
$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,224 +4,77 @@ declare(strict_types=1);
|
||||
/**
|
||||
* Builds image prompts from article content or source data.
|
||||
*
|
||||
* Uses a cheap LLM (via OpenRouter) to distill article content into concise
|
||||
* visual scene descriptions optimised for image generation. Falls back to rule-based
|
||||
* synthesis if the LLM call fails, so image generation never blocks on a
|
||||
* prompt-rewriting outage.
|
||||
* Generates concise visual concepts suitable for FLUX.1 image generation,
|
||||
* extracting key themes from article titles/content and Reddit source data.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Image_Pipeline during content generation run.
|
||||
* Dependencies: PRAutoBlogger_OpenRouter_Provider (LLM call),
|
||||
* PRAutoBlogger_Cost_Tracker (logs prompt-rewrite cost),
|
||||
* PRAutoBlogger_Logger (diagnostics).
|
||||
* Dependencies: None — pure data transformation.
|
||||
*
|
||||
* @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. Favor CONCRETE, HUMAN-SCALE scenes: people reacting to things, doctors and patients, scientists in a lab, gym bros with peptide vials, partners at a kitchen table, ordinary objects in ordinary rooms. Keep it simple — one clear visual joke, 1-3 characters max.
|
||||
|
||||
CAPTION: A short, punchy caption or speech bubble line (under 15 words) that delivers the punchline. The humor should be dry, nerdy, and accessible — the reader should chuckle even if they only half-understand the science.
|
||||
|
||||
Rules:
|
||||
- The joke MUST relate to the article's actual subject matter, not generic science humor
|
||||
- DO NOT personify molecules, peptides, hormones, proteins, or other chemical structures (no "anthropomorphic molecule", no "cartoon hormone"). Image models render anthropomorphized chemistry as incoherent blobs. If the article is about a substance, show a PERSON reacting to or using the substance; if the article is abstract (a mechanism, a process, a concept), fall back on a human observer reacting to its effect.
|
||||
- Characters can have simple cartoon faces (round heads, dot eyes, expressive eyebrows)
|
||||
- Keep the scene physically simple — few objects, clear staging, easy to read at thumbnail size
|
||||
- The caption should work as a standalone joke even without the image
|
||||
- No logos, watermarks, or branding
|
||||
- Output ONLY the scene and caption. No preamble, no explanation.
|
||||
|
||||
Example output format:
|
||||
A muscular gym bro stares, deflated, at a tiny glass vial in his hand while his friend squats a barbell effortlessly in the background.
|
||||
|
||||
"They told me the peptide did the work, not the squats."
|
||||
PROMPT;
|
||||
|
||||
/**
|
||||
* Max tokens for the rewriter response. Comic concepts need more room
|
||||
* for the scene description plus the caption punchline.
|
||||
*/
|
||||
private const REWRITER_MAX_TOKENS = 180;
|
||||
|
||||
/**
|
||||
* OpenRouter provider for LLM calls. Null until first use.
|
||||
*
|
||||
* @var PRAutoBlogger_OpenRouter_Provider|null
|
||||
*/
|
||||
private ?PRAutoBlogger_OpenRouter_Provider $llm = null;
|
||||
|
||||
/**
|
||||
* Build a visual prompt from finished article content.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*
|
||||
* @param array{post_title?: string, post_content?: string, suggested_title?: string} $article_data
|
||||
* @return array{prompt: string, caption: string}
|
||||
* @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.
|
||||
*/
|
||||
public function build_article_prompt( array $article_data ): array {
|
||||
$title = $article_data['post_title'] ?? $article_data['suggested_title'] ?? 'Product';
|
||||
$content = $article_data['post_content'] ?? '';
|
||||
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.
|
||||
$first_para = $this->extract_first_paragraph( $content );
|
||||
|
||||
$parsed = $this->rewrite_via_llm( $title, $first_para );
|
||||
// Build visual concepts from title + first paragraph.
|
||||
$concepts = $this->synthesize_visual_concepts( $title, $first_para );
|
||||
|
||||
// Append style suffix.
|
||||
$style_suffix = get_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
|
||||
|
||||
return [
|
||||
'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ),
|
||||
'caption' => $parsed['caption'],
|
||||
];
|
||||
return trim( $concepts . ' ' . $style_suffix );
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a visual prompt from source Reddit thread data. Tries LLM
|
||||
* rewriting first; falls back to rule-based synthesis on failure.
|
||||
* Build a visual prompt from source Reddit thread data.
|
||||
*
|
||||
* @param array{title?: string, selftext?: string, comments?: string[]} $source_data
|
||||
* @return array{prompt: string, caption: string}
|
||||
* 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.
|
||||
*/
|
||||
public function build_source_prompt( array $source_data ): array {
|
||||
public function build_source_prompt( array $source_data ): string {
|
||||
$title = $source_data['title'] ?? 'Reddit Discussion';
|
||||
$comments = $source_data['comments'] ?? [];
|
||||
$context = is_array( $comments ) && ! empty( $comments ) ? $comments[0] : '';
|
||||
|
||||
$parsed = $this->rewrite_via_llm( $title, $context );
|
||||
// Build visual concepts from title + top comments.
|
||||
$concepts = $this->synthesize_visual_concepts(
|
||||
$title,
|
||||
is_array( $comments ) && ! empty( $comments ) ? $comments[0] : ''
|
||||
);
|
||||
|
||||
// Append style suffix.
|
||||
$style_suffix = get_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
|
||||
|
||||
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' => '' ];
|
||||
return trim( $concepts . ' ' . $style_suffix );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -231,13 +84,18 @@ PROMPT;
|
||||
* or 200 characters, whichever comes first.
|
||||
*
|
||||
* @param string $html HTML content.
|
||||
*
|
||||
* @return string Plain text of the first paragraph.
|
||||
*/
|
||||
private function extract_first_paragraph( string $html ): string {
|
||||
$text = wp_strip_all_tags( $html );
|
||||
$paras = preg_split( '/\n\n+/', $text );
|
||||
$para = isset( $paras[0] ) ? trim( $paras[0] ) : '';
|
||||
// Remove HTML tags.
|
||||
$text = wp_strip_all_tags( $html );
|
||||
|
||||
// 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 ) . '...';
|
||||
}
|
||||
@@ -246,55 +104,33 @@ PROMPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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().
|
||||
* Synthesize visual concepts from a title and supporting text.
|
||||
*
|
||||
* @param string $title Article or source title.
|
||||
* @return array{prompt: string, caption: string}
|
||||
* 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.
|
||||
*/
|
||||
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'],
|
||||
];
|
||||
}
|
||||
private function synthesize_visual_concepts( string $title, string $supporting_text ): string {
|
||||
$title = trim( sanitize_text_field( $title ) );
|
||||
$text = trim( sanitize_text_field( $supporting_text ) );
|
||||
|
||||
/**
|
||||
* 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
|
||||
);
|
||||
// Build a simple declarative concept from title and text snippets.
|
||||
$prompt = "A visual representation of: {$title}";
|
||||
|
||||
return [
|
||||
'scene' => trim( $scene ),
|
||||
'caption' => 'Science is full of surprises.',
|
||||
];
|
||||
}
|
||||
if ( ! empty( $text ) ) {
|
||||
// Take first 150 chars to avoid bloating the prompt.
|
||||
$snippet = strlen( $text ) > 150
|
||||
? substr( $text, 0, 150 ) . '...'
|
||||
: $text;
|
||||
|
||||
/** Lazy-load the OpenRouter provider. */
|
||||
private function get_llm_provider(): PRAutoBlogger_OpenRouter_Provider {
|
||||
if ( null === $this->llm ) {
|
||||
$this->llm = new PRAutoBlogger_OpenRouter_Provider();
|
||||
$prompt .= ". Context: {$snippet}";
|
||||
}
|
||||
return $this->llm;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
return trim( $prompt );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -126,11 +126,6 @@ class PRAutoBlogger_Logger {
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
if ( null === $wpdb ) {
|
||||
// No database available (unit test environment). PHP error_log
|
||||
// was already called above for warnings/errors; silently skip DB write.
|
||||
return;
|
||||
}
|
||||
$table = $wpdb->prefix . 'prautoblogger_event_log';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
<?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,37 +2,41 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Executes the content generation pipeline using a chained-job architecture.
|
||||
* Executes the full content generation pipeline.
|
||||
*
|
||||
* Orchestrates: Budget check → Collect source data → Analyze patterns →
|
||||
* 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.
|
||||
* Score ideas → Generate content → Editorial review → Publish.
|
||||
*
|
||||
* 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.
|
||||
* Triggered by: PRAutoBlogger::on_daily_generation() and PRAutoBlogger::on_ajax_generate_now().
|
||||
* Dependencies: All core content classes (Collector, Analyzer, Scorer, Generator, Editor, Publisher).
|
||||
*
|
||||
* 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.
|
||||
* @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.
|
||||
*/
|
||||
class PRAutoBlogger_Pipeline_Runner {
|
||||
|
||||
/** 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. */
|
||||
/**
|
||||
* 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). */
|
||||
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 {
|
||||
@@ -40,245 +44,104 @@ 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' )
|
||||
);
|
||||
}
|
||||
|
||||
$ideas = $this->orchestrate( $cost_tracker );
|
||||
$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 );
|
||||
|
||||
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 ];
|
||||
}
|
||||
|
||||
// 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 );
|
||||
// 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 );
|
||||
|
||||
// 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 );
|
||||
$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.
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
$total_cost = $cost_tracker->get_current_run_cost();
|
||||
|
||||
/**
|
||||
* 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
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf( 'Pipeline complete: %d generated, %d published, %d rejected. Cost: $%.4f', $generated, $published, $rejected, $total_cost ),
|
||||
'pipeline'
|
||||
);
|
||||
|
||||
$queue = [
|
||||
'run_id' => $run_id,
|
||||
'ideas' => $serialized,
|
||||
'results' => $first,
|
||||
return [
|
||||
'generated' => $generated,
|
||||
'published' => $published,
|
||||
'rejected' => $rejected,
|
||||
'cost' => $total_cost,
|
||||
];
|
||||
|
||||
update_option( self::QUEUE_KEY, $queue, false );
|
||||
$this->update_queue_status( $queue );
|
||||
$this->schedule_next();
|
||||
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf( 'Article 1 done. Queued %d more as chained jobs.', count( $serialized ) ),
|
||||
'pipeline'
|
||||
);
|
||||
}
|
||||
|
||||
/** Schedule the next chained cron event and fire it immediately. */
|
||||
private function schedule_next(): void {
|
||||
if ( ! wp_next_scheduled( self::CRON_ACTION ) ) {
|
||||
wp_schedule_single_event( time(), self::CRON_ACTION );
|
||||
}
|
||||
spawn_cron();
|
||||
wp_remote_post(
|
||||
site_url( 'wp-cron.php?doing_wp_cron=' . sprintf( '%.22F', microtime( true ) ) ),
|
||||
[ 'timeout' => 0.01, 'blocking' => false, 'sslverify' => false ]
|
||||
);
|
||||
}
|
||||
|
||||
/** Update the frontend status transient with queue progress. */
|
||||
private function update_queue_status( array $queue ): void {
|
||||
$done = $queue['results']['generated'] + $queue['results']['rejected'];
|
||||
$total = $done + count( $queue['ideas'] );
|
||||
|
||||
$current = get_transient( 'prautoblogger_generation_status' );
|
||||
set_transient( 'prautoblogger_generation_status', [
|
||||
'status' => 'running',
|
||||
'stage' => sprintf( __( 'Generating article %1$d of %2$d…', 'prautoblogger' ), $done + 1, $total ),
|
||||
'started' => is_array( $current ) ? ( $current['started'] ?? time() ) : time(),
|
||||
'last_updated' => time(),
|
||||
], 600 );
|
||||
}
|
||||
|
||||
/** Write the final "complete" status transient. */
|
||||
private function write_final_status( array $r ): void {
|
||||
set_transient( 'prautoblogger_generation_status', [
|
||||
'status' => 'complete',
|
||||
'generated' => $r['generated'],
|
||||
'published' => $r['published'],
|
||||
'rejected' => $r['rejected'],
|
||||
'cost' => $r['cost'],
|
||||
], 600 );
|
||||
}
|
||||
|
||||
/** Clean up a stale or empty queue. */
|
||||
private function finish_queue(): void {
|
||||
$queue = get_option( self::QUEUE_KEY );
|
||||
if ( is_array( $queue ) && isset( $queue['results'] ) ) {
|
||||
$this->write_final_status( $queue['results'] );
|
||||
}
|
||||
delete_option( self::QUEUE_KEY );
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
}
|
||||
|
||||
/** Log the pipeline summary. */
|
||||
private function log_summary( array $r ): void {
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf(
|
||||
'Pipeline complete: %d generated, %d published, %d rejected. Cost: $%.4f',
|
||||
$r['generated'], $r['published'], $r['rejected'], $r['cost']
|
||||
),
|
||||
'pipeline'
|
||||
);
|
||||
}
|
||||
|
||||
/** Update the generation status transient with the current stage. */
|
||||
private function broadcast_stage( string $stage ): void {
|
||||
$key = 'prautoblogger_generation_status';
|
||||
$current = get_transient( $key );
|
||||
if ( is_array( $current ) && 'running' === ( $current['status'] ?? '' ) ) {
|
||||
$current['stage'] = $stage;
|
||||
$current['last_updated'] = time();
|
||||
set_transient( $key, $current, 600 );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
<?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,12 +7,9 @@ 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_Pipeline_Runner (step 6).
|
||||
* Dependencies: PRAutoBlogger_Post_Assembler (taxonomy, images, sanitization),
|
||||
* WordPress wp_insert_post(), post meta API.
|
||||
* Triggered by: PRAutoBlogger::run_generation_pipeline() (step 6).
|
||||
* Dependencies: 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.
|
||||
@@ -22,11 +19,18 @@ 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(
|
||||
@@ -45,7 +49,9 @@ 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(
|
||||
@@ -65,7 +71,9 @@ 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(
|
||||
@@ -75,36 +83,47 @@ 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( $linked_content ),
|
||||
'post_content' => wp_kses_post( $this->sanitize_llm_content( $content ) ),
|
||||
'post_status' => $post_status,
|
||||
'post_type' => 'post',
|
||||
'post_author' => PRAutoBlogger_Post_Assembler::get_default_author_id(),
|
||||
'meta_input' => $this->build_meta( $idea, $review, $run_id ),
|
||||
'post_author' => $this->get_default_author_id(),
|
||||
'meta_input' => $this->build_meta( $idea, $review ),
|
||||
];
|
||||
|
||||
/** @see class-prautoblogger.php — listeners registered in main loader. */
|
||||
/**
|
||||
* Fires before an PRAutoBlogger post is created.
|
||||
*
|
||||
* Listeners registered in: class-prautoblogger.php (main loader).
|
||||
*
|
||||
* @param array $post_data Post data array.
|
||||
* @param PRAutoBlogger_Article_Idea $idea The article idea.
|
||||
* @param PRAutoBlogger_Editorial_Review $review The editorial review.
|
||||
*/
|
||||
$post_data = apply_filters( 'prautoblogger_filter_post_data', $post_data, $idea, $review );
|
||||
|
||||
$post_id = wp_insert_post( $post_data, true );
|
||||
|
||||
if ( is_wp_error( $post_id ) ) {
|
||||
throw new \RuntimeException(
|
||||
sprintf( __( 'Failed to create post: %s', 'prautoblogger' ), $post_id->get_error_message() )
|
||||
sprintf(
|
||||
/* translators: %s: WordPress error message */
|
||||
__( 'Failed to create post: %s', 'prautoblogger' ),
|
||||
$post_id->get_error_message()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
PRAutoBlogger_Post_Assembler::assign_taxonomy_terms( $post_id, $idea );
|
||||
PRAutoBlogger_Post_Assembler::link_generation_logs( $post_id, $run_id );
|
||||
// Assign categories/tags based on article type and keywords.
|
||||
$this->assign_taxonomy_terms( $post_id, $idea );
|
||||
|
||||
// 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 ) {
|
||||
PRAutoBlogger_Post_Assembler::attach_generated_images( $post_id, $idea, $post_data );
|
||||
$this->attach_generated_images( $post_id, $idea, $post_data );
|
||||
}
|
||||
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
@@ -112,6 +131,14 @@ 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;
|
||||
@@ -120,39 +147,241 @@ class PRAutoBlogger_Publisher {
|
||||
/**
|
||||
* Build post_meta array for generation metadata.
|
||||
*
|
||||
* The `_prautoblogger_run_id` key (added v0.8.1) lets the orphan-research
|
||||
* reaper attribute an orphan `llm_research` row back to its sibling
|
||||
* articles without re-walking the gen_log table. See
|
||||
* core/class-research-reaper.php.
|
||||
*
|
||||
* @param PRAutoBlogger_Article_Idea $idea
|
||||
* @param PRAutoBlogger_Editorial_Review $review
|
||||
* @param string|null $run_id Pipeline run UUID, or null in legacy paths.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function build_meta(
|
||||
PRAutoBlogger_Article_Idea $idea,
|
||||
PRAutoBlogger_Editorial_Review $review,
|
||||
?string $run_id = null
|
||||
PRAutoBlogger_Editorial_Review $review
|
||||
): array {
|
||||
$meta = [
|
||||
'_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() ),
|
||||
$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() ),
|
||||
];
|
||||
if ( null !== $run_id && '' !== $run_id ) {
|
||||
$meta['_prautoblogger_run_id'] = $run_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'
|
||||
);
|
||||
}
|
||||
return $meta;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Daily cron that retroactively amortizes orphan `llm_research` rows.
|
||||
*
|
||||
* When a pipeline run dies between post creation and the final
|
||||
* `Post_Assembler::amortize_research_costs()` call (Hostinger exec kill,
|
||||
* OOM, LiteSpeed cut, fatal), the shared research-cost row stays in
|
||||
* `wp_prautoblogger_generation_log` with `post_id = NULL` and the
|
||||
* articles from that run understate their true cost in the popover.
|
||||
*
|
||||
* This reaper scans for such orphan rows once a day, finds their sibling
|
||||
* posts via the `_prautoblogger_run_id` post_meta (primary) or the
|
||||
* gen_log's own post-linked rows (fallback for legacy posts that
|
||||
* predate the meta), and delegates to the existing idempotent
|
||||
* `amortize_research_costs()` method. Orphans that still have no
|
||||
* siblings after 7 days are deleted outright as sunk cost.
|
||||
*
|
||||
* Spec-wise the CTO asked for the reap method to live on Post_Assembler;
|
||||
* hosting it here instead keeps Post_Assembler under the 300-line cap
|
||||
* (it grew past 300 with the reap method attached) and colocates the
|
||||
* attribution logic next to the cron/CLI plumbing that triggers it.
|
||||
*
|
||||
* Triggered by:
|
||||
* - WP-Cron: `prautoblogger_reap_orphan_research_rows` (daily, 03:15).
|
||||
* - WP-CLI: `wp prautoblogger reap-research` (manual ops trigger).
|
||||
* Dependencies: PRAutoBlogger_Post_Assembler (amortize_research_costs),
|
||||
* PRAutoBlogger_Logger, WordPress $wpdb + get_posts.
|
||||
*
|
||||
* @see core/class-post-assembler.php — amortize_research_costs() does the work.
|
||||
* @see class-activator.php — Schedules the daily cron.
|
||||
* @see class-deactivator.php — Unschedules on deactivate.
|
||||
* @see ARCHITECTURE.md — Cost tracking data flow.
|
||||
*/
|
||||
class PRAutoBlogger_Research_Reaper {
|
||||
|
||||
/** Grace window — live pipelines have this long to finish amortizing before reaping. */
|
||||
private const GRACE_WINDOW_SECONDS = 3600; // 1 hour.
|
||||
|
||||
/** Stale window — orphans with no sibling posts are deleted outright after this long. */
|
||||
private const STALE_WINDOW_SECONDS = 7 * 86400; // 7 days.
|
||||
|
||||
/**
|
||||
* Cron action handler — entrypoint for the daily WP-Cron event.
|
||||
*
|
||||
* Pure delegate. Never throws.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function on_cron(): void {
|
||||
self::reap();
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan and reap orphan `llm_research` rows. Returns a summary that the
|
||||
* WP-CLI command prints and the cron path ignores.
|
||||
*
|
||||
* Side effects: SELECT / INSERT / DELETE on generation_log; post_meta
|
||||
* reads; one INFO log line per reaped or stale-deleted
|
||||
* orphan; one WARNING on DB error.
|
||||
*
|
||||
* @return array{reaped: int, deleted: int, skipped: int}
|
||||
*/
|
||||
public static function reap(): array {
|
||||
$stats = [ 'reaped' => 0, 'deleted' => 0, 'skipped' => 0 ];
|
||||
|
||||
try {
|
||||
global $wpdb;
|
||||
$log_table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||
$grace = gmdate( 'Y-m-d H:i:s', time() - self::GRACE_WINDOW_SECONDS );
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$orphans = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT id, run_id, created_at FROM {$log_table}
|
||||
WHERE stage = 'llm_research' AND post_id IS NULL AND created_at < %s",
|
||||
$grace
|
||||
)
|
||||
);
|
||||
if ( ! is_array( $orphans ) ) {
|
||||
return $stats;
|
||||
}
|
||||
|
||||
$stale_cutoff = time() - self::STALE_WINDOW_SECONDS;
|
||||
foreach ( $orphans as $orphan ) {
|
||||
self::handle_orphan( $orphan, $stale_cutoff, $log_table, $stats );
|
||||
}
|
||||
} catch ( \Throwable $e ) {
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
'Reaper encountered DB error: ' . $e->getMessage(),
|
||||
'cost-tracker'
|
||||
);
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route one orphan row: amortize if siblings exist, delete if stale,
|
||||
* otherwise skip and try again next run.
|
||||
*
|
||||
* @param object $orphan DB row with id, run_id, created_at.
|
||||
* @param int $stale_cutoff Unix timestamp — orphans older than this delete.
|
||||
* @param string $log_table Fully-qualified gen_log table name.
|
||||
* @param array $stats Counters, mutated by reference.
|
||||
* @return void
|
||||
*/
|
||||
private static function handle_orphan( object $orphan, int $stale_cutoff, string $log_table, array &$stats ): void {
|
||||
global $wpdb;
|
||||
$run_id = (string) ( $orphan->run_id ?? '' );
|
||||
$created_ts = strtotime( (string) ( $orphan->created_at ?? '' ) );
|
||||
$is_stale = false !== $created_ts && $created_ts < $stale_cutoff;
|
||||
|
||||
if ( '' === $run_id ) {
|
||||
if ( $is_stale ) {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->delete( $log_table, [ 'id' => (int) $orphan->id ] );
|
||||
$stats['deleted']++;
|
||||
} else {
|
||||
$stats['skipped']++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$post_ids = self::find_posts_for_run_id( $run_id );
|
||||
if ( count( $post_ids ) >= 1 ) {
|
||||
PRAutoBlogger_Post_Assembler::amortize_research_costs( $run_id );
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf( 'Reaped research cost across %d articles for run %s.', count( $post_ids ), $run_id ),
|
||||
'cost-tracker'
|
||||
);
|
||||
$stats['reaped']++;
|
||||
return;
|
||||
}
|
||||
|
||||
if ( $is_stale ) {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->delete( $log_table, [ 'id' => (int) $orphan->id ] );
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf( 'Deleted 7-day-stale orphan research row for run %s — no articles to attribute.', $run_id ),
|
||||
'cost-tracker'
|
||||
);
|
||||
$stats['deleted']++;
|
||||
return;
|
||||
}
|
||||
|
||||
$stats['skipped']++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return distinct post IDs for a given run_id. Prefers post_meta;
|
||||
* falls back to gen_log rows for legacy posts.
|
||||
*
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @return int[] Distinct post IDs.
|
||||
*/
|
||||
private static function find_posts_for_run_id( string $run_id ): array {
|
||||
$meta_ids = get_posts( [
|
||||
'post_type' => 'any',
|
||||
'post_status' => 'any',
|
||||
'meta_key' => '_prautoblogger_run_id',
|
||||
'meta_value' => $run_id,
|
||||
'fields' => 'ids',
|
||||
'posts_per_page' => -1,
|
||||
'suppress_filters' => true,
|
||||
] );
|
||||
if ( is_array( $meta_ids ) && ! empty( $meta_ids ) ) {
|
||||
return array_map( 'intval', $meta_ids );
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$log_table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$ids = $wpdb->get_col(
|
||||
$wpdb->prepare(
|
||||
"SELECT DISTINCT post_id FROM {$log_table} WHERE run_id = %s AND post_id IS NOT NULL",
|
||||
$run_id
|
||||
)
|
||||
);
|
||||
return is_array( $ids ) ? array_map( 'intval', $ids ) : [];
|
||||
}
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
<?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,40 +4,19 @@ declare(strict_types=1);
|
||||
/**
|
||||
* Orchestrates data collection from all enabled source providers.
|
||||
*
|
||||
* Iterates through enabled sources (Reddit, LLM Research), calls their
|
||||
* Iterates through enabled sources (currently Reddit), 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 — Reddit source provider.
|
||||
* @see providers/class-llm-research-provider.php — LLM deep research provider.
|
||||
* @see providers/class-reddit-provider.php — Primary source 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.
|
||||
*
|
||||
@@ -46,10 +25,7 @@ class PRAutoBlogger_Source_Collector {
|
||||
* @return int Total number of new records inserted.
|
||||
*/
|
||||
public function collect_from_all_sources(): int {
|
||||
$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.
|
||||
$enabled = json_decode( get_option( 'prautoblogger_enabled_sources', '["reddit"]' ), true );
|
||||
if ( ! is_array( $enabled ) ) {
|
||||
$enabled = [ 'reddit' ];
|
||||
}
|
||||
@@ -66,15 +42,14 @@ 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, %d refreshed).', count( $data ), $source_type, $inserted, $refreshed ),
|
||||
sprintf( 'Collected %d items from %s (%d new).', count( $data ), $source_type, $inserted ),
|
||||
'collector'
|
||||
);
|
||||
} catch ( \Throwable $e ) {
|
||||
PRAutoBlogger_Logger::instance()->error( sprintf( 'Collection %s from %s: %s', get_class( $e ), $source_type, $e->getMessage() ), 'collector' );
|
||||
} catch ( \Exception $e ) {
|
||||
PRAutoBlogger_Logger::instance()->error( "Collection from {$source_type} FAILED: " . $e->getMessage(), 'collector' );
|
||||
// Continue with other sources — one failure shouldn't stop everything.
|
||||
}
|
||||
}
|
||||
@@ -82,61 +57,16 @@ 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 … 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.
|
||||
* Uses INSERT IGNORE to handle duplicates gracefully (UNIQUE index).
|
||||
*
|
||||
* Side effects: database inserts/updates.
|
||||
* Side effects: database inserts.
|
||||
*
|
||||
* @param PRAutoBlogger_Source_Data[] $items
|
||||
*
|
||||
* @return int Number of new records inserted (updates not counted).
|
||||
* @return int Number of new records inserted.
|
||||
*/
|
||||
private function store_data( array $items ): int {
|
||||
global $wpdb;
|
||||
@@ -146,18 +76,13 @@ class PRAutoBlogger_Source_Collector {
|
||||
foreach ( $items as $item ) {
|
||||
$row = $item->to_db_row();
|
||||
|
||||
// Upsert: insert new items, refresh timestamps on duplicates so
|
||||
// the analyzer's 24-hour window picks them up again.
|
||||
// Use INSERT IGNORE so duplicate (source_type, source_id) pairs are silently skipped.
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$result = $wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"INSERT INTO {$table}
|
||||
"INSERT IGNORE 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)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
collected_at = VALUES(collected_at),
|
||||
score = VALUES(score),
|
||||
comment_count = VALUES(comment_count)",
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %d, %d, %s, %s, %s)",
|
||||
$row['source_type'],
|
||||
$row['source_id'],
|
||||
$row['subreddit'],
|
||||
@@ -172,9 +97,7 @@ class PRAutoBlogger_Source_Collector {
|
||||
)
|
||||
);
|
||||
|
||||
// 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 ) {
|
||||
if ( false !== $result && $result > 0 ) {
|
||||
$inserted++;
|
||||
}
|
||||
}
|
||||
@@ -191,8 +114,8 @@ class PRAutoBlogger_Source_Collector {
|
||||
*/
|
||||
private function get_provider( string $source_type ): ?PRAutoBlogger_Source_Provider_Interface {
|
||||
$providers = [
|
||||
'reddit' => PRAutoBlogger_Reddit_Provider::class,
|
||||
'llm_research' => PRAutoBlogger_LLM_Research_Provider::class,
|
||||
'reddit' => PRAutoBlogger_Reddit_Provider::class,
|
||||
// New providers can be added here — see CONVENTIONS.md for how-to.
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -231,27 +154,14 @@ 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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
<?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,25 +84,4 @@ 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,7 +21,6 @@ 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,
|
||||
@@ -30,17 +29,15 @@ class PRAutoBlogger_Content_Request {
|
||||
int $min_word_count,
|
||||
int $max_word_count,
|
||||
string $niche_description,
|
||||
array $topic_exclusions = [],
|
||||
string $writing_instructions = ''
|
||||
array $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;
|
||||
$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;
|
||||
}
|
||||
|
||||
public function get_idea(): PRAutoBlogger_Article_Idea {
|
||||
@@ -70,8 +67,4 @@ class PRAutoBlogger_Content_Request {
|
||||
public function get_topic_exclusions(): array {
|
||||
return $this->topic_exclusions;
|
||||
}
|
||||
|
||||
public function get_writing_instructions(): string {
|
||||
return $this->writing_instructions;
|
||||
}
|
||||
}
|
||||
|
||||
78
includes/providers/class-cloudflare-image-pricing.php
Normal file
78
includes/providers/class-cloudflare-image-pricing.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Pricing + model-id resolution helpers for the Cloudflare image provider.
|
||||
*
|
||||
* Kept separate from the provider so the provider stays under the 300-line
|
||||
* cap and so cost estimation can be unit-tested without spinning up HTTP
|
||||
* mocks. Mirrors the `class-open-router-pricing.php` split.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Cloudflare_Image_Provider (both generate_image()
|
||||
* and estimate_cost() delegate here).
|
||||
* Dependencies: None — pure calculation + option reads.
|
||||
*
|
||||
* @see class-cloudflare-image-provider.php — Caller.
|
||||
* @see class-open-router-pricing.php — Sibling pattern for LLM pricing.
|
||||
*/
|
||||
class PRAutoBlogger_Cloudflare_Image_Pricing {
|
||||
|
||||
/**
|
||||
* Cloudflare Workers AI FLUX pricing (USD per megapixel), April 2026.
|
||||
* Single source of truth — update here if Cloudflare repricies.
|
||||
*/
|
||||
private const COST_PER_MP_SCHNELL_USD = 0.0011;
|
||||
private const COST_MULTIPLIER_DEV = 4.0;
|
||||
|
||||
/**
|
||||
* Short-alias → fully-qualified Workers AI model id.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const MODEL_ALIAS_MAP = [
|
||||
'flux-1-schnell' => '@cf/black-forest-labs/flux-1-schnell',
|
||||
'flux-1-dev' => '@cf/black-forest-labs/flux-1-dev',
|
||||
];
|
||||
|
||||
/**
|
||||
* Normalize a caller's model hint to a fully-qualified Workers AI id.
|
||||
*
|
||||
* Accepts short aliases (`flux-1-schnell`), fully-qualified names
|
||||
* (`@cf/black-forest-labs/...`), or empty. Empty input falls back to
|
||||
* the site option, then to the hardcoded default.
|
||||
*
|
||||
* @param string $hint Caller-supplied model identifier (may be empty).
|
||||
* @return string Fully-qualified Workers AI model id suitable for the URL path.
|
||||
*/
|
||||
public function resolve_model( string $hint ): string {
|
||||
$hint = trim( $hint );
|
||||
if ( '' === $hint ) {
|
||||
$hint = (string) get_option( 'prautoblogger_image_model', PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL );
|
||||
}
|
||||
if ( 0 === strpos( $hint, '@cf/' ) ) {
|
||||
return $hint;
|
||||
}
|
||||
return self::MODEL_ALIAS_MAP[ $hint ] ?? self::MODEL_ALIAS_MAP['flux-1-schnell'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate USD cost for a single image at the given dimensions.
|
||||
*
|
||||
* FLUX is priced per megapixel of output. Schnell is the baseline;
|
||||
* [dev] is a ~4x multiplier. We clamp the MP floor at 0.01 so a
|
||||
* tiny thumbnail doesn't round to zero and bypass the budget check.
|
||||
*
|
||||
* @param int $width Width in pixels.
|
||||
* @param int $height Height in pixels.
|
||||
* @param string $model Fully-qualified Workers AI model id (call resolve_model first).
|
||||
* @return float USD cost rounded to 6 decimals.
|
||||
*/
|
||||
public function estimate_cost( int $width, int $height, string $model ): float {
|
||||
$mp = max( 0.01, ( $width * $height ) / 1_000_000 );
|
||||
$rate = self::COST_PER_MP_SCHNELL_USD;
|
||||
if ( false !== strpos( $model, 'flux-1-dev' ) ) {
|
||||
$rate *= self::COST_MULTIPLIER_DEV;
|
||||
}
|
||||
return round( $mp * $rate, 6 );
|
||||
}
|
||||
}
|
||||
271
includes/providers/class-cloudflare-image-provider.php
Normal file
271
includes/providers/class-cloudflare-image-provider.php
Normal file
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Cloudflare Workers AI image provider — FLUX.1 family.
|
||||
*
|
||||
* Calls `https://api.cloudflare.com/client/v4/accounts/{id}/ai/run/{model}`
|
||||
* directly. We bypass the Cloudflare AI Gateway for v1 because the gateway
|
||||
* route to Workers AI currently 403s (see decision D-001); the OpenRouter
|
||||
* gateway path is unaffected.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Image_Pipeline (commit 1b) during the content
|
||||
* generation run, after editorial review, before publish. Also
|
||||
* triggered by the admin "Test Connection" action.
|
||||
* Dependencies: PRAutoBlogger_Encryption (token decryption), PRAutoBlogger_Logger
|
||||
* (WARN/ERROR lines), PRAutoBlogger_Cloudflare_Image_Pricing
|
||||
* (model resolution + cost estimation).
|
||||
*
|
||||
* @see interface-image-provider.php Interface this class implements.
|
||||
* @see class-cloudflare-image-pricing.php Cost + model id helpers.
|
||||
* @see class-cloudflare-image-support.php Token, account, URL, and log helpers.
|
||||
* @see class-cloudflare-image-validator.php Credential check helper.
|
||||
* @see class-open-router-provider.php Sibling provider; same retry pattern.
|
||||
* @see ARCHITECTURE.md External API Integrations, key decision #16.
|
||||
*/
|
||||
class PRAutoBlogger_Cloudflare_Image_Provider implements PRAutoBlogger_Image_Provider_Interface {
|
||||
|
||||
private const PROVIDER_ID = 'cloudflare_workers_ai';
|
||||
private const MIN_DIMENSION_PX = 64;
|
||||
private const MAX_DIMENSION_PX = 2048;
|
||||
private const DEFAULT_STEPS_SCHNELL = 4;
|
||||
private const RETRY_AFTER_CAP_S = 60;
|
||||
|
||||
/**
|
||||
* Pricing + model-id resolver. Constructed once per provider instance.
|
||||
*
|
||||
* @var PRAutoBlogger_Cloudflare_Image_Pricing
|
||||
*/
|
||||
private PRAutoBlogger_Cloudflare_Image_Pricing $pricing;
|
||||
|
||||
/**
|
||||
* Token, account, URL, and log helpers — pulled out of this class to
|
||||
* keep it under the 300-line cap.
|
||||
*
|
||||
* @var PRAutoBlogger_Cloudflare_Image_Support
|
||||
*/
|
||||
private PRAutoBlogger_Cloudflare_Image_Support $support;
|
||||
|
||||
/**
|
||||
* Construct the provider with its default pricing + support helpers.
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->pricing = new PRAutoBlogger_Cloudflare_Image_Pricing();
|
||||
$this->support = new PRAutoBlogger_Cloudflare_Image_Support();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a single image from a prompt at the requested dimensions.
|
||||
*
|
||||
* Retries 5xx / 429 / network errors with exponential backoff bounded
|
||||
* by PRAUTOBLOGGER_MAX_RETRIES. 4xx other than 429 fail loudly on the
|
||||
* first attempt — retrying a bad prompt or a bad token wastes money.
|
||||
*
|
||||
* Side effects: one HTTP request per attempt; one Logger line per attempt.
|
||||
*
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* @throws \InvalidArgumentException If dimensions out of range or prompt empty.
|
||||
* @throws \RuntimeException On auth failure, bad response shape, or retry exhaustion.
|
||||
*/
|
||||
public function generate_image( string $prompt, int $width, int $height, array $options = [] ): array {
|
||||
$this->assert_valid_dimensions( $width, $height );
|
||||
$prompt = trim( $prompt );
|
||||
if ( '' === $prompt ) {
|
||||
throw new \InvalidArgumentException(
|
||||
esc_html__( 'Image prompt cannot be empty.', 'prautoblogger' )
|
||||
);
|
||||
}
|
||||
|
||||
$account_id = $this->support->get_account_id();
|
||||
$api_token = $this->support->get_api_token();
|
||||
$model = $this->pricing->resolve_model( (string) ( $options['model'] ?? '' ) );
|
||||
|
||||
$body = [
|
||||
'prompt' => $prompt,
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
'steps' => (int) ( $options['steps'] ?? self::DEFAULT_STEPS_SCHNELL ),
|
||||
];
|
||||
if ( isset( $options['seed'] ) ) {
|
||||
$body['seed'] = (int) $options['seed'];
|
||||
}
|
||||
|
||||
$url = $this->support->build_endpoint_url( $account_id, $model );
|
||||
$headers = [
|
||||
'Authorization' => 'Bearer ' . $api_token,
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'image/*',
|
||||
];
|
||||
$started_at = microtime( true );
|
||||
$last_error = '';
|
||||
|
||||
for ( $attempt = 1; $attempt <= PRAUTOBLOGGER_MAX_RETRIES; $attempt++ ) {
|
||||
$response = wp_remote_post(
|
||||
$url,
|
||||
[
|
||||
'timeout' => PRAUTOBLOGGER_API_TIMEOUT_SECONDS,
|
||||
'headers' => $headers,
|
||||
'body' => wp_json_encode( $body ),
|
||||
]
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
$last_error = $response->get_error_message();
|
||||
$this->support->log_retryable_failure( $attempt, 'network', $last_error );
|
||||
$this->sleep_for_attempt( $attempt );
|
||||
continue;
|
||||
}
|
||||
|
||||
$status = (int) wp_remote_retrieve_response_code( $response );
|
||||
$raw = (string) wp_remote_retrieve_body( $response );
|
||||
|
||||
if ( 429 === $status || $status >= 500 ) {
|
||||
$last_error = sprintf( 'HTTP %d: %s', $status, substr( $raw, 0, 300 ) );
|
||||
$this->support->log_retryable_failure( $attempt, (string) $status, $last_error );
|
||||
if ( $attempt < PRAUTOBLOGGER_MAX_RETRIES ) {
|
||||
$retry_after = (int) wp_remote_retrieve_header( $response, 'retry-after' );
|
||||
$this->sleep_for_attempt( $attempt, $retry_after );
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( $status >= 400 ) {
|
||||
$this->support->log_client_error( $status, $api_token, $account_id, $raw );
|
||||
throw new \RuntimeException(
|
||||
sprintf(
|
||||
/* translators: %1$d: HTTP status, %2$s: error body. */
|
||||
esc_html__( 'Cloudflare Workers AI error (HTTP %1$d): %2$s', 'prautoblogger' ),
|
||||
$status,
|
||||
esc_html( substr( $raw, 0, 300 ) )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Success. FLUX on Workers AI returns either raw image bytes
|
||||
// (Content-Type: image/*) or a JSON envelope with base64 under
|
||||
// `result.image`. Handle both without branching the caller.
|
||||
$mime = (string) wp_remote_retrieve_header( $response, 'content-type' );
|
||||
[ $raw, $mime ] = $this->normalize_response_body( $raw, $mime );
|
||||
|
||||
return [
|
||||
'bytes' => $raw,
|
||||
'mime_type' => $mime,
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
'model' => $model,
|
||||
'seed' => isset( $body['seed'] ) ? (int) $body['seed'] : null,
|
||||
'cost_usd' => $this->pricing->estimate_cost( $width, $height, $model ),
|
||||
'latency_ms' => (int) ( ( microtime( true ) - $started_at ) * 1000 ),
|
||||
];
|
||||
}
|
||||
|
||||
throw new \RuntimeException(
|
||||
sprintf(
|
||||
/* translators: %1$d: max retries, %2$s: last error. */
|
||||
esc_html__( 'Cloudflare Workers AI failed after %1$d attempts. Last error: %2$s', 'prautoblogger' ),
|
||||
PRAUTOBLOGGER_MAX_RETRIES,
|
||||
esc_html( $last_error )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate the USD cost of a single generation call.
|
||||
*
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function estimate_cost( int $width, int $height, array $options = [] ): float {
|
||||
$model = $this->pricing->resolve_model( (string) ( $options['model'] ?? '' ) );
|
||||
return $this->pricing->estimate_cost( $width, $height, $model );
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string Machine-readable provider id used in logs and settings.
|
||||
*/
|
||||
public function get_provider_name(): string {
|
||||
return self::PROVIDER_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify credentials are present and the API is reachable.
|
||||
*
|
||||
* Delegates to PRAutoBlogger_Cloudflare_Image_Validator so the HTTP probe
|
||||
* and error-shape code can be tested and sized independently.
|
||||
*
|
||||
* Side effects: one HTTP GET via the validator; one Logger line on failure.
|
||||
*
|
||||
* @return array{status: string, message: string, debug?: string}
|
||||
*/
|
||||
public function validate_credentials_detailed(): array {
|
||||
return ( new PRAutoBlogger_Cloudflare_Image_Validator() )->run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce the interface's dimension contract.
|
||||
*
|
||||
* @param int $width Width in pixels.
|
||||
* @param int $height Height in pixels.
|
||||
* @return void
|
||||
* @throws \InvalidArgumentException If either dimension is outside [MIN, MAX].
|
||||
*/
|
||||
private function assert_valid_dimensions( int $width, int $height ): void {
|
||||
if (
|
||||
$width < self::MIN_DIMENSION_PX || $width > self::MAX_DIMENSION_PX ||
|
||||
$height < self::MIN_DIMENSION_PX || $height > self::MAX_DIMENSION_PX
|
||||
) {
|
||||
throw new \InvalidArgumentException(
|
||||
sprintf(
|
||||
/* translators: %1$d: width, %2$d: height, %3$d: min, %4$d: max. */
|
||||
esc_html__( 'Image dimensions %1$dx%2$d are outside the supported range (%3$d–%4$d px per axis).', 'prautoblogger' ),
|
||||
$width,
|
||||
$height,
|
||||
self::MIN_DIMENSION_PX,
|
||||
self::MAX_DIMENSION_PX
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a 2xx response into [bytes, mime-type] whether the body
|
||||
* arrives as raw image bytes or as a JSON envelope with base64 image.
|
||||
*
|
||||
* @param string $raw Raw response body.
|
||||
* @param string $mime Response Content-Type header value (may be empty).
|
||||
* @return array{0: string, 1: string} [image bytes, mime type]
|
||||
* @throws \RuntimeException If the body matches neither expected shape.
|
||||
*/
|
||||
private function normalize_response_body( string $raw, string $mime ): array {
|
||||
if ( '' !== $mime && 0 === stripos( $mime, 'image/' ) ) {
|
||||
return [ $raw, $mime ];
|
||||
}
|
||||
$decoded = json_decode( $raw, true );
|
||||
if ( is_array( $decoded ) && isset( $decoded['result']['image'] ) && is_string( $decoded['result']['image'] ) ) {
|
||||
$bytes = (string) base64_decode( $decoded['result']['image'], true );
|
||||
if ( '' !== $bytes ) {
|
||||
return [ $bytes, 'image/png' ];
|
||||
}
|
||||
}
|
||||
throw new \RuntimeException(
|
||||
esc_html__( 'Cloudflare Workers AI returned an unexpected response shape.', 'prautoblogger' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep between retries. Retry-After header value wins when present,
|
||||
* otherwise exponential backoff from the PRAUTOBLOGGER_* constants.
|
||||
*
|
||||
* @param int $attempt 1-based attempt number that just failed.
|
||||
* @param int $retry_after_secs Retry-After header value (0 if absent).
|
||||
* @return void
|
||||
*/
|
||||
private function sleep_for_attempt( int $attempt, int $retry_after_secs = 0 ): void {
|
||||
$backoff = PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS * (int) pow( 2, $attempt - 1 );
|
||||
$delay = $retry_after_secs > 0 ? min( $retry_after_secs, self::RETRY_AFTER_CAP_S ) : $backoff;
|
||||
if ( $delay > 0 ) {
|
||||
sleep( $delay );
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
101
includes/providers/class-cloudflare-image-support.php
Normal file
101
includes/providers/class-cloudflare-image-support.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Small support helpers for the Cloudflare image provider — credential
|
||||
* lookups, URL building, and structured log lines.
|
||||
*
|
||||
* Split out of the provider so the provider stays under the 300-line cap
|
||||
* and so these tiny pieces are individually testable. Pure functions plus
|
||||
* one logger forward; no state.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Cloudflare_Image_Provider only.
|
||||
* Dependencies: PRAutoBlogger_Encryption (token decryption),
|
||||
* PRAutoBlogger_Logger (WARN/ERROR forwarding).
|
||||
*
|
||||
* @see class-cloudflare-image-provider.php Sole caller.
|
||||
*/
|
||||
class PRAutoBlogger_Cloudflare_Image_Support {
|
||||
|
||||
/**
|
||||
* Decrypt the Cloudflare API token from the settings option.
|
||||
*
|
||||
* @return string Plaintext token, or empty string if not set / decrypt fails.
|
||||
*/
|
||||
public function get_api_token(): string {
|
||||
$encrypted = (string) get_option( 'prautoblogger_cloudflare_ai_token', '' );
|
||||
if ( '' === $encrypted ) {
|
||||
return '';
|
||||
}
|
||||
return (string) PRAutoBlogger_Encryption::decrypt( $encrypted );
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the Cloudflare account ID from settings.
|
||||
*
|
||||
* @return string Account UUID, or empty string.
|
||||
*/
|
||||
public function get_account_id(): string {
|
||||
return trim( (string) get_option( 'prautoblogger_cloudflare_account_id', '' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Workers AI run URL for a given account + model.
|
||||
*
|
||||
* @param string $account_id Cloudflare account UUID.
|
||||
* @param string $model Fully-qualified Workers AI model id.
|
||||
* @return string Fully-formed HTTPS URL.
|
||||
*/
|
||||
public function build_endpoint_url( string $account_id, string $model ): string {
|
||||
return sprintf(
|
||||
'https://api.cloudflare.com/client/v4/accounts/%s/ai/run/%s',
|
||||
rawurlencode( $account_id ),
|
||||
ltrim( $model, '/' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a WARN line for a retryable failure.
|
||||
*
|
||||
* @param int $attempt 1-based attempt number.
|
||||
* @param string $failure_class Either 'network' or HTTP status code as string.
|
||||
* @param string $detail Short failure detail for the log.
|
||||
* @return void
|
||||
*/
|
||||
public function log_retryable_failure( int $attempt, string $failure_class, string $detail ): void {
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf(
|
||||
'Cloudflare image gen %s failure (attempt %d/%d): %s',
|
||||
$failure_class,
|
||||
$attempt,
|
||||
PRAUTOBLOGGER_MAX_RETRIES,
|
||||
$detail
|
||||
),
|
||||
'cloudflare-image'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an unrecoverable 4xx with triage context. Never logs the full
|
||||
* secret — only a 4-char token prefix and a 6-char account prefix.
|
||||
*
|
||||
* @param int $status HTTP status returned by Cloudflare.
|
||||
* @param string $api_token Plaintext token.
|
||||
* @param string $account_id Account UUID.
|
||||
* @param string $raw Raw response body (truncated to 300 chars in the log).
|
||||
* @return void
|
||||
*/
|
||||
public function log_client_error( int $status, string $api_token, string $account_id, string $raw ): void {
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
sprintf(
|
||||
'Cloudflare image gen HTTP %d (token_prefix=%s, token_len=%d, account=%s): %s',
|
||||
$status,
|
||||
substr( $api_token, 0, 4 ),
|
||||
strlen( $api_token ),
|
||||
substr( $account_id, 0, 6 ),
|
||||
substr( $raw, 0, 300 )
|
||||
),
|
||||
'cloudflare-image'
|
||||
);
|
||||
}
|
||||
}
|
||||
116
includes/providers/class-cloudflare-image-validator.php
Normal file
116
includes/providers/class-cloudflare-image-validator.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Credential + connectivity validator for the Cloudflare image provider.
|
||||
*
|
||||
* Split out of the provider to keep each file under the 300-line cap and
|
||||
* to keep the validation path easy to unit-test without spinning up the
|
||||
* retry loop. The provider's `validate_credentials_detailed()` delegates
|
||||
* straight to `run()` here.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Cloudflare_Image_Provider::validate_credentials_detailed()
|
||||
* which is called by the admin "Test Connection" action.
|
||||
* Dependencies: PRAutoBlogger_Encryption (token decryption), PRAutoBlogger_Logger
|
||||
* (WARN/ERROR lines), wp_remote_get().
|
||||
*
|
||||
* @see class-cloudflare-image-provider.php Parent class that delegates here.
|
||||
* @see interface-image-provider.php Return-shape contract.
|
||||
*/
|
||||
class PRAutoBlogger_Cloudflare_Image_Validator {
|
||||
|
||||
private const TIMEOUT_SECONDS = 15;
|
||||
|
||||
/**
|
||||
* Run a non-destructive credential check.
|
||||
*
|
||||
* Lightweight probe: hits the account-scoped AI models listing endpoint.
|
||||
* Never generates a real image — that would cost money on every click.
|
||||
*
|
||||
* Side effects: one HTTP GET; one Logger line on failure.
|
||||
*
|
||||
* @return array{status: string, message: string, debug?: string}
|
||||
*/
|
||||
public function run(): array {
|
||||
$account_id = trim( (string) get_option( 'prautoblogger_cloudflare_account_id', '' ) );
|
||||
if ( '' === $account_id ) {
|
||||
return $this->err(
|
||||
'account_id_empty',
|
||||
__( 'Cloudflare account ID is not set. Go to PRAutoBlogger → Settings → Images.', 'prautoblogger' )
|
||||
);
|
||||
}
|
||||
|
||||
$encrypted = (string) get_option( 'prautoblogger_cloudflare_ai_token', '' );
|
||||
$api_token = '' === $encrypted ? '' : (string) PRAutoBlogger_Encryption::decrypt( $encrypted );
|
||||
if ( '' === $api_token ) {
|
||||
return $this->err(
|
||||
'token_empty',
|
||||
__( 'Cloudflare API token is not set. Go to PRAutoBlogger → Settings → Images.', 'prautoblogger' )
|
||||
);
|
||||
}
|
||||
|
||||
$response = wp_remote_get(
|
||||
sprintf(
|
||||
'https://api.cloudflare.com/client/v4/accounts/%s/ai/models/search?per_page=1',
|
||||
rawurlencode( $account_id )
|
||||
),
|
||||
[
|
||||
'timeout' => self::TIMEOUT_SECONDS,
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $api_token,
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
$msg = $response->get_error_message();
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
sprintf( 'Cloudflare credential check wp_error: %s', $msg ),
|
||||
'cloudflare-image'
|
||||
);
|
||||
return $this->err(
|
||||
'wp_error:' . $msg,
|
||||
sprintf(
|
||||
/* translators: %s: transport-layer error message. */
|
||||
esc_html__( 'Network error reaching Cloudflare: %s', 'prautoblogger' ),
|
||||
esc_html( $msg )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$status = (int) wp_remote_retrieve_response_code( $response );
|
||||
if ( 200 === $status ) {
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'message' => __( 'Cloudflare Workers AI connected.', 'prautoblogger' ),
|
||||
];
|
||||
}
|
||||
|
||||
$body = (string) wp_remote_retrieve_body( $response );
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf( 'Cloudflare credential check HTTP %d: %s', $status, substr( $body, 0, 200 ) ),
|
||||
'cloudflare-image'
|
||||
);
|
||||
return $this->err(
|
||||
sprintf( 'http_%d', $status ),
|
||||
sprintf(
|
||||
/* translators: %1$d: HTTP status, %2$s: response body. */
|
||||
esc_html__( 'Cloudflare returned HTTP %1$d. %2$s', 'prautoblogger' ),
|
||||
$status,
|
||||
esc_html( substr( $body, 0, 200 ) )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status: string, message: string, debug: string}
|
||||
*/
|
||||
private function err( string $debug, string $message ): array {
|
||||
return [
|
||||
'status' => 'error',
|
||||
'message' => $message,
|
||||
'debug' => $debug,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Exception raised when an image provider rejects a prompt as NSFW.
|
||||
*
|
||||
* Image providers (Runware, OpenRouter) return provider-specific error
|
||||
* shapes when their content filters block a prompt. We catch those shapes
|
||||
* in each provider and raise this typed exception so the image pipeline
|
||||
* can distinguish a content-filter rejection from a generic 4xx and
|
||||
* retry once with a sanitized fallback prompt.
|
||||
*
|
||||
* Triggered by: image providers on NSFW-filter rejection.
|
||||
* Dependencies: None.
|
||||
*
|
||||
* @see providers/class-runware-image-provider.php — May raise this.
|
||||
* @see providers/class-open-router-image-provider.php — May raise this.
|
||||
* @see core/class-image-nsfw-retry.php — Catches + rebuilds prompt.
|
||||
*/
|
||||
class PRAutoBlogger_Image_NSFW_Blocked extends \RuntimeException {
|
||||
|
||||
/**
|
||||
* The raw upstream response body, kept verbatim for log forensics.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private string $upstream_body;
|
||||
|
||||
/**
|
||||
* @param string $message Human-readable summary.
|
||||
* @param string $upstream_body Raw response body from the provider.
|
||||
* @param \Throwable|null $previous Optional previous exception.
|
||||
*/
|
||||
public function __construct( string $message, string $upstream_body = '', ?\Throwable $previous = null ) {
|
||||
parent::__construct( $message, 0, $previous );
|
||||
$this->upstream_body = $upstream_body;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string Raw upstream body (may be empty).
|
||||
*/
|
||||
public function get_upstream_body(): string {
|
||||
return $this->upstream_body;
|
||||
}
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Configuration helpers for OpenRouter provider.
|
||||
*
|
||||
* Resolves admin-configured settings for API endpoint selection
|
||||
* (direct vs. Cloudflare AI Gateway routing) and caching behavior.
|
||||
*
|
||||
* Dependencies: get_option() for WordPress settings lookups.
|
||||
*
|
||||
* @see class-open-router-provider.php — Parent class that uses this config.
|
||||
* @see class-open-router-validator.php — Also uses API base URL resolution.
|
||||
* @see ARCHITECTURE.md — External API Integrations section.
|
||||
*/
|
||||
class PRAutoBlogger_OpenRouter_Config {
|
||||
|
||||
/**
|
||||
* Default direct-to-OpenRouter endpoint.
|
||||
*
|
||||
* Used when no Cloudflare AI Gateway URL is configured. The active endpoint
|
||||
* is resolved per-request via get_api_base_url() so admins can flip to an
|
||||
* AI Gateway proxy without code changes.
|
||||
*/
|
||||
private const DEFAULT_API_BASE_URL = 'https://openrouter.ai/api/v1';
|
||||
|
||||
/**
|
||||
* Resolve the API base URL.
|
||||
*
|
||||
* If an admin has configured a Cloudflare AI Gateway URL in settings,
|
||||
* we route through that instead of hitting OpenRouter directly. The
|
||||
* gateway is a transparent OpenAI/OpenRouter-compatible proxy that
|
||||
* adds caching, cost logging, rate-limiting, and provider fallback
|
||||
* — see ARCHITECTURE.md "External API Integrations".
|
||||
*
|
||||
* Expected gateway URL shape:
|
||||
* https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}/openrouter
|
||||
*
|
||||
* @return string Base URL with no trailing slash.
|
||||
*/
|
||||
public function get_api_base_url(): string {
|
||||
$override = trim( (string) get_option( 'prautoblogger_ai_gateway_base_url', '' ) );
|
||||
if ( '' === $override ) {
|
||||
return self::DEFAULT_API_BASE_URL;
|
||||
}
|
||||
// Only accept https to avoid plaintext-auth regressions.
|
||||
if ( 0 !== stripos( $override, 'https://' ) ) {
|
||||
return self::DEFAULT_API_BASE_URL;
|
||||
}
|
||||
return rtrim( $override, '/' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache TTL (seconds) for AI Gateway response caching.
|
||||
*
|
||||
* Cloudflare honours the `cf-aig-cache-ttl` request header and serves
|
||||
* cached responses for identical payloads within the TTL window. Only
|
||||
* meaningful when a gateway base URL is configured; harmless otherwise.
|
||||
*
|
||||
* @return int Non-negative integer seconds; 0 disables caching.
|
||||
*/
|
||||
public function get_cache_ttl_seconds(): int {
|
||||
$ttl = (int) get_option( 'prautoblogger_ai_gateway_cache_ttl', 0 );
|
||||
return $ttl > 0 ? $ttl : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if routing through Cloudflare AI Gateway vs. direct-to-OpenRouter.
|
||||
*
|
||||
* @return bool True if a gateway URL is configured.
|
||||
*/
|
||||
public function is_via_gateway(): bool {
|
||||
return self::DEFAULT_API_BASE_URL !== $this->get_api_base_url();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default OpenRouter API base URL constant.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_default_api_base_url(): string {
|
||||
return self::DEFAULT_API_BASE_URL;
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
<?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 );
|
||||
}
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
<?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 ],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Pricing + model-id resolution helpers for the OpenRouter image provider.
|
||||
*
|
||||
* Mirrors the Runware pricing helper pattern. Each model's cost per image
|
||||
* is a flat rate sourced from OpenRouter's pricing page (April 2026).
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_OpenRouter_Image_Provider (both generate_image()
|
||||
* and estimate_cost() delegate here).
|
||||
* Dependencies: None — pure calculation + option reads.
|
||||
*
|
||||
* @see class-openrouter-image-provider.php — Caller.
|
||||
* @see class-runware-image-pricing.php — Sibling pattern for Runware.
|
||||
*/
|
||||
class PRAutoBlogger_OpenRouter_Image_Pricing {
|
||||
|
||||
/**
|
||||
* Flat per-image cost (USD) for each supported model, April 2026.
|
||||
* Update here when OpenRouter reprices or new models launch.
|
||||
*
|
||||
* @var array<string, float>
|
||||
*/
|
||||
private const COST_PER_IMAGE = [
|
||||
'google/gemini-2.5-flash-image' => 0.005,
|
||||
'google/gemini-3.1-flash-image-preview' => 0.008,
|
||||
'google/gemini-3-pro-image-preview' => 0.03,
|
||||
'openai/gpt-5-image-mini' => 0.02,
|
||||
'openai/gpt-5-image' => 0.08,
|
||||
];
|
||||
|
||||
/**
|
||||
* Resolve the model identifier from a hint, falling back to site option,
|
||||
* then to the PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL constant.
|
||||
*
|
||||
* Never silently swaps to a different model — if the user picked something,
|
||||
* we send it to OpenRouter and let the API error if it's invalid. That way
|
||||
* the user sees a clear log message instead of getting billed for a model
|
||||
* they didn't choose.
|
||||
*
|
||||
* @param string $hint Caller-supplied model identifier (may be empty).
|
||||
* @return string OpenRouter model id.
|
||||
*/
|
||||
public function resolve_model( string $hint ): string {
|
||||
$hint = trim( $hint );
|
||||
if ( '' !== $hint ) {
|
||||
return $hint;
|
||||
}
|
||||
|
||||
$saved = trim( (string) get_option( 'prautoblogger_image_model', '' ) );
|
||||
if ( '' !== $saved ) {
|
||||
return $saved;
|
||||
}
|
||||
|
||||
// Last resort: use the constant from prautoblogger.php (admin can change it).
|
||||
return defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL' )
|
||||
? PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL
|
||||
: 'google/gemini-2.5-flash-image';
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate USD cost for a single image at the given model.
|
||||
*
|
||||
* OpenRouter image models use flat per-image pricing, not per-megapixel.
|
||||
* Width/height are accepted for interface compatibility but don't affect cost.
|
||||
*
|
||||
* @param int $width Width in pixels (unused for flat pricing).
|
||||
* @param int $height Height in pixels (unused for flat pricing).
|
||||
* @param string $model Resolved OpenRouter model id.
|
||||
* @return float USD cost rounded to 6 decimals.
|
||||
*/
|
||||
public function estimate_cost( int $width, int $height, string $model ): float {
|
||||
if ( isset( self::COST_PER_IMAGE[ $model ] ) ) {
|
||||
return round( self::COST_PER_IMAGE[ $model ], 6 );
|
||||
}
|
||||
|
||||
// Unknown model — log it and use a conservative $0.05 estimate.
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf( 'No pricing data for image model "%s". Using $0.05 estimate.', $model ),
|
||||
'openrouter-image'
|
||||
);
|
||||
return 0.05;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full model cost table for admin display.
|
||||
*
|
||||
* @return array<string, float> Model id => cost per image in USD.
|
||||
*/
|
||||
public static function get_model_costs(): array {
|
||||
return self::COST_PER_IMAGE;
|
||||
}
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
<?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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
<?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,11 +114,6 @@ 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.
|
||||
|
||||
@@ -11,17 +11,60 @@ declare(strict_types=1);
|
||||
* Triggered by: Content_Analyzer, Content_Generator, Chief_Editor, Metrics_Collector.
|
||||
* Dependencies: PRAutoBlogger_Encryption (for API key decryption), wp_remote_post(), PRAutoBlogger_OpenRouter_Pricing.
|
||||
*
|
||||
* @see interface-llm-provider.php — Interface this class implements.
|
||||
* @see class-open-router-config.php — Config helpers (base URL, cache TTL).
|
||||
* @see class-open-router-pricing.php — Pricing and model list lookups.
|
||||
* @see class-open-router-validator.php — Credential validation (delegated).
|
||||
* @see class-open-router-request-builder.php — Request header building + cURL filter.
|
||||
* @see class-open-router-response-parser.php — Response parsing + error classification.
|
||||
* @see class-cost-tracker.php — Called after every request to log token usage.
|
||||
* @see ARCHITECTURE.md — Data flow diagram showing where this fits.
|
||||
* @see interface-llm-provider.php — Interface this class implements.
|
||||
* @see class-open-router-pricing.php — Pricing and model list lookups.
|
||||
* @see class-cost-tracker.php — Called after every request to log token usage.
|
||||
* @see ARCHITECTURE.md — Data flow diagram showing where this fits.
|
||||
*/
|
||||
class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_Interface {
|
||||
|
||||
/**
|
||||
* Default direct-to-OpenRouter endpoint. Used when no Cloudflare AI Gateway
|
||||
* URL is configured. The active endpoint is resolved per-request via
|
||||
* get_api_base_url() so admins can flip to an AI Gateway proxy without code.
|
||||
*/
|
||||
private const DEFAULT_API_BASE_URL = 'https://openrouter.ai/api/v1';
|
||||
|
||||
/**
|
||||
* Resolve the API base URL.
|
||||
*
|
||||
* If an admin has configured a Cloudflare AI Gateway URL in settings,
|
||||
* we route through that instead of hitting OpenRouter directly. The
|
||||
* gateway is a transparent OpenAI/OpenRouter-compatible proxy that
|
||||
* adds caching, cost logging, rate-limiting, and provider fallback
|
||||
* — see ARCHITECTURE.md "External API Integrations".
|
||||
*
|
||||
* Expected gateway URL shape:
|
||||
* https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}/openrouter
|
||||
*
|
||||
* @return string Base URL with no trailing slash.
|
||||
*/
|
||||
private function get_api_base_url(): string {
|
||||
$override = trim( (string) get_option( 'prautoblogger_ai_gateway_base_url', '' ) );
|
||||
if ( '' === $override ) {
|
||||
return self::DEFAULT_API_BASE_URL;
|
||||
}
|
||||
// Only accept https to avoid plaintext-auth regressions.
|
||||
if ( 0 !== stripos( $override, 'https://' ) ) {
|
||||
return self::DEFAULT_API_BASE_URL;
|
||||
}
|
||||
return rtrim( $override, '/' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache TTL (seconds) for AI Gateway response caching.
|
||||
*
|
||||
* Cloudflare honours the `cf-aig-cache-ttl` request header and serves
|
||||
* cached responses for identical payloads within the TTL window. Only
|
||||
* meaningful when a gateway base URL is configured; harmless otherwise.
|
||||
*
|
||||
* @return int Non-negative integer seconds; 0 disables caching.
|
||||
*/
|
||||
private function get_cache_ttl_seconds(): int {
|
||||
$ttl = (int) get_option( 'prautoblogger_ai_gateway_cache_ttl', 0 );
|
||||
return $ttl > 0 ? $ttl : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a chat completion request to OpenRouter.
|
||||
*
|
||||
@@ -36,8 +79,7 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
|
||||
* temperature?: float,
|
||||
* max_tokens?: int,
|
||||
* response_format?: array{type: string},
|
||||
* reasoning?: array{enabled: bool, effort?: string},
|
||||
* } $options Optional parameters. Pass 'reasoning' to override the global setting.
|
||||
* } $options Optional parameters.
|
||||
*
|
||||
* @return array{
|
||||
* content: string,
|
||||
@@ -89,31 +131,51 @@ 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();
|
||||
$base_url = $config->get_api_base_url();
|
||||
$base_host = (string) wp_parse_url( $base_url, PHP_URL_HOST );
|
||||
$cache_ttl = $config->get_cache_ttl_seconds();
|
||||
$via_gateway = $config->is_via_gateway();
|
||||
$base_url = $this->get_api_base_url();
|
||||
$base_host = (string) wp_parse_url( $base_url, PHP_URL_HOST );
|
||||
$cache_ttl = $this->get_cache_ttl_seconds();
|
||||
$via_gateway = self::DEFAULT_API_BASE_URL !== $base_url;
|
||||
|
||||
$builder = new PRAutoBlogger_OpenRouter_Request_Builder();
|
||||
$request_headers = $builder->build_headers( $api_key, $via_gateway, $cache_ttl );
|
||||
$curl_auth_filter = $builder->register_curl_auth_filter( $request_headers, $base_host );
|
||||
// Build headers once so the wp_remote_post call and the cURL belt-and-
|
||||
// suspenders filter stay in lockstep. The cf-aig-* headers are only
|
||||
// meaningful when going through a Cloudflare AI Gateway; they are
|
||||
// ignored by direct OpenRouter calls, so sending them unconditionally
|
||||
// when a gateway is configured is safe and keeps the code branch-free.
|
||||
$request_headers = [
|
||||
'Authorization' => 'Bearer ' . $api_key,
|
||||
'Content-Type' => 'application/json',
|
||||
'HTTP-Referer' => home_url(),
|
||||
'X-Title' => 'PRAutoBlogger WordPress Plugin',
|
||||
];
|
||||
if ( $via_gateway && $cache_ttl > 0 ) {
|
||||
$request_headers['cf-aig-cache-ttl'] = (string) $cache_ttl;
|
||||
}
|
||||
|
||||
// Belt-and-suspenders: inject Authorization header at cURL level.
|
||||
// Some hosting environments (Hostinger, certain proxies) strip the
|
||||
// Authorization header from wp_remote_post's 'headers' array before
|
||||
// the request is sent. The http_api_curl action fires after WordPress
|
||||
// configures the cURL handle but before curl_exec — re-setting
|
||||
// CURLOPT_HTTPHEADER here ensures the header reaches the upstream
|
||||
// (either OpenRouter directly or the Cloudflare AI Gateway proxy).
|
||||
$curl_auth_filter = function ( $handle, $parsed_args, $url ) use ( $request_headers, $base_host ): void {
|
||||
// Scope to the configured upstream host only — never leak auth
|
||||
// into unrelated outbound requests made elsewhere in WordPress.
|
||||
if ( '' === $base_host || false === strpos( (string) $url, $base_host ) ) {
|
||||
return;
|
||||
}
|
||||
$curl_headers = [];
|
||||
foreach ( $request_headers as $name => $value ) {
|
||||
$curl_headers[] = $name . ': ' . $value;
|
||||
}
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt
|
||||
curl_setopt( $handle, CURLOPT_HTTPHEADER, $curl_headers );
|
||||
};
|
||||
add_action( 'http_api_curl', $curl_auth_filter, 99, 3 );
|
||||
|
||||
try {
|
||||
$parser = new PRAutoBlogger_OpenRouter_Response_Parser();
|
||||
|
||||
for ( $attempt = 1; $attempt <= PRAUTOBLOGGER_MAX_RETRIES; $attempt++ ) {
|
||||
$response = wp_remote_post(
|
||||
$base_url . '/chat/completions',
|
||||
@@ -143,7 +205,7 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
|
||||
$data = json_decode( $body_raw, true );
|
||||
|
||||
// Rate limited (429) or server error (5xx) — retry with backoff.
|
||||
if ( $parser->is_retryable( $status_code ) ) {
|
||||
if ( 429 === $status_code || $status_code >= 500 ) {
|
||||
$last_error = sprintf( 'HTTP %d: %s', $status_code, $body_raw );
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf( 'OpenRouter HTTP %d (attempt %d/%d): %s', $status_code, $attempt, PRAUTOBLOGGER_MAX_RETRIES, substr( $body_raw, 0, 500 ) ),
|
||||
@@ -163,7 +225,9 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
|
||||
|
||||
// Client error — don't retry, fail immediately.
|
||||
if ( $status_code >= 400 ) {
|
||||
$error_msg = $parser->get_error_message( $data, $status_code );
|
||||
$error_msg = isset( $data['error']['message'] )
|
||||
? $data['error']['message']
|
||||
: 'HTTP ' . $status_code;
|
||||
|
||||
// Log detailed diagnostic info for auth errors to aid debugging.
|
||||
if ( 401 === $status_code || 403 === $status_code ) {
|
||||
@@ -190,7 +254,22 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
|
||||
}
|
||||
|
||||
// Success — parse response.
|
||||
return $parser->parse_success( $data, $model );
|
||||
if ( ! isset( $data['choices'][0]['message']['content'] ) ) {
|
||||
throw new \RuntimeException(
|
||||
__( 'OpenRouter returned unexpected response format.', 'prautoblogger' )
|
||||
);
|
||||
}
|
||||
|
||||
$usage = $data['usage'] ?? [];
|
||||
|
||||
return [
|
||||
'content' => $data['choices'][0]['message']['content'],
|
||||
'model' => $data['model'] ?? $model,
|
||||
'prompt_tokens' => (int) ( $usage['prompt_tokens'] ?? 0 ),
|
||||
'completion_tokens' => (int) ( $usage['completion_tokens'] ?? 0 ),
|
||||
'total_tokens' => (int) ( $usage['total_tokens'] ?? 0 ),
|
||||
'finish_reason' => $data['choices'][0]['finish_reason'] ?? 'unknown',
|
||||
];
|
||||
}
|
||||
|
||||
// All retries exhausted.
|
||||
@@ -248,9 +327,9 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
|
||||
/**
|
||||
* Validate that the OpenRouter API key is configured and working.
|
||||
*
|
||||
* Delegates to PRAutoBlogger_OpenRouter_Validator for the actual checks.
|
||||
* Makes a lightweight request to the /auth/key endpoint.
|
||||
*
|
||||
* Side effects: HTTP request to OpenRouter (via validator), logs diagnostic info.
|
||||
* Side effects: HTTP request to OpenRouter, logs diagnostic info.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
@@ -262,16 +341,112 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
|
||||
/**
|
||||
* Validate credentials with detailed diagnostic info.
|
||||
*
|
||||
* Delegates to PRAutoBlogger_OpenRouter_Validator which returns an array
|
||||
* with status ('ok' or 'error') and a human-readable message explaining
|
||||
* what went wrong if validation fails.
|
||||
* Returns an array with status ('ok' or 'error') and a human-readable
|
||||
* message explaining what went wrong if validation fails.
|
||||
*
|
||||
* Side effects: HTTP request to OpenRouter (via validator), logs diagnostic info.
|
||||
* Side effects: HTTP request to OpenRouter, logs diagnostic info.
|
||||
*
|
||||
* @return array{status: string, message: string, debug?: string}
|
||||
*/
|
||||
public function validate_credentials_detailed(): array {
|
||||
return ( new PRAutoBlogger_OpenRouter_Validator() )->run();
|
||||
$encrypted = get_option( 'prautoblogger_openrouter_api_key', '' );
|
||||
if ( '' === $encrypted ) {
|
||||
return [
|
||||
'status' => 'error',
|
||||
'message' => __( 'No API key saved. Enter your OpenRouter key in settings.', 'prautoblogger' ),
|
||||
'debug' => 'option_empty',
|
||||
];
|
||||
}
|
||||
|
||||
$api_key = PRAutoBlogger_Encryption::decrypt( $encrypted );
|
||||
if ( '' === $api_key ) {
|
||||
return [
|
||||
'status' => 'error',
|
||||
'message' => __( 'API key decryption failed. Re-enter your key.', 'prautoblogger' ),
|
||||
'debug' => 'decrypt_failed:encrypted_len=' . strlen( $encrypted ),
|
||||
];
|
||||
}
|
||||
|
||||
// Sanity check: OpenRouter keys typically start with "sk-or-".
|
||||
$key_prefix = substr( $api_key, 0, 6 );
|
||||
$key_len = strlen( $api_key );
|
||||
|
||||
if ( 0 !== strpos( $api_key, 'sk-or-' ) ) {
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
sprintf(
|
||||
'API key format invalid (prefix="%s", len=%d). Likely salt changed — re-enter key.',
|
||||
$key_prefix,
|
||||
$key_len
|
||||
),
|
||||
'openrouter'
|
||||
);
|
||||
return [
|
||||
'status' => 'error',
|
||||
'message' => __( 'API key appears corrupted (decryption produced invalid data). Your WordPress auth salt may have changed. Please re-enter your OpenRouter API key in settings.', 'prautoblogger' ),
|
||||
'debug' => sprintf( 'bad_format:prefix=%s,len=%d', $key_prefix, $key_len ),
|
||||
];
|
||||
}
|
||||
|
||||
// Belt-and-suspenders: inject Authorization at cURL level (see send_chat_completion).
|
||||
$base_url = $this->get_api_base_url();
|
||||
$base_host = (string) wp_parse_url( $base_url, PHP_URL_HOST );
|
||||
$auth_header_value = 'Bearer ' . $api_key;
|
||||
$curl_auth_filter_cred = function ( $handle, $parsed_args, $url ) use ( $auth_header_value, $base_host ): void {
|
||||
if ( '' === $base_host || false === strpos( (string) $url, $base_host ) ) {
|
||||
return;
|
||||
}
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt
|
||||
curl_setopt( $handle, CURLOPT_HTTPHEADER, [
|
||||
'Authorization: ' . $auth_header_value,
|
||||
] );
|
||||
};
|
||||
add_action( 'http_api_curl', $curl_auth_filter_cred, 99, 3 );
|
||||
|
||||
$response = wp_remote_get(
|
||||
$base_url . '/auth/key',
|
||||
[
|
||||
'timeout' => 15,
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $api_key,
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
remove_action( 'http_api_curl', $curl_auth_filter_cred, 99 );
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
$err_msg = $response->get_error_message();
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
sprintf( 'OpenRouter credential check wp_error: %s (key_prefix=%s, key_len=%d)', $err_msg, $key_prefix, $key_len ),
|
||||
'openrouter'
|
||||
);
|
||||
return [
|
||||
'status' => 'error',
|
||||
'message' => sprintf( __( 'Network error reaching OpenRouter: %s', 'prautoblogger' ), $err_msg ),
|
||||
'debug' => 'wp_error:' . $err_msg,
|
||||
];
|
||||
}
|
||||
|
||||
$status_code = wp_remote_retrieve_response_code( $response );
|
||||
$body_raw = wp_remote_retrieve_body( $response );
|
||||
|
||||
if ( 200 === $status_code ) {
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'message' => __( 'OpenRouter connected.', 'prautoblogger' ),
|
||||
];
|
||||
}
|
||||
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf( 'OpenRouter credential check HTTP %d (key_prefix=%s, key_len=%d): %s', $status_code, $key_prefix, $key_len, substr( $body_raw, 0, 300 ) ),
|
||||
'openrouter'
|
||||
);
|
||||
|
||||
return [
|
||||
'status' => 'error',
|
||||
'message' => sprintf( __( 'OpenRouter returned HTTP %d. %s', 'prautoblogger' ), $status_code, substr( $body_raw, 0, 200 ) ),
|
||||
'debug' => sprintf( 'http_%d:key_prefix=%s,key_len=%d', $status_code, $key_prefix, $key_len ),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Request builder for OpenRouter API calls.
|
||||
*
|
||||
* Encapsulates the logic for building HTTP request headers, including
|
||||
* Cloudflare AI Gateway caching directives and belt-and-suspenders
|
||||
* cURL header injection to work around authorization header stripping
|
||||
* in certain hosting environments.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_OpenRouter_Provider::send_chat_completion()
|
||||
* Dependencies: add_action(), curl_setopt() (via http_api_curl filter).
|
||||
*
|
||||
* @see class-open-router-provider.php — Parent class that uses this builder.
|
||||
*/
|
||||
class PRAutoBlogger_OpenRouter_Request_Builder {
|
||||
|
||||
/**
|
||||
* Build request headers for OpenRouter API call.
|
||||
*
|
||||
* Includes Authorization, Content-Type, HTTP-Referer, and optional
|
||||
* Cloudflare AI Gateway cache control headers.
|
||||
*
|
||||
* @param string $api_key Decrypted OpenRouter API key.
|
||||
* @param bool $via_gateway Whether the request is routed through Cloudflare AI Gateway.
|
||||
* @param int $cache_ttl Cache TTL in seconds (0 disables caching).
|
||||
*
|
||||
* @return array<string, string> HTTP headers ready for wp_remote_post().
|
||||
*/
|
||||
public function build_headers( string $api_key, bool $via_gateway, int $cache_ttl ): array {
|
||||
$headers = [
|
||||
'Authorization' => 'Bearer ' . $api_key,
|
||||
'Content-Type' => 'application/json',
|
||||
'HTTP-Referer' => home_url(),
|
||||
'X-Title' => 'PRAutoBlogger WordPress Plugin',
|
||||
];
|
||||
|
||||
if ( $via_gateway && $cache_ttl > 0 ) {
|
||||
$headers['cf-aig-cache-ttl'] = (string) $cache_ttl;
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a cURL filter to inject Authorization header.
|
||||
*
|
||||
* Some hosting environments (Hostinger, certain proxies) strip the
|
||||
* Authorization header from wp_remote_post's 'headers' array before
|
||||
* the request is sent. The http_api_curl action fires after WordPress
|
||||
* configures the cURL handle but before curl_exec — setting
|
||||
* CURLOPT_HTTPHEADER here ensures the header reaches the upstream.
|
||||
*
|
||||
* Side effects: Adds an http_api_curl filter (caller must remove).
|
||||
*
|
||||
* @param array $request_headers Request headers (includes Authorization).
|
||||
* @param string $base_host Upstream host (scopes the filter to avoid leaking auth).
|
||||
*
|
||||
* @return callable The filter function (for later removal via remove_action).
|
||||
*/
|
||||
public function register_curl_auth_filter( array $request_headers, string $base_host ): callable {
|
||||
$curl_auth_filter = function ( $handle, $parsed_args, $url ) use ( $request_headers, $base_host ): void {
|
||||
// Scope to the configured upstream host only — never leak auth
|
||||
// into unrelated outbound requests made elsewhere in WordPress.
|
||||
if ( '' === $base_host || false === strpos( (string) $url, $base_host ) ) {
|
||||
return;
|
||||
}
|
||||
$curl_headers = [];
|
||||
foreach ( $request_headers as $name => $value ) {
|
||||
$curl_headers[] = $name . ': ' . $value;
|
||||
}
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt
|
||||
curl_setopt( $handle, CURLOPT_HTTPHEADER, $curl_headers );
|
||||
};
|
||||
add_action( 'http_api_curl', $curl_auth_filter, 99, 3 );
|
||||
return $curl_auth_filter;
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Response parser for OpenRouter API responses.
|
||||
*
|
||||
* Encapsulates the logic for parsing and validating OpenRouter chat completion
|
||||
* responses, including error classification and retry-decision logic.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_OpenRouter_Provider::send_chat_completion()
|
||||
* Dependencies: PRAutoBlogger_Logger (for error/warning logging).
|
||||
*
|
||||
* @see class-open-router-provider.php — Parent class that uses this parser.
|
||||
*/
|
||||
class PRAutoBlogger_OpenRouter_Response_Parser {
|
||||
|
||||
/**
|
||||
* Determine if an HTTP status code indicates a retryable error.
|
||||
*
|
||||
* Returns true if the failure is transient (5xx, 429) and the retry loop
|
||||
* should continue with backoff. Returns false for 4xx errors, which should
|
||||
* fail immediately without retry.
|
||||
*
|
||||
* @param int $status_code HTTP status code.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function is_retryable( int $status_code ): bool {
|
||||
return 429 === $status_code || $status_code >= 500;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a successful OpenRouter response.
|
||||
*
|
||||
* Validates the response contains the required fields and extracts
|
||||
* the completion content and token usage.
|
||||
*
|
||||
* @param array $data Decoded JSON response from OpenRouter.
|
||||
* @param string $model The model that was requested (fallback if not in response).
|
||||
*
|
||||
* @return array{
|
||||
* content: string,
|
||||
* model: string,
|
||||
* prompt_tokens: int,
|
||||
* completion_tokens: int,
|
||||
* reasoning_tokens: int,
|
||||
* total_tokens: int,
|
||||
* finish_reason: string,
|
||||
* reasoning_content: string,
|
||||
* }
|
||||
*
|
||||
* @throws \RuntimeException If response structure is invalid.
|
||||
*/
|
||||
public function parse_success( array $data, string $model ): array {
|
||||
// OpenRouter routers (e.g. openrouter/free) may wrap upstream provider
|
||||
// errors inside an HTTP 200 body with an `error` object. Surface them
|
||||
// as RuntimeExceptions so the retry loop or caller can handle them.
|
||||
if ( isset( $data['error']['message'] ) ) {
|
||||
$upstream_code = (int) ( $data['error']['code'] ?? 0 );
|
||||
throw new \RuntimeException(
|
||||
sprintf(
|
||||
/* translators: %1$d: upstream HTTP code, %2$s: error message */
|
||||
__( 'OpenRouter upstream error (code %1$d): %2$s', 'prautoblogger' ),
|
||||
$upstream_code,
|
||||
$data['error']['message']
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Validate we have a choices array with at least one message.
|
||||
if ( ! isset( $data['choices'][0]['message'] ) ) {
|
||||
// Log the raw shape so we can diagnose what the model actually returned.
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
sprintf(
|
||||
'Unexpected response shape from %s: %s',
|
||||
$data['model'] ?? $model,
|
||||
substr( (string) wp_json_encode( $data ), 0, 500 )
|
||||
),
|
||||
'openrouter'
|
||||
);
|
||||
throw new \RuntimeException(
|
||||
sprintf(
|
||||
/* translators: %s: model name */
|
||||
__( 'OpenRouter model %s returned unexpected response format.', 'prautoblogger' ),
|
||||
$data['model'] ?? $model
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Content can be null (some models use tool_calls or refusal instead).
|
||||
// Treat null as empty string — downstream callers handle empty content.
|
||||
$content = $data['choices'][0]['message']['content'] ?? '';
|
||||
if ( null === $content ) {
|
||||
$content = '';
|
||||
}
|
||||
|
||||
$usage = $data['usage'] ?? [];
|
||||
|
||||
// Reasoning content lives alongside 'content' in the message object.
|
||||
$reasoning_content = $data['choices'][0]['message']['reasoning'] ?? '';
|
||||
|
||||
return [
|
||||
'content' => (string) $content,
|
||||
'model' => $data['model'] ?? $model,
|
||||
'prompt_tokens' => (int) ( $usage['prompt_tokens'] ?? 0 ),
|
||||
'completion_tokens' => (int) ( $usage['completion_tokens'] ?? 0 ),
|
||||
'reasoning_tokens' => (int) ( $usage['reasoning_tokens'] ?? 0 ),
|
||||
'total_tokens' => (int) ( $usage['total_tokens'] ?? 0 ),
|
||||
'finish_reason' => $data['choices'][0]['finish_reason'] ?? 'unknown',
|
||||
'reasoning_content' => (string) $reasoning_content,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the error message from a failed response.
|
||||
*
|
||||
* Prefers the `error.message` field if present; falls back to the
|
||||
* HTTP status code as a string.
|
||||
*
|
||||
* @param array $data Decoded JSON response (may be empty or malformed).
|
||||
* @param int $status HTTP status code.
|
||||
*
|
||||
* @return string Human-readable error message.
|
||||
*/
|
||||
public function get_error_message( array $data, int $status ): string {
|
||||
return isset( $data['error']['message'] )
|
||||
? $data['error']['message']
|
||||
: 'HTTP ' . $status;
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Credential + connectivity validator for the OpenRouter LLM provider.
|
||||
*
|
||||
* Split out of the provider to keep each file under the 300-line cap and
|
||||
* to keep the validation path easy to unit-test without spinning up the
|
||||
* retry loop. The provider's `validate_credentials_detailed()` delegates
|
||||
* straight to `run()` here.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_OpenRouter_Provider::validate_credentials_detailed()
|
||||
* which is called by the admin "Test Connection" action.
|
||||
* Dependencies: PRAutoBlogger_OpenRouter_Config (URL resolution),
|
||||
* PRAutoBlogger_Encryption (API key decryption),
|
||||
* PRAutoBlogger_Logger (WARN/ERROR lines), wp_remote_get().
|
||||
*
|
||||
* @see class-open-router-provider.php Parent class that delegates here.
|
||||
* @see class-open-router-config.php Config helpers for base URL.
|
||||
* @see interface-llm-provider.php Return-shape contract.
|
||||
*/
|
||||
class PRAutoBlogger_OpenRouter_Validator {
|
||||
|
||||
private const TIMEOUT_SECONDS = 15;
|
||||
|
||||
/**
|
||||
* Run a non-destructive credential check.
|
||||
*
|
||||
* Lightweight probe: hits the /auth/key endpoint. Never makes a real
|
||||
* LLM completion request — that would consume tokens on every click.
|
||||
*
|
||||
* Side effects: one HTTP GET; one Logger line on failure.
|
||||
*
|
||||
* @return array{status: string, message: string, debug?: string}
|
||||
*/
|
||||
public function run(): array {
|
||||
$encrypted = get_option( 'prautoblogger_openrouter_api_key', '' );
|
||||
if ( '' === $encrypted ) {
|
||||
return $this->err(
|
||||
'option_empty',
|
||||
__( 'No API key saved. Enter your OpenRouter key in settings.', 'prautoblogger' )
|
||||
);
|
||||
}
|
||||
|
||||
$api_key = PRAutoBlogger_Encryption::decrypt( $encrypted );
|
||||
if ( '' === $api_key ) {
|
||||
return $this->err(
|
||||
'decrypt_failed:encrypted_len=' . strlen( $encrypted ),
|
||||
__( 'API key decryption failed. Re-enter your key.', 'prautoblogger' )
|
||||
);
|
||||
}
|
||||
|
||||
// Sanity check: OpenRouter keys typically start with "sk-or-".
|
||||
$key_prefix = substr( $api_key, 0, 6 );
|
||||
$key_len = strlen( $api_key );
|
||||
|
||||
if ( 0 !== strpos( $api_key, 'sk-or-' ) ) {
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
sprintf(
|
||||
'API key format invalid (prefix="%s", len=%d). Likely salt changed — re-enter key.',
|
||||
$key_prefix,
|
||||
$key_len
|
||||
),
|
||||
'openrouter'
|
||||
);
|
||||
return $this->err(
|
||||
sprintf( 'bad_format:prefix=%s,len=%d', $key_prefix, $key_len ),
|
||||
__( 'API key appears corrupted (decryption produced invalid data). Your WordPress auth salt may have changed. Please re-enter your OpenRouter API key in settings.', 'prautoblogger' )
|
||||
);
|
||||
}
|
||||
|
||||
// Belt-and-suspenders: inject Authorization at cURL level (see send_chat_completion).
|
||||
$config = new PRAutoBlogger_OpenRouter_Config();
|
||||
$base_url = $config->get_api_base_url();
|
||||
$base_host = (string) wp_parse_url( $base_url, PHP_URL_HOST );
|
||||
$auth_header_value = 'Bearer ' . $api_key;
|
||||
$curl_auth_filter_cred = function ( $handle, $parsed_args, $url ) use ( $auth_header_value, $base_host ): void {
|
||||
if ( '' === $base_host || false === strpos( (string) $url, $base_host ) ) {
|
||||
return;
|
||||
}
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt
|
||||
curl_setopt( $handle, CURLOPT_HTTPHEADER, [
|
||||
'Authorization: ' . $auth_header_value,
|
||||
] );
|
||||
};
|
||||
add_action( 'http_api_curl', $curl_auth_filter_cred, 99, 3 );
|
||||
|
||||
$response = wp_remote_get(
|
||||
$base_url . '/auth/key',
|
||||
[
|
||||
'timeout' => self::TIMEOUT_SECONDS,
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $api_key,
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
remove_action( 'http_api_curl', $curl_auth_filter_cred, 99 );
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
$err_msg = $response->get_error_message();
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
sprintf( 'OpenRouter credential check wp_error: %s (key_prefix=%s, key_len=%d)', $err_msg, $key_prefix, $key_len ),
|
||||
'openrouter'
|
||||
);
|
||||
return $this->err(
|
||||
'wp_error:' . $err_msg,
|
||||
sprintf(
|
||||
/* translators: %s: transport-layer error message. */
|
||||
esc_html__( 'Network error reaching OpenRouter: %s', 'prautoblogger' ),
|
||||
esc_html( $err_msg )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$status_code = wp_remote_retrieve_response_code( $response );
|
||||
if ( 200 === $status_code ) {
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'message' => __( 'OpenRouter connected.', 'prautoblogger' ),
|
||||
];
|
||||
}
|
||||
|
||||
$body_raw = wp_remote_retrieve_body( $response );
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf( 'OpenRouter credential check HTTP %d (key_prefix=%s, key_len=%d): %s', $status_code, $key_prefix, $key_len, substr( $body_raw, 0, 300 ) ),
|
||||
'openrouter'
|
||||
);
|
||||
|
||||
return $this->err(
|
||||
sprintf( 'http_%d:key_prefix=%s,key_len=%d', $status_code, $key_prefix, $key_len ),
|
||||
sprintf(
|
||||
/* translators: %1$d: HTTP status, %2$s: response body. */
|
||||
esc_html__( 'OpenRouter returned HTTP %1$d. %2$s', 'prautoblogger' ),
|
||||
$status_code,
|
||||
esc_html( substr( $body_raw, 0, 200 ) )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @return array{status: string, message: string, debug: string}
|
||||
*/
|
||||
private function err( string $debug, string $message ): array {
|
||||
return [
|
||||
'status' => 'error',
|
||||
'message' => $message,
|
||||
'debug' => $debug,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
<?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,12 +10,10 @@ 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, PRAutoBlogger_Reddit_RSS_Parser.
|
||||
* Dependencies: wp_remote_get(), PRAutoBlogger_Logger.
|
||||
*
|
||||
* @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.
|
||||
* @see providers/class-reddit-provider.php — Instantiates and calls this class.
|
||||
* @see ARCHITECTURE.md — External API integrations table.
|
||||
*/
|
||||
class PRAutoBlogger_Reddit_JSON_Client {
|
||||
|
||||
@@ -41,6 +39,13 @@ 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.
|
||||
*
|
||||
@@ -124,8 +129,7 @@ class PRAutoBlogger_Reddit_JSON_Client {
|
||||
}
|
||||
|
||||
$body = wp_remote_retrieve_body( $response );
|
||||
$parser = new PRAutoBlogger_Reddit_RSS_Parser();
|
||||
return $parser->parse( $body, $subreddit );
|
||||
return $this->parse_atom_feed( $body, $subreddit );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -169,6 +173,105 @@ 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.
|
||||
*
|
||||
@@ -221,47 +324,145 @@ class PRAutoBlogger_Reddit_JSON_Client {
|
||||
* @return bool True if we got a valid response from either endpoint.
|
||||
*/
|
||||
public function is_available(): bool {
|
||||
return ( new PRAutoBlogger_Reddit_Availability() )->is_available();
|
||||
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.
|
||||
*
|
||||
* @return bool True if RSS endpoint returns HTTP 200.
|
||||
*/
|
||||
public function is_rss_available(): bool {
|
||||
return ( new PRAutoBlogger_Reddit_Availability() )->is_rss_available();
|
||||
$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.
|
||||
*
|
||||
* @return bool True if .json endpoint returns HTTP 200.
|
||||
*/
|
||||
public function is_json_available(): bool {
|
||||
return ( new PRAutoBlogger_Reddit_Availability() )->is_json_available();
|
||||
$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.
|
||||
*
|
||||
* 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 {
|
||||
return ( new PRAutoBlogger_Reddit_Availability() )->get_rate_limit_status();
|
||||
$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.
|
||||
*
|
||||
* Delegates to PRAutoBlogger_Reddit_Availability which owns the HTTP
|
||||
* transport layer and rate-limit tracking.
|
||||
* 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.
|
||||
*/
|
||||
private function api_get( string $url ): ?array {
|
||||
return ( new PRAutoBlogger_Reddit_Availability() )->api_get( $url );
|
||||
$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 );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,11 +128,10 @@ class PRAutoBlogger_Reddit_Provider implements PRAutoBlogger_Source_Provider_Int
|
||||
sprintf( 'Reddit r/%s: fetched %d items', $subreddit, $added ),
|
||||
'reddit'
|
||||
);
|
||||
} catch ( \Throwable $e ) {
|
||||
} catch ( \Exception $e ) {
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
sprintf(
|
||||
'Reddit: %s collecting from r/%s: %s',
|
||||
get_class( $e ),
|
||||
'Reddit: Failed to collect from r/%s: %s',
|
||||
$subreddit,
|
||||
$e->getMessage()
|
||||
),
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
<?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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_init
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_setopt
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_close
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_errno
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_error
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_getinfo
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_init
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_add_handle
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_exec
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_select
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_getcontent
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_remove_handle
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_close
|
||||
|
||||
/**
|
||||
* Parallel image generation via curl_multi for Runware.
|
||||
*
|
||||
* Fires multiple POST /v1 inference calls concurrently, then fetches
|
||||
* the returned signed image URLs in a second parallel round. Cuts
|
||||
* wall-clock time from N×2s to ~2s regardless of batch size — matches
|
||||
* the Image A/B parallelism pattern already in use for OpenRouter.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Runware_Image_Provider::generate_image_batch().
|
||||
* Dependencies: PRAutoBlogger_Runware_Image_Support, PRAutoBlogger_Runware_Image_Pricing,
|
||||
* PRAutoBlogger_Logger.
|
||||
*
|
||||
* @see class-runware-image-provider.php — Sole caller.
|
||||
* @see class-runware-image-support.php — Response parsing + image URL fetch.
|
||||
* @see class-open-router-image-batch.php — Sibling pattern for OpenRouter.
|
||||
*/
|
||||
class PRAutoBlogger_Runware_Image_Batch {
|
||||
|
||||
/** @var PRAutoBlogger_Runware_Image_Pricing */
|
||||
private PRAutoBlogger_Runware_Image_Pricing $pricing;
|
||||
|
||||
/** @var PRAutoBlogger_Runware_Image_Support */
|
||||
private PRAutoBlogger_Runware_Image_Support $support;
|
||||
|
||||
public function __construct(
|
||||
PRAutoBlogger_Runware_Image_Pricing $pricing,
|
||||
PRAutoBlogger_Runware_Image_Support $support
|
||||
) {
|
||||
$this->pricing = $pricing;
|
||||
$this->support = $support;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute multiple image generation requests concurrently via curl_multi.
|
||||
*
|
||||
* Two parallel rounds: (1) POST /v1 returns signed imageURL per request;
|
||||
* (2) GET each signed imageURL returns PNG bytes. Each round runs
|
||||
* concurrently. No retry — single-attempt success rate on schnell >99%.
|
||||
*
|
||||
* @param array<string, array{prompt: string, width: int, height: int, options?: array}> $requests Keyed requests.
|
||||
* @return array<string, array> Image data or `{error}` keyed identically.
|
||||
*/
|
||||
public function execute( array $requests ): array {
|
||||
if ( empty( $requests ) ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$api_key = $this->support->get_api_key();
|
||||
$round1 = $this->round_one_inference( $api_key, $requests );
|
||||
$results = [];
|
||||
$to_download = [];
|
||||
|
||||
foreach ( $round1 as $key => $entry ) {
|
||||
if ( isset( $entry['error'] ) ) {
|
||||
$results[ $key ] = [ 'error' => $entry['error'] ];
|
||||
continue;
|
||||
}
|
||||
$to_download[ $key ] = $entry;
|
||||
}
|
||||
|
||||
if ( empty( $to_download ) ) {
|
||||
return $results;
|
||||
}
|
||||
|
||||
$bytes_map = $this->round_two_download( $to_download );
|
||||
foreach ( $to_download as $key => $entry ) {
|
||||
if ( isset( $bytes_map[ $key ]['error'] ) ) {
|
||||
$results[ $key ] = [ 'error' => $bytes_map[ $key ]['error'] ];
|
||||
continue;
|
||||
}
|
||||
$results[ $key ] = [
|
||||
'bytes' => $bytes_map[ $key ]['bytes'],
|
||||
'mime_type' => 'image/png',
|
||||
'width' => $entry['width'],
|
||||
'height' => $entry['height'],
|
||||
'model' => $entry['model'],
|
||||
'seed' => $entry['seed'],
|
||||
'cost_usd' => $this->pricing->estimate_cost( $entry['width'], $entry['height'], $entry['model'] ),
|
||||
'latency_ms' => (int) ( ( microtime( true ) - $entry['started_at'] ) * 1000 ),
|
||||
];
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Round 1: fire all inference POSTs in parallel.
|
||||
*
|
||||
* @param string $api_key Plaintext API key.
|
||||
* @param array $requests Batch requests.
|
||||
* @return array<string, array> Map of key => {error} | {image_url, width, height, model, seed, started_at}.
|
||||
*/
|
||||
private function round_one_inference( string $api_key, array $requests ): array {
|
||||
$headers = [ 'Content-Type: application/json' ];
|
||||
$mh = curl_multi_init();
|
||||
$handles = [];
|
||||
$start_times = [];
|
||||
|
||||
foreach ( $requests as $key => $req ) {
|
||||
$model = $this->pricing->resolve_model( (string) ( $req['options']['model'] ?? '' ) );
|
||||
$snap_w = $this->support->snap_dimension( (int) $req['width'] );
|
||||
$snap_h = $this->support->snap_dimension( (int) $req['height'] );
|
||||
$seed = $this->support->normalize_seed( isset( $req['options']['seed'] ) ? (int) $req['options']['seed'] : null );
|
||||
$steps = isset( $req['options']['steps'] ) ? (int) $req['options']['steps'] : $this->pricing->default_steps( $model );
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt( $ch, CURLOPT_URL, PRAutoBlogger_Runware_Image_Support::API_URL );
|
||||
curl_setopt( $ch, CURLOPT_POST, true );
|
||||
curl_setopt( $ch, CURLOPT_POSTFIELDS, wp_json_encode(
|
||||
$this->build_payload( $api_key, (string) $req['prompt'], $snap_w, $snap_h, $model, $seed, $steps )
|
||||
) );
|
||||
curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers );
|
||||
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
|
||||
curl_setopt( $ch, CURLOPT_TIMEOUT, PRAUTOBLOGGER_API_TIMEOUT_SECONDS );
|
||||
curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, 10 );
|
||||
|
||||
curl_multi_add_handle( $mh, $ch );
|
||||
$handles[ $key ] = [
|
||||
'handle' => $ch,
|
||||
'model' => $model,
|
||||
'width' => $snap_w,
|
||||
'height' => $snap_h,
|
||||
'seed' => $seed,
|
||||
];
|
||||
$start_times[ $key ] = microtime( true );
|
||||
}
|
||||
|
||||
$this->run_multi( $mh );
|
||||
|
||||
$out = [];
|
||||
foreach ( $handles as $key => $entry ) {
|
||||
$out[ $key ] = $this->collect_inference_result( (string) $key, $entry, $start_times[ $key ] );
|
||||
curl_multi_remove_handle( $mh, $entry['handle'] );
|
||||
curl_close( $entry['handle'] );
|
||||
}
|
||||
curl_multi_close( $mh );
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Round 2: fetch all signed image URLs in parallel.
|
||||
*
|
||||
* @param array<string, array> $inferences Map from round one.
|
||||
* @return array<string, array{bytes?: string, error?: string}>
|
||||
*/
|
||||
private function round_two_download( array $inferences ): array {
|
||||
$mh = curl_multi_init();
|
||||
$handles = [];
|
||||
|
||||
foreach ( $inferences as $key => $entry ) {
|
||||
$ch = curl_init();
|
||||
curl_setopt( $ch, CURLOPT_URL, $entry['image_url'] );
|
||||
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
|
||||
curl_setopt( $ch, CURLOPT_TIMEOUT, 30 );
|
||||
curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, 10 );
|
||||
curl_multi_add_handle( $mh, $ch );
|
||||
$handles[ $key ] = $ch;
|
||||
}
|
||||
|
||||
$this->run_multi( $mh );
|
||||
|
||||
$out = [];
|
||||
foreach ( $handles as $key => $ch ) {
|
||||
$errno = curl_errno( $ch );
|
||||
if ( 0 !== $errno ) {
|
||||
$msg = sprintf( 'Runware image download cURL error %d for "%s": %s', $errno, $key, curl_error( $ch ) );
|
||||
PRAutoBlogger_Logger::instance()->error( $msg, 'runware-image-batch' );
|
||||
$out[ $key ] = [ 'error' => $msg ];
|
||||
} else {
|
||||
$code = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE );
|
||||
$body = (string) curl_multi_getcontent( $ch );
|
||||
if ( 200 !== $code || '' === $body ) {
|
||||
$msg = sprintf( 'Runware image download HTTP %d for "%s" (%d bytes)', $code, $key, strlen( $body ) );
|
||||
PRAutoBlogger_Logger::instance()->error( $msg, 'runware-image-batch' );
|
||||
$out[ $key ] = [ 'error' => $msg ];
|
||||
} else {
|
||||
$out[ $key ] = [ 'bytes' => $body ];
|
||||
}
|
||||
}
|
||||
curl_multi_remove_handle( $mh, $ch );
|
||||
curl_close( $ch );
|
||||
}
|
||||
curl_multi_close( $mh );
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive curl_multi_exec to completion.
|
||||
*
|
||||
* @param \CurlMultiHandle $mh Multi handle.
|
||||
*/
|
||||
private function run_multi( $mh ): void {
|
||||
$running = 0;
|
||||
do {
|
||||
$status = curl_multi_exec( $mh, $running );
|
||||
if ( $running > 0 ) {
|
||||
curl_multi_select( $mh, 1.0 );
|
||||
}
|
||||
} while ( $running > 0 && CURLM_OK === $status );
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the auth+inference task-array body for one image request.
|
||||
*
|
||||
* @param string $api_key Plaintext API key.
|
||||
* @param string $prompt Prompt text.
|
||||
* @param int $width Snapped width.
|
||||
* @param int $height Snapped height.
|
||||
* @param string $model Resolved model id.
|
||||
* @param int $seed Positive seed.
|
||||
* @param int $steps Inference steps.
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function build_payload( string $api_key, string $prompt, int $width, int $height, string $model, int $seed, int $steps ): array {
|
||||
return [
|
||||
[ 'taskType' => 'authentication', 'apiKey' => $api_key ],
|
||||
[
|
||||
'taskType' => 'imageInference',
|
||||
'taskUUID' => wp_generate_uuid4(),
|
||||
'positivePrompt' => $prompt,
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
'model' => $model,
|
||||
'steps' => $steps,
|
||||
'numberResults' => 1,
|
||||
'outputFormat' => 'PNG',
|
||||
'outputType' => 'URL',
|
||||
'seed' => $seed,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one round-1 handle into either an error or an imageURL entry.
|
||||
*
|
||||
* @param string $key Request key.
|
||||
* @param array $entry Handle entry (handle + model + dims + seed).
|
||||
* @param float $start Microtime when this handle was queued.
|
||||
* @return array Either {error} or {image_url, width, height, model, seed, started_at}.
|
||||
*/
|
||||
private function collect_inference_result( string $key, array $entry, float $start ): array {
|
||||
$ch = $entry['handle'];
|
||||
$errno = curl_errno( $ch );
|
||||
if ( 0 !== $errno ) {
|
||||
$msg = sprintf( 'Runware batch inference cURL error %d for "%s": %s', $errno, $key, curl_error( $ch ) );
|
||||
PRAutoBlogger_Logger::instance()->error( $msg, 'runware-image-batch' );
|
||||
return [ 'error' => $msg ];
|
||||
}
|
||||
|
||||
$http_code = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE );
|
||||
$raw = (string) curl_multi_getcontent( $ch );
|
||||
if ( $http_code >= 400 ) {
|
||||
$msg = sprintf( 'Runware batch inference HTTP %d for "%s": %s', $http_code, $key, substr( $raw, 0, 300 ) );
|
||||
PRAutoBlogger_Logger::instance()->error( $msg, 'runware-image-batch' );
|
||||
return [ 'error' => $msg ];
|
||||
}
|
||||
|
||||
try {
|
||||
$image_url = $this->support->extract_image_url( $raw );
|
||||
} catch ( \Throwable $e ) {
|
||||
$msg = sprintf( 'Runware batch inference parse error for "%s": %s', $key, $e->getMessage() );
|
||||
PRAutoBlogger_Logger::instance()->error( $msg, 'runware-image-batch' );
|
||||
return [ 'error' => $msg ];
|
||||
}
|
||||
|
||||
return [
|
||||
'image_url' => $image_url,
|
||||
'width' => (int) $entry['width'],
|
||||
'height' => (int) $entry['height'],
|
||||
'model' => (string) $entry['model'],
|
||||
'seed' => (int) $entry['seed'],
|
||||
'started_at' => $start,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Pricing + model-id resolution helpers for the Runware image provider.
|
||||
*
|
||||
* Each Runware FLUX model has a flat per-image cost sourced from
|
||||
* runware.ai/pricing (April 2026). Unlike OpenRouter models, Runware
|
||||
* prices scale slightly with steps — the quoted rate here assumes the
|
||||
* provider's default step count (4 for schnell, 28 for dev).
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Runware_Image_Provider, PRAutoBlogger_Runware_Image_Batch.
|
||||
* Dependencies: None — pure calculation + option reads.
|
||||
*
|
||||
* @see class-runware-image-provider.php — Primary caller.
|
||||
* @see class-open-router-image-pricing.php — Sibling pattern for OpenRouter.
|
||||
*/
|
||||
class PRAutoBlogger_Runware_Image_Pricing {
|
||||
|
||||
/**
|
||||
* Flat per-image cost (USD) for each supported Runware model, April 2026.
|
||||
* Update here when Runware reprices or new models launch.
|
||||
*
|
||||
* Runware model IDs use `family:variant@version` form. The two FLUX
|
||||
* variants we support are the CEO-sanctioned defaults.
|
||||
*
|
||||
* @var array<string, float>
|
||||
*/
|
||||
private const COST_PER_IMAGE = [
|
||||
// FLUX.1 schnell — 4-step distilled. Our new default (2026-04-21).
|
||||
'runware:100@1' => 0.0006,
|
||||
// FLUX.1 dev — 28-step full model. Higher fidelity, ~30x cost.
|
||||
'runware:101@1' => 0.02,
|
||||
];
|
||||
|
||||
/**
|
||||
* Default number of inference steps per model. Runware bills per image
|
||||
* at the model's standard step count; custom step counts may alter price.
|
||||
*
|
||||
* @var array<string, int>
|
||||
*/
|
||||
private const DEFAULT_STEPS = [
|
||||
'runware:100@1' => 4,
|
||||
'runware:101@1' => 28,
|
||||
];
|
||||
|
||||
/**
|
||||
* Resolve the model identifier from a hint, falling back to site option,
|
||||
* then to the PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL constant.
|
||||
*
|
||||
* If the saved/default model is not a Runware id (e.g. still points at
|
||||
* a Gemini slug), fall back to schnell — the provider was selected
|
||||
* explicitly, so the user wants a Runware model.
|
||||
*
|
||||
* @param string $hint Caller-supplied model identifier (may be empty).
|
||||
* @return string Runware model id.
|
||||
*/
|
||||
public function resolve_model( string $hint ): string {
|
||||
$candidate = trim( $hint );
|
||||
if ( '' === $candidate ) {
|
||||
$candidate = trim( (string) get_option( 'prautoblogger_image_model', '' ) );
|
||||
}
|
||||
if ( '' === $candidate && defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL' ) ) {
|
||||
$candidate = (string) PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL;
|
||||
}
|
||||
|
||||
// If the candidate isn't a known Runware id, fall back to schnell.
|
||||
if ( '' === $candidate || ! isset( self::COST_PER_IMAGE[ $candidate ] ) ) {
|
||||
return 'runware:100@1';
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate USD cost for a single image at the given model.
|
||||
*
|
||||
* Runware FLUX pricing is flat per image at the model's standard step
|
||||
* count. Width/height are accepted for interface compatibility but do
|
||||
* not affect cost for the variants we support.
|
||||
*
|
||||
* @param int $width Width in pixels (unused for flat pricing).
|
||||
* @param int $height Height in pixels (unused for flat pricing).
|
||||
* @param string $model Resolved Runware model id.
|
||||
* @return float USD cost rounded to 6 decimals.
|
||||
*/
|
||||
public function estimate_cost( int $width, int $height, string $model ): float {
|
||||
if ( isset( self::COST_PER_IMAGE[ $model ] ) ) {
|
||||
return round( self::COST_PER_IMAGE[ $model ], 6 );
|
||||
}
|
||||
|
||||
// Unknown model — log it and use a conservative $0.01 estimate.
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf( 'No Runware pricing data for model "%s". Using $0.01 estimate.', $model ),
|
||||
'runware-image'
|
||||
);
|
||||
return 0.01;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default step count for a Runware model.
|
||||
*
|
||||
* @param string $model Resolved Runware model id.
|
||||
* @return int Default steps.
|
||||
*/
|
||||
public function default_steps( string $model ): int {
|
||||
return self::DEFAULT_STEPS[ $model ] ?? 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full model cost table for admin display.
|
||||
*
|
||||
* @return array<string, float> Model id => cost per image in USD.
|
||||
*/
|
||||
public static function get_model_costs(): array {
|
||||
return self::COST_PER_IMAGE;
|
||||
}
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Runware image provider — FLUX.1 schnell / dev via runware.ai.
|
||||
*
|
||||
* Default image backend as of 2026-04-21: schnell (runware:100@1) at
|
||||
* ~$0.0006/image, ~2s latency. 65× cheaper than Gemini Nano Banana with
|
||||
* quality that reads as "stylistic looseness" for our comic aesthetic.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Image_Pipeline during content generation.
|
||||
* Dependencies: Runware_Image_Support (key/parsing/backoff),
|
||||
* Runware_Image_Pricing (model/cost),
|
||||
* Runware_Image_Batch (parallel curl_multi), Logger.
|
||||
*
|
||||
* @see interface-image-provider.php — Contract.
|
||||
* @see class-runware-image-batch.php — Parallel generation via curl_multi.
|
||||
* @see class-runware-image-support.php — Key, response parsing, retry.
|
||||
* @see class-runware-image-pricing.php — Model + cost table.
|
||||
* @see class-open-router-image-provider.php — Sibling pattern for OpenRouter.
|
||||
* @see ARCHITECTURE.md — External API Integrations table.
|
||||
*/
|
||||
class PRAutoBlogger_Runware_Image_Provider implements PRAutoBlogger_Image_Provider_Interface {
|
||||
|
||||
private const PROVIDER_ID = 'runware';
|
||||
private const MIN_DIMENSION_PX = 64;
|
||||
private const MAX_DIMENSION_PX = 2048;
|
||||
|
||||
/** @var PRAutoBlogger_Runware_Image_Pricing */
|
||||
private PRAutoBlogger_Runware_Image_Pricing $pricing;
|
||||
|
||||
/** @var PRAutoBlogger_Runware_Image_Support */
|
||||
private PRAutoBlogger_Runware_Image_Support $support;
|
||||
|
||||
public function __construct() {
|
||||
$this->pricing = new PRAutoBlogger_Runware_Image_Pricing();
|
||||
$this->support = new PRAutoBlogger_Runware_Image_Support();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* Two-step HTTP flow: POST /v1 returns a signed imageURL, then GET that
|
||||
* URL for the bytes. Retries 5xx/429 on the POST with exponential
|
||||
* backoff; 4xx (except 429) fail immediately. The image download GET
|
||||
* is not retried — signed URLs are short-lived.
|
||||
*/
|
||||
public function generate_image( string $prompt, int $width, int $height, array $options = [] ): array {
|
||||
$this->assert_valid_dimensions( $width, $height );
|
||||
$prompt = trim( $prompt );
|
||||
if ( '' === $prompt ) {
|
||||
throw new \InvalidArgumentException(
|
||||
esc_html__( 'Image prompt cannot be empty.', 'prautoblogger' )
|
||||
);
|
||||
}
|
||||
|
||||
$api_key = $this->support->get_api_key();
|
||||
$model = $this->pricing->resolve_model( (string) ( $options['model'] ?? '' ) );
|
||||
$snap_w = $this->support->snap_dimension( $width );
|
||||
$snap_h = $this->support->snap_dimension( $height );
|
||||
$seed = $this->support->normalize_seed( isset( $options['seed'] ) ? (int) $options['seed'] : null );
|
||||
$steps = isset( $options['steps'] ) ? (int) $options['steps'] : $this->pricing->default_steps( $model );
|
||||
$body = $this->build_request_body( $api_key, $prompt, $snap_w, $snap_h, $model, $seed, $steps );
|
||||
$headers = [ 'Content-Type' => 'application/json' ];
|
||||
$started_at = microtime( true );
|
||||
$last_error = '';
|
||||
|
||||
for ( $attempt = 1; $attempt <= PRAUTOBLOGGER_MAX_RETRIES; $attempt++ ) {
|
||||
$response = wp_remote_post( PRAutoBlogger_Runware_Image_Support::API_URL, [
|
||||
'timeout' => PRAUTOBLOGGER_API_TIMEOUT_SECONDS,
|
||||
'headers' => $headers,
|
||||
'body' => wp_json_encode( $body ),
|
||||
] );
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
$last_error = $response->get_error_message();
|
||||
$this->support->log_retry( $attempt, 'network', $last_error );
|
||||
$this->support->backoff( $attempt );
|
||||
continue;
|
||||
}
|
||||
|
||||
$status = (int) wp_remote_retrieve_response_code( $response );
|
||||
$raw = (string) wp_remote_retrieve_body( $response );
|
||||
|
||||
if ( 429 === $status || $status >= 500 ) {
|
||||
$last_error = sprintf( 'HTTP %d: %s', $status, substr( $raw, 0, 300 ) );
|
||||
$this->support->log_retry( $attempt, (string) $status, $last_error );
|
||||
if ( $attempt < PRAUTOBLOGGER_MAX_RETRIES ) {
|
||||
$retry_after = (int) wp_remote_retrieve_header( $response, 'retry-after' );
|
||||
$this->support->backoff( $attempt, $retry_after );
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( $status >= 400 ) {
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
sprintf( 'Runware image gen HTTP %d: %s', $status, substr( $raw, 0, 300 ) ),
|
||||
'runware-image'
|
||||
);
|
||||
throw new \RuntimeException(
|
||||
sprintf(
|
||||
esc_html__( 'Runware image error (HTTP %1$d): %2$s', 'prautoblogger' ),
|
||||
$status,
|
||||
esc_html( substr( $raw, 0, 300 ) )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$image_url = $this->support->extract_image_url( $raw );
|
||||
$image_bytes = $this->support->download_image_bytes( $image_url );
|
||||
|
||||
return [
|
||||
'bytes' => $image_bytes,
|
||||
'mime_type' => 'image/png',
|
||||
'width' => $snap_w,
|
||||
'height' => $snap_h,
|
||||
'model' => $model,
|
||||
'seed' => $seed,
|
||||
'cost_usd' => $this->pricing->estimate_cost( $snap_w, $snap_h, $model ),
|
||||
'latency_ms' => (int) ( ( microtime( true ) - $started_at ) * 1000 ),
|
||||
];
|
||||
}
|
||||
|
||||
throw new \RuntimeException(
|
||||
sprintf(
|
||||
esc_html__( 'Runware image gen failed after %1$d attempts. Last error: %2$s', 'prautoblogger' ),
|
||||
PRAUTOBLOGGER_MAX_RETRIES,
|
||||
esc_html( $last_error )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate multiple images concurrently via curl_multi.
|
||||
*
|
||||
* Delegates to PRAutoBlogger_Runware_Image_Batch, which fires all
|
||||
* inference POSTs in parallel, then fetches the signed image URLs
|
||||
* in parallel as well. Wall-clock time ≈ slowest single image.
|
||||
*
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function generate_image_batch( array $requests ): array {
|
||||
foreach ( $requests as $key => $req ) {
|
||||
$this->assert_valid_dimensions( $req['width'], $req['height'] );
|
||||
if ( '' === trim( $req['prompt'] ) ) {
|
||||
throw new \InvalidArgumentException(
|
||||
sprintf( 'Image prompt for key "%s" cannot be empty.', esc_html( $key ) )
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$batch = new PRAutoBlogger_Runware_Image_Batch( $this->pricing, $this->support );
|
||||
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf( 'Starting parallel Runware image batch (%d images)', count( $requests ) ),
|
||||
'runware-image'
|
||||
);
|
||||
|
||||
return $batch->execute( $requests );
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
public function estimate_cost( int $width, int $height, array $options = [] ): float {
|
||||
$model = $this->pricing->resolve_model( (string) ( $options['model'] ?? '' ) );
|
||||
return $this->pricing->estimate_cost( $width, $height, $model );
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
public function get_provider_name(): string {
|
||||
return self::PROVIDER_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* Runware has no dedicated "validate key" endpoint, so we send a minimal
|
||||
* authentication task and look for `taskType=authentication` in the
|
||||
* response data with no accompanying errors.
|
||||
*/
|
||||
public function validate_credentials_detailed(): array {
|
||||
try {
|
||||
$api_key = $this->support->get_api_key();
|
||||
} catch ( \RuntimeException $e ) {
|
||||
return [ 'status' => 'error', 'message' => $e->getMessage() ];
|
||||
}
|
||||
|
||||
$body = [
|
||||
[ 'taskType' => 'authentication', 'apiKey' => $api_key ],
|
||||
];
|
||||
|
||||
$response = wp_remote_post( PRAutoBlogger_Runware_Image_Support::API_URL, [
|
||||
'timeout' => 15,
|
||||
'headers' => [ 'Content-Type' => 'application/json' ],
|
||||
'body' => wp_json_encode( $body ),
|
||||
] );
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return [
|
||||
'status' => 'error',
|
||||
'message' => __( 'Could not reach Runware API.', 'prautoblogger' ),
|
||||
'debug' => $response->get_error_message(),
|
||||
];
|
||||
}
|
||||
|
||||
$status = (int) wp_remote_retrieve_response_code( $response );
|
||||
$raw = (string) wp_remote_retrieve_body( $response );
|
||||
|
||||
if ( 200 !== $status ) {
|
||||
return [
|
||||
'status' => 'error',
|
||||
'message' => sprintf( __( 'Runware returned HTTP %d.', 'prautoblogger' ), $status ),
|
||||
'debug' => substr( $raw, 0, 200 ),
|
||||
];
|
||||
}
|
||||
|
||||
$decoded = json_decode( $raw, true );
|
||||
if ( is_array( $decoded ) && isset( $decoded['errors'] ) && ! empty( $decoded['errors'] ) ) {
|
||||
$first = $decoded['errors'][0] ?? [];
|
||||
$msg = is_array( $first ) ? (string) ( $first['message'] ?? '' ) : (string) $first;
|
||||
return [
|
||||
'status' => 'error',
|
||||
'message' => __( 'Runware rejected the API key.', 'prautoblogger' ),
|
||||
'debug' => substr( $msg, 0, 200 ),
|
||||
];
|
||||
}
|
||||
|
||||
return [ 'status' => 'ok', 'message' => __( 'Runware API key valid.', 'prautoblogger' ) ];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the JSON task-array body for a single imageInference request.
|
||||
*
|
||||
* Runware's single endpoint consumes [auth, inference] in one POST.
|
||||
*
|
||||
* @param string $api_key Plaintext API key.
|
||||
* @param string $prompt Full prompt text.
|
||||
* @param int $width Snapped width.
|
||||
* @param int $height Snapped height.
|
||||
* @param string $model Resolved model id.
|
||||
* @param int $seed Positive seed.
|
||||
* @param int $steps Inference steps.
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function build_request_body(
|
||||
string $api_key,
|
||||
string $prompt,
|
||||
int $width,
|
||||
int $height,
|
||||
string $model,
|
||||
int $seed,
|
||||
int $steps
|
||||
): array {
|
||||
return [
|
||||
[
|
||||
'taskType' => 'authentication',
|
||||
'apiKey' => $api_key,
|
||||
],
|
||||
[
|
||||
'taskType' => 'imageInference',
|
||||
'taskUUID' => wp_generate_uuid4(),
|
||||
'positivePrompt' => $prompt,
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
'model' => $model,
|
||||
'steps' => $steps,
|
||||
'numberResults' => 1,
|
||||
'outputFormat' => 'PNG',
|
||||
'outputType' => 'URL',
|
||||
'seed' => $seed,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce the interface's dimension contract.
|
||||
*
|
||||
* @param int $width Width in pixels.
|
||||
* @param int $height Height in pixels.
|
||||
* @throws \InvalidArgumentException If outside [MIN, MAX].
|
||||
*/
|
||||
private function assert_valid_dimensions( int $width, int $height ): void {
|
||||
if (
|
||||
$width < self::MIN_DIMENSION_PX || $width > self::MAX_DIMENSION_PX ||
|
||||
$height < self::MIN_DIMENSION_PX || $height > self::MAX_DIMENSION_PX
|
||||
) {
|
||||
throw new \InvalidArgumentException(
|
||||
sprintf(
|
||||
esc_html__( 'Image dimensions %1$dx%2$d outside supported range (%3$d–%4$d px).', 'prautoblogger' ),
|
||||
$width, $height, self::MIN_DIMENSION_PX, self::MAX_DIMENSION_PX
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Support helpers for the Runware image provider — API key lookup,
|
||||
* response parsing (URL fetch), retry backoff, and logging.
|
||||
*
|
||||
* Split from the provider to keep both files under the 300-line cap.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Runware_Image_Provider, PRAutoBlogger_Runware_Image_Batch.
|
||||
* Dependencies: PRAutoBlogger_Encryption, PRAutoBlogger_Logger.
|
||||
*
|
||||
* @see class-runware-image-provider.php — Primary caller.
|
||||
* @see class-runware-image-batch.php — Parallel caller.
|
||||
*/
|
||||
class PRAutoBlogger_Runware_Image_Support {
|
||||
|
||||
/**
|
||||
* Runware task-based single endpoint. Unlike OpenRouter /chat/completions,
|
||||
* Runware accepts a JSON array of tasks (auth + inference) at this URL.
|
||||
*/
|
||||
public const API_URL = 'https://api.runware.ai/v1';
|
||||
|
||||
/**
|
||||
* Get the decrypted Runware API key from settings.
|
||||
*
|
||||
* @return string Plaintext key.
|
||||
* @throws \RuntimeException If key is not configured or decrypt fails.
|
||||
*/
|
||||
public function get_api_key(): string {
|
||||
$stored = (string) get_option( 'prautoblogger_runware_api_key', '' );
|
||||
if ( '' === $stored ) {
|
||||
throw new \RuntimeException(
|
||||
__( 'Runware API key is not configured. Go to PRAutoBlogger → Settings → Images.', 'prautoblogger' )
|
||||
);
|
||||
}
|
||||
|
||||
if ( PRAutoBlogger_Encryption::is_encrypted( $stored ) ) {
|
||||
$decrypted = PRAutoBlogger_Encryption::decrypt( $stored );
|
||||
if ( '' === $decrypted ) {
|
||||
throw new \RuntimeException(
|
||||
__( 'Runware API key could not be decrypted. Re-enter in Settings.', 'prautoblogger' )
|
||||
);
|
||||
}
|
||||
return $decrypted;
|
||||
}
|
||||
|
||||
return $stored;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the image URL from Runware's JSON response body.
|
||||
*
|
||||
* The response is `{"data": [{"taskType":"imageInference","imageURL":"..."}]}`.
|
||||
* We scan `data[]` for the first imageInference entry with a non-empty URL.
|
||||
*
|
||||
* @param string $raw Raw JSON response body from POST /v1.
|
||||
* @return string The imageURL to fetch bytes from.
|
||||
* @throws \RuntimeException If the response is malformed or contains no image.
|
||||
*/
|
||||
public function extract_image_url( string $raw ): string {
|
||||
$decoded = json_decode( $raw, true );
|
||||
if ( ! is_array( $decoded ) ) {
|
||||
throw new \RuntimeException(
|
||||
esc_html__( 'Runware returned invalid JSON.', 'prautoblogger' )
|
||||
);
|
||||
}
|
||||
|
||||
// Runware wraps errors in an `errors` array.
|
||||
if ( isset( $decoded['errors'] ) && is_array( $decoded['errors'] ) && ! empty( $decoded['errors'] ) ) {
|
||||
$first = $decoded['errors'][0] ?? [];
|
||||
$msg = is_array( $first )
|
||||
? (string) ( $first['message'] ?? wp_json_encode( $first ) )
|
||||
: (string) $first;
|
||||
throw new \RuntimeException(
|
||||
sprintf(
|
||||
esc_html__( 'Runware upstream error: %s', 'prautoblogger' ),
|
||||
esc_html( substr( $msg, 0, 300 ) )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$data = $decoded['data'] ?? [];
|
||||
if ( ! is_array( $data ) || empty( $data ) ) {
|
||||
throw new \RuntimeException(
|
||||
esc_html__( 'Runware response contained no data.', 'prautoblogger' )
|
||||
);
|
||||
}
|
||||
|
||||
foreach ( $data as $entry ) {
|
||||
if ( ! is_array( $entry ) ) {
|
||||
continue;
|
||||
}
|
||||
if ( 'imageInference' === ( $entry['taskType'] ?? '' ) ) {
|
||||
$url = (string) ( $entry['imageURL'] ?? '' );
|
||||
if ( '' !== $url ) {
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new \RuntimeException(
|
||||
esc_html__( 'Runware response contained no imageInference imageURL.', 'prautoblogger' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the image bytes from a signed Runware delivery URL.
|
||||
*
|
||||
* Runware returns the image URL separately and bills the inference call;
|
||||
* downloading is a regular GET against `im.runware.ai`.
|
||||
*
|
||||
* @param string $image_url Signed image URL from extract_image_url().
|
||||
* @return string Raw PNG bytes.
|
||||
* @throws \RuntimeException On HTTP error or empty body.
|
||||
*/
|
||||
public function download_image_bytes( string $image_url ): string {
|
||||
$response = wp_remote_get( $image_url, [
|
||||
'timeout' => 30,
|
||||
] );
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
throw new \RuntimeException(
|
||||
sprintf(
|
||||
esc_html__( 'Runware image download failed: %s', 'prautoblogger' ),
|
||||
esc_html( $response->get_error_message() )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$status = (int) wp_remote_retrieve_response_code( $response );
|
||||
if ( 200 !== $status ) {
|
||||
throw new \RuntimeException(
|
||||
sprintf(
|
||||
esc_html__( 'Runware image download HTTP %d.', 'prautoblogger' ),
|
||||
$status
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$bytes = (string) wp_remote_retrieve_body( $response );
|
||||
if ( '' === $bytes ) {
|
||||
throw new \RuntimeException(
|
||||
esc_html__( 'Runware image download returned empty body.', 'prautoblogger' )
|
||||
);
|
||||
}
|
||||
|
||||
return $bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap a target pixel dimension to Runware's FLUX constraints.
|
||||
*
|
||||
* FLUX.1 (schnell + dev) requires dimensions divisible by 64, in the
|
||||
* range [512, 2048]. We round to the nearest multiple of 64 and clamp.
|
||||
*
|
||||
* @param int $px Caller-requested pixel dimension.
|
||||
* @return int Snapped dimension.
|
||||
*/
|
||||
public function snap_dimension( int $px ): int {
|
||||
$snapped = (int) round( $px / 64 ) * 64;
|
||||
if ( $snapped < 512 ) {
|
||||
return 512;
|
||||
}
|
||||
if ( $snapped > 2048 ) {
|
||||
return 2048;
|
||||
}
|
||||
return $snapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a caller seed to Runware's constraint (must be > 0).
|
||||
*
|
||||
* @param int|null $seed Caller-supplied seed or null for "random".
|
||||
* @return int A positive integer seed.
|
||||
*/
|
||||
public function normalize_seed( ?int $seed ): int {
|
||||
if ( null === $seed || $seed < 1 ) {
|
||||
$derived = (int) ( ( microtime( true ) * 1000 ) % 2147483647 );
|
||||
return $derived > 0 ? $derived : 1;
|
||||
}
|
||||
return $seed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a WARN line for a retryable image-gen failure.
|
||||
*
|
||||
* @param int $attempt 1-based attempt number.
|
||||
* @param string $class 'network' or HTTP status code string.
|
||||
* @param string $detail Short failure detail.
|
||||
*/
|
||||
public function log_retry( int $attempt, string $class, string $detail ): void {
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf(
|
||||
'Runware image gen %s failure (attempt %d/%d): %s',
|
||||
$class,
|
||||
$attempt,
|
||||
PRAUTOBLOGGER_MAX_RETRIES,
|
||||
$detail
|
||||
),
|
||||
'runware-image'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep between retries with exponential backoff.
|
||||
*
|
||||
* @param int $attempt 1-based attempt that just failed.
|
||||
* @param int $retry_after Retry-After header value (0 if absent).
|
||||
*/
|
||||
public function backoff( int $attempt, int $retry_after = 0 ): void {
|
||||
$delay = $retry_after > 0
|
||||
? min( $retry_after, 60 )
|
||||
: PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS * (int) pow( 2, $attempt - 1 );
|
||||
if ( $delay > 0 ) {
|
||||
sleep( $delay );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Contract for any image generation provider (Runware FLUX, OpenRouter Gemini, etc.).
|
||||
* Contract for any image generation provider (Cloudflare Workers AI, DALL-E, etc.).
|
||||
*
|
||||
* Consumers depend on this interface, never on a concrete provider — swapping
|
||||
* providers means writing one new class, not refactoring the pipeline. This
|
||||
@@ -13,8 +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-runware-image-provider.php — Runware FLUX implementation (default).
|
||||
* @see class-open-router-image-provider.php — OpenRouter image implementation.
|
||||
* @see class-cloudflare-image-provider.php — Default implementation (FLUX.1 via Workers AI).
|
||||
* @see interface-llm-provider.php — Parallel pattern for LLM providers.
|
||||
* @see ARCHITECTURE.md — External API Integrations table.
|
||||
*/
|
||||
@@ -80,41 +79,10 @@ interface PRAutoBlogger_Image_Provider_Interface {
|
||||
/**
|
||||
* Machine-readable provider identifier used in logs and settings.
|
||||
*
|
||||
* @return string Lowercase identifier, e.g. 'runware' or 'openrouter'.
|
||||
* @return string Lowercase identifier, e.g. 'cloudflare_workers_ai'.
|
||||
*/
|
||||
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-open-router-model-registry.php — Sole consumer.
|
||||
* @see services/class-openrouter-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-open-router-model-normalizer.php — Normalization logic.
|
||||
* @see class-openrouter-model-normalizer.php — Normalization logic.
|
||||
* @see class-prautoblogger.php — Hooks the refresh action.
|
||||
* @see ARCHITECTURE.md — AI Model Registries section.
|
||||
*/
|
||||
@@ -4,10 +4,10 @@ declare(strict_types=1);
|
||||
/**
|
||||
* Contract for a provider-specific AI model registry.
|
||||
*
|
||||
* Phase 1 (v1): single implementation for OpenRouter (`class-open-router-model-registry.php`).
|
||||
* Phase 3: a second implementation wraps Runware 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.
|
||||
* 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
|
||||
* higher-level dispatcher iterates registered registries, calls each with the same
|
||||
* capability, and unions the results — the per-registry interface stays unchanged.
|
||||
*
|
||||
* Implementations MUST:
|
||||
* - Store the normalized registry as a WP option + transient cache layer.
|
||||
@@ -19,7 +19,7 @@ declare(strict_types=1);
|
||||
* (commit 3), daily cron via the main plugin loader.
|
||||
* Dependencies: None at interface level — implementations wrap an HTTP API.
|
||||
*
|
||||
* @see services/class-open-router-model-registry.php — Default implementation.
|
||||
* @see services/class-openrouter-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.
|
||||
*/
|
||||
@@ -86,7 +86,7 @@ interface PRAutoBlogger_Model_Registry_Interface {
|
||||
public function get_fetched_at(): int;
|
||||
|
||||
/**
|
||||
* Machine-readable provider identifier (e.g. 'openrouter', 'runware').
|
||||
* Machine-readable provider identifier (e.g. 'openrouter', 'cloudflare_workers_ai').
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
|
||||
@@ -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.10.0
|
||||
* Version: 0.2.3
|
||||
* 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.10.0' );
|
||||
define( 'PRAUTOBLOGGER_VERSION', '0.2.3' );
|
||||
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', 180 );
|
||||
define( 'PRAUTOBLOGGER_API_TIMEOUT_SECONDS', 120 );
|
||||
define( 'PRAUTOBLOGGER_CACHE_TTL_SECONDS', 3600 );
|
||||
|
||||
// Default models for OpenRouter (user can override in settings).
|
||||
@@ -53,22 +53,17 @@ 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.
|
||||
// Default provider is Runware; OpenRouter (Gemini) remains as the alternate.
|
||||
// Default model is FLUX.1 schnell via Runware (~$0.0006/image, ~2s). CEO
|
||||
// decision 2026-04-21 after comic A/B: schnell's looser fidelity reads as
|
||||
// editorial-cartoon style and is 65× cheaper than Nano Banana. Users can
|
||||
// override via Settings → Images → Image Model.
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER', 'runware' );
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL', 'runware:100@1' );
|
||||
// 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' );
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
define(
|
||||
'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX',
|
||||
'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.'
|
||||
'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.'
|
||||
);
|
||||
|
||||
/*
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
<?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>
|
||||
@@ -9,15 +9,15 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$cost_reporter = new PRAutoBlogger_Cost_Reporter();
|
||||
$monthly_spend = $cost_reporter->get_monthly_spend();
|
||||
$cost_tracker = new PRAutoBlogger_Cost_Tracker();
|
||||
$monthly_spend = $cost_tracker->get_monthly_spend();
|
||||
$budget = (float) get_option( 'prautoblogger_monthly_budget_usd', 50.00 );
|
||||
$utilization = $cost_reporter->get_budget_utilization();
|
||||
$daily_spend = $cost_reporter->get_daily_spend( 30 );
|
||||
$utilization = $cost_tracker->get_budget_utilization();
|
||||
$daily_spend = $cost_tracker->get_daily_spend( 30 );
|
||||
|
||||
$first_of_month = gmdate( 'Y-m-01' );
|
||||
$today = gmdate( 'Y-m-d' );
|
||||
$stage_breakdown = $cost_reporter->get_spend_by_stage( $first_of_month, $today );
|
||||
$stage_breakdown = $cost_tracker->get_spend_by_stage( $first_of_month, $today );
|
||||
?>
|
||||
<div class="wrap prautoblogger-metrics">
|
||||
<h1><?php esc_html_e( 'PRAutoBlogger — Metrics & Costs', 'prautoblogger' ); ?></h1>
|
||||
|
||||
@@ -14,8 +14,8 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
}
|
||||
|
||||
$active_tab = isset( $_GET['tab'] ) ? sanitize_key( $_GET['tab'] ) : 'prautoblogger_api';
|
||||
$cost_reporter = new PRAutoBlogger_Cost_Reporter();
|
||||
$monthly_spend = $cost_reporter->get_monthly_spend();
|
||||
$cost_tracker = new PRAutoBlogger_Cost_Tracker();
|
||||
$monthly_spend = $cost_tracker->get_monthly_spend();
|
||||
$budget = (float) get_option( 'prautoblogger_monthly_budget_usd', 50.00 );
|
||||
$utilization = $budget > 0 ? ( $monthly_spend / $budget ) * 100.0 : 0;
|
||||
$next_run = wp_next_scheduled( 'prautoblogger_daily_generation' );
|
||||
|
||||
@@ -55,20 +55,6 @@ if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL' ) ) {
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL', 'google/gemini-2.5-flash-lite' );
|
||||
}
|
||||
|
||||
// Default image model slug used by OpenRouter/Runware pricing fallbacks and
|
||||
// settings UI. Source of truth: prautoblogger.php line 63.
|
||||
// Historically this was defined implicitly by CloudflareImageProviderTest::setUp()
|
||||
// — that test was deleted in v0.10.0 (cloudflare-workers-ai-removal), so the
|
||||
// define now lives here where it belongs.
|
||||
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL' ) ) {
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL', 'runware:100@1' );
|
||||
}
|
||||
|
||||
// Default image style suffix used by ImagePromptBuilder and settings UI.
|
||||
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX' ) ) {
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX', 'Style: test infomercial style.' );
|
||||
}
|
||||
|
||||
// WordPress database constants used in $wpdb queries.
|
||||
if ( ! defined( 'ARRAY_A' ) ) {
|
||||
define( 'ARRAY_A', 'ARRAY_A' );
|
||||
@@ -106,39 +92,3 @@ if ( ! class_exists( 'WP_Query' ) ) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: Do NOT stub wp_strip_all_tags() or sanitize_text_field() here.
|
||||
// Brain Monkey / Patchwork manages these stubs in individual tests via
|
||||
// Functions\when(). Defining them in bootstrap causes Patchwork's
|
||||
// "DefinedTooEarly" error.
|
||||
|
||||
// WP_Error stub — used by model registry and image pipeline tests.
|
||||
// This is safe to define here because it's a class (not a function),
|
||||
// so Patchwork doesn't need to intercept its definition.
|
||||
if ( ! class_exists( 'WP_Error' ) ) {
|
||||
class WP_Error {
|
||||
public $errors = [];
|
||||
public $error_data = [];
|
||||
|
||||
public function __construct( $code = '', $message = '', $data = '' ) {
|
||||
if ( $code ) {
|
||||
$this->errors[ $code ][] = $message;
|
||||
if ( $data ) {
|
||||
$this->error_data[ $code ] = $data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function get_error_code() {
|
||||
$codes = array_keys( $this->errors );
|
||||
return $codes ? $codes[0] : '';
|
||||
}
|
||||
|
||||
public function get_error_message( $code = '' ) {
|
||||
if ( ! $code ) {
|
||||
$code = $this->get_error_code();
|
||||
}
|
||||
return isset( $this->errors[ $code ] ) ? $this->errors[ $code ][0] : '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for the v0.8.2 one-shot migration that reschedules the daily
|
||||
* generation cron in the site's configured timezone. Exercises both
|
||||
* the "first run" path and the "already migrated, no-op" path.
|
||||
*
|
||||
* See thread scheduled-gen-timezone/01-cto-handoff.md.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class ActivatorMigrationTest extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* Track cron scheduling side effects so we can assert on them.
|
||||
*
|
||||
* @var array<string, array{scheduled_at: int|null, recurrence: string|null, cleared: bool}>
|
||||
*/
|
||||
private array $cron_state = [];
|
||||
|
||||
/**
|
||||
* Track the v0.8.2 migration flag across get_option/update_option calls.
|
||||
*
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
private array $options = [];
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->cron_state = [
|
||||
'prautoblogger_daily_generation' => [
|
||||
'scheduled_at' => null,
|
||||
'recurrence' => null,
|
||||
'cleared' => false,
|
||||
],
|
||||
];
|
||||
|
||||
// Start with a pre-existing cron simulating the v<0.8.2 UTC-interpreted
|
||||
// schedule, so the migration has something to clear.
|
||||
$this->cron_state['prautoblogger_daily_generation']['scheduled_at'] = strtotime( 'tomorrow 03:00 UTC' );
|
||||
$this->cron_state['prautoblogger_daily_generation']['recurrence'] = 'daily';
|
||||
|
||||
// Baseline options.
|
||||
$this->options = [
|
||||
'prautoblogger_schedule_time' => '06:00',
|
||||
'prautoblogger_log_level' => 'info',
|
||||
];
|
||||
|
||||
Functions\when( 'get_option' )->alias(
|
||||
fn( $key, $default = false ) => $this->options[ $key ] ?? $default
|
||||
);
|
||||
Functions\when( 'update_option' )->alias(
|
||||
function ( $key, $value ) {
|
||||
$this->options[ $key ] = $value;
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
Functions\when( 'wp_next_scheduled' )->alias(
|
||||
fn( $hook ) => $this->cron_state[ $hook ]['scheduled_at'] ?? false
|
||||
);
|
||||
Functions\when( 'wp_clear_scheduled_hook' )->alias(
|
||||
function ( $hook ) {
|
||||
if ( isset( $this->cron_state[ $hook ] ) ) {
|
||||
$this->cron_state[ $hook ]['scheduled_at'] = null;
|
||||
$this->cron_state[ $hook ]['cleared'] = true;
|
||||
}
|
||||
}
|
||||
);
|
||||
Functions\when( 'wp_schedule_event' )->alias(
|
||||
function ( $timestamp, $recurrence, $hook ) {
|
||||
$this->cron_state[ $hook ]['scheduled_at'] = (int) $timestamp;
|
||||
$this->cron_state[ $hook ]['recurrence'] = (string) $recurrence;
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
Functions\when( 'wp_timezone' )->alias(
|
||||
static fn() => new \DateTimeZone( 'Asia/Singapore' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Migration clears the UTC-scheduled event and reschedules in SGT.
|
||||
* Flag is set so the next pass is a no-op.
|
||||
*/
|
||||
public function test_db_version_migration_reschedules_existing_cron(): void {
|
||||
$before_timestamp = $this->cron_state['prautoblogger_daily_generation']['scheduled_at'];
|
||||
$this->assertNotNull( $before_timestamp, 'Precondition: stale cron was pre-seeded.' );
|
||||
|
||||
\PRAutoBlogger_Activator::reschedule_daily_in_site_timezone_v082();
|
||||
|
||||
// Hook was cleared and then re-scheduled in one pass.
|
||||
$this->assertTrue( $this->cron_state['prautoblogger_daily_generation']['cleared'] );
|
||||
$after = $this->cron_state['prautoblogger_daily_generation']['scheduled_at'];
|
||||
$this->assertNotNull( $after );
|
||||
$this->assertNotSame( $before_timestamp, $after, 'Migration should produce a different timestamp.' );
|
||||
|
||||
// Re-scheduled timestamp maps to 06:00 SGT (22:00 UTC prev day).
|
||||
$utc = ( new \DateTimeImmutable( '@' . $after ) )->setTimezone( new \DateTimeZone( 'UTC' ) );
|
||||
$this->assertSame( '22:00', $utc->format( 'H:i' ) );
|
||||
|
||||
// Flag was set.
|
||||
$this->assertSame( '1', $this->options['prautoblogger_migrated_schedule_tz_v082'] ?? null );
|
||||
}
|
||||
|
||||
/**
|
||||
* Running the migration twice is idempotent — the second pass must
|
||||
* return immediately without clearing or rescheduling.
|
||||
*/
|
||||
public function test_db_version_migration_idempotent(): void {
|
||||
\PRAutoBlogger_Activator::reschedule_daily_in_site_timezone_v082();
|
||||
|
||||
// Reset the cleared marker so we can detect a second clear.
|
||||
$this->cron_state['prautoblogger_daily_generation']['cleared'] = false;
|
||||
$timestamp_after_first = $this->cron_state['prautoblogger_daily_generation']['scheduled_at'];
|
||||
|
||||
\PRAutoBlogger_Activator::reschedule_daily_in_site_timezone_v082();
|
||||
|
||||
// No second clear, no re-schedule.
|
||||
$this->assertFalse( $this->cron_state['prautoblogger_daily_generation']['cleared'] );
|
||||
$this->assertSame(
|
||||
$timestamp_after_first,
|
||||
$this->cron_state['prautoblogger_daily_generation']['scheduled_at']
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for the v0.10.0 one-shot migration that removes Cloudflare
|
||||
* Workers AI as an image provider — exercises the cloudflare-user
|
||||
* path, the non-cloudflare-user path, idempotency, and the critical
|
||||
* scope-boundary invariant that `prautoblogger_ai_gateway_base_url`
|
||||
* (shared with the OpenRouter AI Gateway route) is left intact.
|
||||
*
|
||||
* See thread 2026-04-cloudflare-workers-ai-removal/01-cto-handoff.md.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class ActivatorRemoveCloudflareTest extends BaseTestCase {
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
private array $options = [];
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->options = [];
|
||||
|
||||
Functions\when( 'get_option' )->alias(
|
||||
fn( $key, $default = false ) => $this->options[ $key ] ?? $default
|
||||
);
|
||||
Functions\when( 'update_option' )->alias(
|
||||
function ( $key, $value ) {
|
||||
$this->options[ $key ] = $value;
|
||||
return true;
|
||||
}
|
||||
);
|
||||
Functions\when( 'delete_option' )->alias(
|
||||
function ( $key ) {
|
||||
unset( $this->options[ $key ] );
|
||||
return true;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloudflare user: flipped to Runware schnell, CF credentials removed,
|
||||
* gateway URL preserved, flag set.
|
||||
*/
|
||||
public function test_cloudflare_user_migrated_to_runware(): void {
|
||||
$this->options = [
|
||||
'prautoblogger_image_provider' => 'cloudflare',
|
||||
'prautoblogger_image_model' => 'flux-1-schnell',
|
||||
'prautoblogger_cloudflare_ai_token' => 'enc:SECRET',
|
||||
'prautoblogger_cloudflare_account_id' => 'acct-123',
|
||||
'prautoblogger_cf_image_via_gateway' => '1',
|
||||
// Shared with OpenRouter AI Gateway — MUST be preserved.
|
||||
'prautoblogger_ai_gateway_base_url' => 'https://gateway.ai.cloudflare.com/v1/acct/gw/openrouter',
|
||||
];
|
||||
|
||||
\PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
|
||||
|
||||
$this->assertSame( 'runware', $this->options['prautoblogger_image_provider'] );
|
||||
$this->assertSame( 'runware:100@1', $this->options['prautoblogger_image_model'] );
|
||||
|
||||
$this->assertArrayNotHasKey( 'prautoblogger_cloudflare_ai_token', $this->options );
|
||||
$this->assertArrayNotHasKey( 'prautoblogger_cloudflare_account_id', $this->options );
|
||||
$this->assertArrayNotHasKey( 'prautoblogger_cf_image_via_gateway', $this->options );
|
||||
|
||||
$this->assertSame(
|
||||
'https://gateway.ai.cloudflare.com/v1/acct/gw/openrouter',
|
||||
$this->options['prautoblogger_ai_gateway_base_url'],
|
||||
'AI Gateway URL (shared with OpenRouter route) must NOT be touched.'
|
||||
);
|
||||
|
||||
$this->assertSame( '1', $this->options['prautoblogger_migrated_remove_cloudflare_v0100'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-Cloudflare user (e.g. already on OpenRouter Gemini): model +
|
||||
* provider untouched; CF credentials still swept; flag set.
|
||||
*/
|
||||
public function test_non_cloudflare_user_preserved(): void {
|
||||
$this->options = [
|
||||
'prautoblogger_image_provider' => 'openrouter',
|
||||
'prautoblogger_image_model' => 'google/gemini-2.5-flash-image',
|
||||
// Hypothetical orphans from an earlier experiment.
|
||||
'prautoblogger_cloudflare_ai_token' => 'enc:OLD',
|
||||
'prautoblogger_cloudflare_account_id' => 'acct-999',
|
||||
'prautoblogger_ai_gateway_base_url' => 'https://gateway.ai.cloudflare.com/v1/acct/gw/openrouter',
|
||||
];
|
||||
|
||||
\PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
|
||||
|
||||
$this->assertSame( 'openrouter', $this->options['prautoblogger_image_provider'] );
|
||||
$this->assertSame( 'google/gemini-2.5-flash-image', $this->options['prautoblogger_image_model'] );
|
||||
|
||||
$this->assertArrayNotHasKey( 'prautoblogger_cloudflare_ai_token', $this->options );
|
||||
$this->assertArrayNotHasKey( 'prautoblogger_cloudflare_account_id', $this->options );
|
||||
$this->assertSame(
|
||||
'https://gateway.ai.cloudflare.com/v1/acct/gw/openrouter',
|
||||
$this->options['prautoblogger_ai_gateway_base_url']
|
||||
);
|
||||
$this->assertSame( '1', $this->options['prautoblogger_migrated_remove_cloudflare_v0100'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Runware user: provider + model untouched; flag set.
|
||||
*/
|
||||
public function test_runware_user_preserved(): void {
|
||||
$this->options = [
|
||||
'prautoblogger_image_provider' => 'runware',
|
||||
'prautoblogger_image_model' => 'runware:101@1',
|
||||
];
|
||||
|
||||
\PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
|
||||
|
||||
$this->assertSame( 'runware', $this->options['prautoblogger_image_provider'] );
|
||||
$this->assertSame( 'runware:101@1', $this->options['prautoblogger_image_model'] );
|
||||
$this->assertSame( '1', $this->options['prautoblogger_migrated_remove_cloudflare_v0100'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy slug detection — the pre-registry `flux-1-schnell` model id
|
||||
* still gets migrated even when the provider option is empty.
|
||||
*/
|
||||
public function test_legacy_slug_triggers_migration(): void {
|
||||
$this->options = [
|
||||
'prautoblogger_image_provider' => '', // Unset before v0.9.0 landed.
|
||||
'prautoblogger_image_model' => 'flux-1-schnell',
|
||||
];
|
||||
|
||||
\PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
|
||||
|
||||
$this->assertSame( 'runware', $this->options['prautoblogger_image_provider'] );
|
||||
$this->assertSame( 'runware:100@1', $this->options['prautoblogger_image_model'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Running the migration twice is idempotent — the second pass must
|
||||
* return immediately and must not clobber a user change made after
|
||||
* the first migration run.
|
||||
*/
|
||||
public function test_idempotent_second_run_is_noop(): void {
|
||||
$this->options = [
|
||||
'prautoblogger_image_provider' => 'cloudflare',
|
||||
'prautoblogger_image_model' => 'flux-1-schnell',
|
||||
];
|
||||
|
||||
\PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
|
||||
$this->assertSame( 'runware', $this->options['prautoblogger_image_provider'] );
|
||||
|
||||
// Simulate admin manually switching to OpenRouter after migration.
|
||||
$this->options['prautoblogger_image_provider'] = 'openrouter';
|
||||
$this->options['prautoblogger_image_model'] = 'google/gemini-3.1-flash-image-preview';
|
||||
|
||||
\PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
|
||||
|
||||
// Second run must NOT overwrite the admin's manual choice.
|
||||
$this->assertSame( 'openrouter', $this->options['prautoblogger_image_provider'] );
|
||||
$this->assertSame( 'google/gemini-3.1-flash-image-preview', $this->options['prautoblogger_image_model'] );
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for `PRAutoBlogger_Activator::next_daily_generation_timestamp()` —
|
||||
* the v0.8.2 timezone-aware replacement for `strtotime("tomorrow HH:MM")`
|
||||
* which evaluated in UTC regardless of the WordPress site timezone.
|
||||
*
|
||||
* See thread scheduled-gen-timezone/01-cto-handoff.md for the bug report.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class ActivatorScheduleTest extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* Stub `wp_timezone()` to return the given tz id, and seed
|
||||
* `prautoblogger_schedule_time` with the given value.
|
||||
*/
|
||||
private function with_site_tz_and_time( string $tz_id, string $time_str ): void {
|
||||
$this->stub_get_option( [
|
||||
'prautoblogger_schedule_time' => $time_str,
|
||||
] );
|
||||
Functions\when( 'wp_timezone' )->alias(
|
||||
static fn() => new \DateTimeZone( $tz_id )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* On an Asia/Singapore site (UTC+8), `schedule_time = 06:00` must
|
||||
* resolve to 22:00 UTC the previous day — the prod bug from thread
|
||||
* scheduled-gen-timezone seq 1.
|
||||
*/
|
||||
public function test_schedule_cron_uses_site_timezone(): void {
|
||||
$this->with_site_tz_and_time( 'Asia/Singapore', '06:00' );
|
||||
|
||||
$timestamp = \PRAutoBlogger_Activator::next_daily_generation_timestamp();
|
||||
|
||||
$this->assertGreaterThan( 0, $timestamp );
|
||||
$utc = ( new \DateTimeImmutable( '@' . $timestamp ) )->setTimezone( new \DateTimeZone( 'UTC' ) );
|
||||
$this->assertSame( '22:00', $utc->format( 'H:i' ), 'Expected 06:00 SGT == 22:00 UTC previous day' );
|
||||
|
||||
// And it really is "tomorrow" in Singapore — date part should not be today SGT.
|
||||
$sgt = ( new \DateTimeImmutable( '@' . $timestamp ) )->setTimezone( new \DateTimeZone( 'Asia/Singapore' ) );
|
||||
$tomorrow_sgt = ( new \DateTimeImmutable( 'tomorrow', new \DateTimeZone( 'Asia/Singapore' ) ) )->format( 'Y-m-d' );
|
||||
$this->assertSame( $tomorrow_sgt, $sgt->format( 'Y-m-d' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* UTC site, `06:00` input → 06:00 UTC. Behaviour unchanged for
|
||||
* the always-UTC case; regression guard.
|
||||
*/
|
||||
public function test_schedule_cron_handles_utc_site(): void {
|
||||
$this->with_site_tz_and_time( 'UTC', '06:00' );
|
||||
|
||||
$timestamp = \PRAutoBlogger_Activator::next_daily_generation_timestamp();
|
||||
|
||||
$utc = ( new \DateTimeImmutable( '@' . $timestamp ) )->setTimezone( new \DateTimeZone( 'UTC' ) );
|
||||
$this->assertSame( '06:00', $utc->format( 'H:i' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Nepal runs UTC+5:45. 06:00 local → 00:15 UTC. Guards against any
|
||||
* integer-hour-offset assumptions in the conversion logic.
|
||||
*/
|
||||
public function test_schedule_cron_handles_fractional_offset(): void {
|
||||
$this->with_site_tz_and_time( 'Asia/Kathmandu', '06:00' );
|
||||
|
||||
$timestamp = \PRAutoBlogger_Activator::next_daily_generation_timestamp();
|
||||
|
||||
$utc = ( new \DateTimeImmutable( '@' . $timestamp ) )->setTimezone( new \DateTimeZone( 'UTC' ) );
|
||||
$this->assertSame( '00:15', $utc->format( 'H:i' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Malformed `schedule_time` (e.g. blank string saved accidentally)
|
||||
* must not throw — `absint()` clamps to 0:0 and the helper returns
|
||||
* a valid timestamp. Regression guard against the "bricked schedule
|
||||
* on bad input" failure mode.
|
||||
*/
|
||||
public function test_schedule_cron_survives_malformed_time_input(): void {
|
||||
$this->with_site_tz_and_time( 'UTC', '' );
|
||||
|
||||
$timestamp = \PRAutoBlogger_Activator::next_daily_generation_timestamp();
|
||||
|
||||
$this->assertGreaterThan( 0, $timestamp );
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Cost_Reporter.
|
||||
*
|
||||
* Validates monthly spend, daily spend trends, spend-by-stage breakdowns,
|
||||
* and budget utilization reporting methods.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class CostReporterTest extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb instance.
|
||||
*/
|
||||
private $wpdb;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->wpdb = $this->create_mock_wpdb();
|
||||
$GLOBALS['wpdb'] = $this->wpdb;
|
||||
|
||||
// CostReporter methods call get_option for budget settings.
|
||||
$this->stub_get_option( [
|
||||
'prautoblogger_monthly_budget_usd' => '50.00',
|
||||
] );
|
||||
|
||||
// Stub current_time used in date-based queries.
|
||||
$this->stub_current_time( '2026-04-12 10:00:00' );
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
unset( $GLOBALS['wpdb'] );
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test get_monthly_spend returns float.
|
||||
*/
|
||||
public function test_get_monthly_spend_returns_float(): void {
|
||||
$this->wpdb->method( 'prepare' )->willReturn( 'prepared' );
|
||||
$this->wpdb->method( 'get_var' )->willReturn( '10.50' );
|
||||
|
||||
$reporter = new \PRAutoBlogger_Cost_Reporter();
|
||||
$spend = $reporter->get_monthly_spend();
|
||||
|
||||
$this->assertIsFloat( $spend );
|
||||
$this->assertGreaterThanOrEqual( 0.0, $spend );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test get_daily_spend returns array.
|
||||
*/
|
||||
public function test_get_daily_spend_returns_array(): void {
|
||||
$this->wpdb->method( 'prepare' )->willReturn( 'prepared' );
|
||||
$this->wpdb->method( 'get_results' )->willReturn( [] );
|
||||
|
||||
$reporter = new \PRAutoBlogger_Cost_Reporter();
|
||||
$daily = $reporter->get_daily_spend( 30 );
|
||||
|
||||
$this->assertIsArray( $daily );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test get_spend_by_stage returns array.
|
||||
*/
|
||||
public function test_get_spend_by_stage_returns_array(): void {
|
||||
$this->wpdb->method( 'prepare' )->willReturn( 'prepared' );
|
||||
$this->wpdb->method( 'get_results' )->willReturn( [] );
|
||||
|
||||
$reporter = new \PRAutoBlogger_Cost_Reporter();
|
||||
$spend = $reporter->get_spend_by_stage( '2026-04-01', '2026-04-30' );
|
||||
|
||||
$this->assertIsArray( $spend );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test get_budget_utilization returns float between 0 and 100+.
|
||||
*/
|
||||
public function test_get_budget_utilization_returns_float(): void {
|
||||
$reporter = new \PRAutoBlogger_Cost_Reporter();
|
||||
$utilization = $reporter->get_budget_utilization();
|
||||
|
||||
$this->assertIsFloat( $utilization );
|
||||
$this->assertGreaterThanOrEqual( 0.0, $utilization );
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,20 @@ class CostTrackerTest extends BaseTestCase {
|
||||
$this->assertFalse( $tracker->is_budget_exceeded() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test get_monthly_spend returns float.
|
||||
*/
|
||||
public function test_get_monthly_spend_returns_float(): void {
|
||||
$this->wpdb->method( 'prepare' )->willReturn( 'prepared' );
|
||||
$this->wpdb->method( 'get_var' )->willReturn( '10.50' );
|
||||
|
||||
$tracker = new \PRAutoBlogger_Cost_Tracker();
|
||||
$spend = $tracker->get_monthly_spend();
|
||||
|
||||
$this->assertIsFloat( $spend );
|
||||
$this->assertGreaterThanOrEqual( 0.0, $spend );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test get_current_run_cost returns float.
|
||||
*/
|
||||
@@ -84,6 +98,43 @@ class CostTrackerTest extends BaseTestCase {
|
||||
$this->assertGreaterThanOrEqual( 0.0, $cost );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test get_daily_spend returns array.
|
||||
*/
|
||||
public function test_get_daily_spend_returns_array(): void {
|
||||
$this->wpdb->method( 'prepare' )->willReturn( 'prepared' );
|
||||
$this->wpdb->method( 'get_results' )->willReturn( [] );
|
||||
|
||||
$tracker = new \PRAutoBlogger_Cost_Tracker();
|
||||
$daily = $tracker->get_daily_spend( 30 );
|
||||
|
||||
$this->assertIsArray( $daily );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test get_spend_by_stage returns array.
|
||||
*/
|
||||
public function test_get_spend_by_stage_returns_array(): void {
|
||||
$this->wpdb->method( 'prepare' )->willReturn( 'prepared' );
|
||||
$this->wpdb->method( 'get_results' )->willReturn( [] );
|
||||
|
||||
$tracker = new \PRAutoBlogger_Cost_Tracker();
|
||||
$spend = $tracker->get_spend_by_stage( '2026-04-01', '2026-04-30' );
|
||||
|
||||
$this->assertIsArray( $spend );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test get_budget_utilization returns float between 0 and 1.
|
||||
*/
|
||||
public function test_get_budget_utilization_returns_float(): void {
|
||||
$tracker = new \PRAutoBlogger_Cost_Tracker();
|
||||
$utilization = $tracker->get_budget_utilization();
|
||||
|
||||
$this->assertIsFloat( $utilization );
|
||||
$this->assertGreaterThanOrEqual( 0.0, $utilization );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test log_api_call with mock database.
|
||||
*/
|
||||
|
||||
@@ -21,9 +21,6 @@ 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' );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,27 +141,4 @@ 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.' );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,11 +18,8 @@ class ImagePipelineTest extends BaseTestCase {
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
// Mock WordPress functions used by the pipeline and its dependencies.
|
||||
Functions\when( 'is_wp_error' )->alias( function ( $thing ) {
|
||||
return $thing instanceof \WP_Error;
|
||||
} );
|
||||
Functions\when( 'wp_generate_uuid4' )->justReturn( 'test-uuid' );
|
||||
// Mock WordPress functions.
|
||||
Functions\when( 'wp_generate_uuid4' )->returnArg();
|
||||
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
|
||||
static $options = [
|
||||
'prautoblogger_image_enabled' => '1',
|
||||
@@ -31,20 +28,7 @@ class ImagePipelineTest extends BaseTestCase {
|
||||
return $options[ $key ] ?? $default;
|
||||
} );
|
||||
Functions\when( 'sanitize_text_field' )->returnArg();
|
||||
Functions\when( 'wp_strip_all_tags' )->alias( function ( $str ) {
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,10 +40,13 @@ class ImagePipelineTest extends BaseTestCase {
|
||||
return $options[ $key ] ?? $default;
|
||||
} );
|
||||
|
||||
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
|
||||
$provider->expects( $this->never() )->method( 'generate_image' );
|
||||
// Mock the logger.
|
||||
$logger = $this->createMock( \PRAutoBlogger_Logger::class );
|
||||
$logger->expects( $this->once() )->method( 'info' );
|
||||
|
||||
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
|
||||
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
|
||||
$provider->expects( $this->never() )->method( 'generate_image' );
|
||||
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
|
||||
|
||||
$pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker );
|
||||
$result = $pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test' ] );
|
||||
@@ -71,32 +58,28 @@ 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 {
|
||||
$image_result = [
|
||||
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
|
||||
$provider->method( 'estimate_cost' )->willReturn( 0.05 );
|
||||
$provider->method( 'generate_image' )->willReturn( [
|
||||
'bytes' => 'fake_image_data',
|
||||
'mime_type' => 'image/png',
|
||||
'width' => 1200,
|
||||
'height' => 630,
|
||||
'model' => 'test-model',
|
||||
'model' => 'flux-1-schnell',
|
||||
'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 );
|
||||
$cost_tracker->method( 'would_exceed_budget' )->willReturn( false );
|
||||
$cost_tracker->method( 'log_image_generation' )->willReturn( true );
|
||||
|
||||
$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 );
|
||||
|
||||
@@ -109,24 +92,19 @@ class ImagePipelineTest extends BaseTestCase {
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that cost is accumulated when both images are generated via batch.
|
||||
* Test that cost is accumulated when both images are generated.
|
||||
*/
|
||||
public function test_cost_is_accumulated_for_both_images(): void {
|
||||
$image_result = [
|
||||
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
|
||||
$provider->method( 'estimate_cost' )->willReturn( 0.05 );
|
||||
$provider->method( 'generate_image' )->willReturn( [
|
||||
'bytes' => 'fake_image_data',
|
||||
'mime_type' => 'image/png',
|
||||
'width' => 1200,
|
||||
'height' => 630,
|
||||
'model' => 'test-model',
|
||||
'model' => 'flux-1-schnell',
|
||||
'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 );
|
||||
@@ -144,60 +122,17 @@ class ImagePipelineTest extends BaseTestCase {
|
||||
[ 'title' => 'Source' ]
|
||||
);
|
||||
|
||||
// Cost should be accumulated from both images ($0.05 × 2 = $0.10).
|
||||
$this->assertEqualsWithDelta( 0.10, $result['cost_usd'], 0.001 );
|
||||
// Cost should be accumulated from both images.
|
||||
$this->assertGreaterThan( 0.05, $result['cost_usd'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Test graceful handling when image generation fails.
|
||||
*/
|
||||
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,
|
||||
];
|
||||
|
||||
public function test_graceful_failure_when_image_generation_fails(): void {
|
||||
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
|
||||
$provider->method( 'estimate_cost' )->willReturn( 0.05 );
|
||||
// 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' ) );
|
||||
$provider->method( 'generate_image' )->willThrowException( new \Exception( 'API timeout' ) );
|
||||
|
||||
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
|
||||
$cost_tracker->method( 'would_exceed_budget' )->willReturn( false );
|
||||
@@ -206,185 +141,9 @@ 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'] ) );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ class ImagePromptBuilderTest extends BaseTestCase {
|
||||
|
||||
// Mock WordPress functions.
|
||||
Functions\when( 'wp_strip_all_tags' )->alias( function ( $str ) {
|
||||
return trim( strip_tags( $str ) );
|
||||
return wp_strip_all_tags( $str );
|
||||
} );
|
||||
Functions\when( 'sanitize_text_field' )->returnArg();
|
||||
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
|
||||
@@ -30,32 +30,7 @@ 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.
|
||||
*
|
||||
* `build_article_prompt()` has returned `array{prompt: string, caption: string}`
|
||||
* since v0.4.x. Destructure on receipt so the assertions run against the
|
||||
* scene string, not the envelope.
|
||||
*
|
||||
* Note: this test does NOT assert on the article content substring. With no
|
||||
* OpenRouter API key in the unit-test env, `rewrite_via_llm()` catches and
|
||||
* falls back to `synthesize_visual_concepts_fallback()`, which reliably
|
||||
* includes only the title — not the content. Content-based assertions
|
||||
* belong in a test that mocks the LLM (see `test_rewrite_uses_setting_when_present`).
|
||||
* Test build_article_prompt() generates a prompt from article title and content.
|
||||
*/
|
||||
public function test_build_article_prompt_from_content(): void {
|
||||
$builder = new \PRAutoBlogger_Image_Prompt_Builder();
|
||||
@@ -65,13 +40,13 @@ class ImagePromptBuilderTest extends BaseTestCase {
|
||||
'post_content' => '<p>Dragons are fascinating creatures. Learn the secrets.</p>',
|
||||
];
|
||||
|
||||
[ 'prompt' => $scene, 'caption' => $caption ] = $builder->build_article_prompt( $article_data );
|
||||
$prompt = $builder->build_article_prompt( $article_data );
|
||||
|
||||
$this->assertStringContainsString( 'How to Train Your Dragon', $scene );
|
||||
$this->assertStringContainsString( 'How to Train Your Dragon', $prompt );
|
||||
$this->assertStringContainsString( 'Dragons are fascinating', $prompt );
|
||||
// Style suffix should be appended.
|
||||
$this->assertNotEmpty( $scene );
|
||||
$this->assertGreaterThan( 50, strlen( $scene ) );
|
||||
$this->assertIsString( $caption );
|
||||
$this->assertNotEmpty( $prompt );
|
||||
$this->assertGreaterThan( 50, strlen( $prompt ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,11 +59,10 @@ class ImagePromptBuilderTest extends BaseTestCase {
|
||||
'post_title' => 'Minimal Article',
|
||||
];
|
||||
|
||||
[ 'prompt' => $scene, 'caption' => $caption ] = $builder->build_article_prompt( $article_data );
|
||||
$prompt = $builder->build_article_prompt( $article_data );
|
||||
|
||||
$this->assertStringContainsString( 'Minimal Article', $scene );
|
||||
$this->assertNotEmpty( $scene );
|
||||
$this->assertIsString( $caption );
|
||||
$this->assertStringContainsString( 'Minimal Article', $prompt );
|
||||
$this->assertNotEmpty( $prompt );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,12 +79,11 @@ class ImagePromptBuilderTest extends BaseTestCase {
|
||||
],
|
||||
];
|
||||
|
||||
[ 'prompt' => $scene, 'caption' => $caption ] = $builder->build_source_prompt( $source_data );
|
||||
$prompt = $builder->build_source_prompt( $source_data );
|
||||
|
||||
$this->assertStringContainsString( 'Robot', $scene );
|
||||
$this->assertStringContainsString( 'Dancing', $scene );
|
||||
$this->assertNotEmpty( $scene );
|
||||
$this->assertIsString( $caption );
|
||||
$this->assertStringContainsString( 'Robot', $prompt );
|
||||
$this->assertStringContainsString( 'Dancing', $prompt );
|
||||
$this->assertNotEmpty( $prompt );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,18 +96,14 @@ class ImagePromptBuilderTest extends BaseTestCase {
|
||||
'title' => 'Standalone Title',
|
||||
];
|
||||
|
||||
[ 'prompt' => $scene, 'caption' => $caption ] = $builder->build_source_prompt( $source_data );
|
||||
$prompt = $builder->build_source_prompt( $source_data );
|
||||
|
||||
$this->assertStringContainsString( 'Standalone Title', $scene );
|
||||
$this->assertNotEmpty( $scene );
|
||||
$this->assertIsString( $caption );
|
||||
$this->assertStringContainsString( 'Standalone Title', $prompt );
|
||||
$this->assertNotEmpty( $prompt );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that prompts don't exceed reasonable length.
|
||||
*
|
||||
* The 500-char cap applies to the scene + style suffix (the `prompt`
|
||||
* field), not the full array envelope.
|
||||
*/
|
||||
public function test_prompts_are_reasonably_sized(): void {
|
||||
$builder = new \PRAutoBlogger_Image_Prompt_Builder();
|
||||
@@ -145,110 +114,9 @@ class ImagePromptBuilderTest extends BaseTestCase {
|
||||
'post_content' => $long_content,
|
||||
];
|
||||
|
||||
[ 'prompt' => $scene ] = $builder->build_article_prompt( $article_data );
|
||||
$prompt = $builder->build_article_prompt( $article_data );
|
||||
|
||||
// Scene + style suffix should be under 500 chars.
|
||||
$this->assertLessThan( 500, strlen( $scene ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* When `prautoblogger_image_prompt_instructions` is non-empty, the
|
||||
* rewriter LLM must receive that content as its system message —
|
||||
* NOT the hardcoded REWRITER_SYSTEM_PROMPT constant.
|
||||
*/
|
||||
public function test_rewrite_uses_setting_when_present(): void {
|
||||
$custom_system = 'CUSTOM-OVERRIDE: tell the LLM a very different thing.';
|
||||
|
||||
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) use ( $custom_system ) {
|
||||
if ( 'prautoblogger_image_prompt_instructions' === $key ) {
|
||||
return $custom_system;
|
||||
}
|
||||
if ( 'prautoblogger_image_style_suffix' === $key ) {
|
||||
return 'Style: x';
|
||||
}
|
||||
return $default;
|
||||
} );
|
||||
|
||||
$captured_messages = [];
|
||||
$mock_llm = $this->createMock( \PRAutoBlogger_OpenRouter_Provider::class );
|
||||
$mock_llm->method( 'send_chat_completion' )->willReturnCallback(
|
||||
function ( $messages ) use ( &$captured_messages ) {
|
||||
$captured_messages = $messages;
|
||||
return [
|
||||
'content' => "A scene.\n\n\"A caption.\"",
|
||||
'prompt_tokens' => 10,
|
||||
'completion_tokens' => 10,
|
||||
];
|
||||
}
|
||||
);
|
||||
$mock_llm->method( 'estimate_cost' )->willReturn( 0.0 );
|
||||
|
||||
// Stub $wpdb so Cost_Tracker::log_api_call inside rewrite_via_llm
|
||||
// can insert without blowing up.
|
||||
$mock_wpdb = $this->create_mock_wpdb();
|
||||
$mock_wpdb->insert_id = 1;
|
||||
$mock_wpdb->method( 'insert' )->willReturn( 1 );
|
||||
$GLOBALS['wpdb'] = $mock_wpdb;
|
||||
|
||||
$builder = new \PRAutoBlogger_Image_Prompt_Builder();
|
||||
$this->inject_llm( $builder, $mock_llm );
|
||||
|
||||
$builder->build_article_prompt( [ 'post_title' => 'Test', 'post_content' => '<p>Body.</p>' ] );
|
||||
|
||||
unset( $GLOBALS['wpdb'] );
|
||||
|
||||
$this->assertNotEmpty( $captured_messages, 'Rewriter LLM was not called.' );
|
||||
$this->assertSame( 'system', $captured_messages[0]['role'] ?? '' );
|
||||
$this->assertSame( $custom_system, $captured_messages[0]['content'] ?? '' );
|
||||
}
|
||||
|
||||
/**
|
||||
* When the option is empty (or whitespace-only), the rewriter must
|
||||
* fall back to REWRITER_SYSTEM_PROMPT — belt-and-braces so a blank
|
||||
* save from the admin UI cannot brick image generation.
|
||||
*/
|
||||
public function test_rewrite_falls_back_to_default_when_setting_empty(): void {
|
||||
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
|
||||
if ( 'prautoblogger_image_prompt_instructions' === $key ) {
|
||||
return ' '; // whitespace — should trigger fallback.
|
||||
}
|
||||
if ( 'prautoblogger_image_style_suffix' === $key ) {
|
||||
return 'Style: x';
|
||||
}
|
||||
return $default;
|
||||
} );
|
||||
|
||||
$captured_messages = [];
|
||||
$mock_llm = $this->createMock( \PRAutoBlogger_OpenRouter_Provider::class );
|
||||
$mock_llm->method( 'send_chat_completion' )->willReturnCallback(
|
||||
function ( $messages ) use ( &$captured_messages ) {
|
||||
$captured_messages = $messages;
|
||||
return [
|
||||
'content' => "A scene.\n\n\"A caption.\"",
|
||||
'prompt_tokens' => 10,
|
||||
'completion_tokens' => 10,
|
||||
];
|
||||
}
|
||||
);
|
||||
$mock_llm->method( 'estimate_cost' )->willReturn( 0.0 );
|
||||
|
||||
// Stub $wpdb so Cost_Tracker::log_api_call inside rewrite_via_llm
|
||||
// can insert without blowing up.
|
||||
$mock_wpdb = $this->create_mock_wpdb();
|
||||
$mock_wpdb->insert_id = 1;
|
||||
$mock_wpdb->method( 'insert' )->willReturn( 1 );
|
||||
$GLOBALS['wpdb'] = $mock_wpdb;
|
||||
|
||||
$builder = new \PRAutoBlogger_Image_Prompt_Builder();
|
||||
$this->inject_llm( $builder, $mock_llm );
|
||||
|
||||
$builder->build_article_prompt( [ 'post_title' => 'Test', 'post_content' => '<p>Body.</p>' ] );
|
||||
|
||||
unset( $GLOBALS['wpdb'] );
|
||||
|
||||
$this->assertSame(
|
||||
\PRAutoBlogger_Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT,
|
||||
$captured_messages[0]['content'] ?? ''
|
||||
);
|
||||
// Prompt should be under 500 chars (concept + style suffix).
|
||||
$this->assertLessThan( 500, strlen( $prompt ) );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Research_Reaper.
|
||||
*
|
||||
* Exercises the five scenarios spec'd in convo thread
|
||||
* research-cost-reaper/01-cto-handoff.md: meta-based reap, grace-period
|
||||
* skip, 7-day stale delete, gen_log fallback when meta is missing, and
|
||||
* idempotency on repeat runs.
|
||||
*
|
||||
* All DB work is mocked via a $wpdb stub — no real database round-trips.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class ResearchReaperTest extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb.
|
||||
*/
|
||||
private $wpdb;
|
||||
|
||||
/**
|
||||
* Captured INSERT rows (from amortize_research_costs delegating to $wpdb->insert).
|
||||
*
|
||||
* @var array<int, array<string, mixed>>
|
||||
*/
|
||||
private array $inserted_rows = [];
|
||||
|
||||
/**
|
||||
* Captured DELETE calls: [$table, $where-array].
|
||||
*
|
||||
* @var array<int, array{0: string, 1: array<string, mixed>}>
|
||||
*/
|
||||
private array $deleted_rows = [];
|
||||
|
||||
/**
|
||||
* Fixture: the rows get_results should hand back for the orphan scan.
|
||||
*
|
||||
* @var array<int, object>
|
||||
*/
|
||||
private array $orphan_fixtures = [];
|
||||
|
||||
/**
|
||||
* Fixture: post IDs the reaper's fallback gen_log query should return.
|
||||
*
|
||||
* @var int[]
|
||||
*/
|
||||
private array $gen_log_post_ids = [];
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->inserted_rows = [];
|
||||
$this->deleted_rows = [];
|
||||
$this->orphan_fixtures = [];
|
||||
$this->gen_log_post_ids = [];
|
||||
|
||||
// Bespoke $wpdb mock — BaseTestCase::create_mock_wpdb() doesn't
|
||||
// stub `delete` or `get_col`, both of which the reaper + amortize
|
||||
// call into. Wide-method mock keeps the test scope self-contained.
|
||||
$this->wpdb = $this->getMockBuilder( \stdClass::class )
|
||||
->addMethods( [ 'prepare', 'get_var', 'get_results', 'get_row', 'get_col', 'insert', 'delete', 'update', 'query' ] )
|
||||
->getMock();
|
||||
$this->wpdb->prefix = 'wp_';
|
||||
$this->wpdb->insert_id = 1;
|
||||
$this->wpdb->last_error = '';
|
||||
$this->wpdb->prab_cost_logs = 'wp_prab_cost_logs';
|
||||
$this->wpdb->method( 'insert' )->willReturnCallback(
|
||||
function ( $table, $row ) {
|
||||
$this->inserted_rows[] = array_merge( [ '__table' => $table ], $row );
|
||||
return 1;
|
||||
}
|
||||
);
|
||||
$this->wpdb->method( 'delete' )->willReturnCallback(
|
||||
function ( $table, $where ) {
|
||||
$this->deleted_rows[] = [ (string) $table, (array) $where ];
|
||||
return 1;
|
||||
}
|
||||
);
|
||||
// prepare() returns its args joined so we can pattern-match in get_* stubs.
|
||||
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
||||
function ( $sql, ...$args ) {
|
||||
return $sql . '|' . implode( '|', array_map( 'strval', $args ) );
|
||||
}
|
||||
);
|
||||
// get_results routes by SQL fragment between the reaper's orphan scan
|
||||
// and the occasional Logger insert probes.
|
||||
$this->wpdb->method( 'get_results' )->willReturnCallback(
|
||||
function ( $sql ) {
|
||||
if ( false !== stripos( (string) $sql, "post_id IS NULL AND created_at <" ) ) {
|
||||
return $this->orphan_fixtures;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
);
|
||||
// get_row serves amortize_research_costs's research-row lookup.
|
||||
$this->wpdb->method( 'get_row' )->willReturnCallback(
|
||||
function ( $sql ) {
|
||||
foreach ( $this->orphan_fixtures as $o ) {
|
||||
if ( false !== stripos( (string) $sql, (string) $o->run_id ) ) {
|
||||
return (object) [
|
||||
'id' => $o->id,
|
||||
'estimated_cost' => $o->estimated_cost ?? 0.50,
|
||||
'provider' => $o->provider ?? 'openrouter',
|
||||
'model' => $o->model ?? 'gemini-flash',
|
||||
'prompt_tokens' => $o->prompt_tokens ?? 1000,
|
||||
'completion_tokens' => $o->completion_tokens ?? 500,
|
||||
];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
// get_col is the reaper's fallback "posts by run_id" and amortize's
|
||||
// "distinct post_ids" query — both read from the same fixture.
|
||||
$this->wpdb->method( 'get_col' )->willReturnCallback(
|
||||
fn() => array_map( 'strval', $this->gen_log_post_ids )
|
||||
);
|
||||
|
||||
$GLOBALS['wpdb'] = $this->wpdb;
|
||||
|
||||
$this->stub_get_option( [
|
||||
'prautoblogger_log_level' => 'info',
|
||||
] );
|
||||
|
||||
// Reaper uses get_posts to look up sibling posts via post_meta.
|
||||
Functions\when( 'get_posts' )->justReturn( [] );
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
unset( $GLOBALS['wpdb'] );
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return only the `generation_log` inserts from the capture — filters out
|
||||
* the `event_log` rows that Logger::instance()->info() writes as a side
|
||||
* effect of the reaper + amortize's own progress logging.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function gen_log_inserts(): array {
|
||||
return array_values( array_filter(
|
||||
$this->inserted_rows,
|
||||
static fn( $row ) => false !== strpos( (string) ( $row['__table'] ?? '' ), 'generation_log' )
|
||||
) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: seed the orphan list returned by the reaper's primary SELECT.
|
||||
*
|
||||
* @param array<int, array{id: int, run_id: string, created_at: string, estimated_cost?: float, provider?: string, model?: string, prompt_tokens?: int, completion_tokens?: int}> $orphans
|
||||
*/
|
||||
private function seed_orphans( array $orphans ): void {
|
||||
$this->orphan_fixtures = array_map(
|
||||
static fn( $r ) => (object) $r,
|
||||
$orphans
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: set the post_ids the gen_log fallback query returns.
|
||||
*
|
||||
* @param int[] $post_ids
|
||||
*/
|
||||
private function seed_gen_log_posts_for_run( array $post_ids ): void {
|
||||
$this->gen_log_post_ids = $post_ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Reap succeeds via post_meta primary lookup.
|
||||
*
|
||||
* Orphan row is >1h old, `_prautoblogger_run_id` meta returns two
|
||||
* post IDs, amortize inserts two attributed rows and deletes the
|
||||
* original orphan.
|
||||
*/
|
||||
public function test_reaps_orphan_with_matching_run_id_posts(): void {
|
||||
$old_ts = gmdate( 'Y-m-d H:i:s', time() - ( 2 * HOUR_IN_SECONDS ) );
|
||||
$this->seed_orphans( [ [ 'id' => 99, 'run_id' => 'run-abc', 'created_at' => $old_ts, 'estimated_cost' => 0.40 ] ] );
|
||||
|
||||
// In the v0.8.1 world both sources agree. Meta is the reaper's gate;
|
||||
// gen_log also has rows so `amortize_research_costs()` can do its
|
||||
// own internal post-count query.
|
||||
Functions\when( 'get_posts' )->alias(
|
||||
static fn( $args ) => ( '_prautoblogger_run_id' === ( $args['meta_key'] ?? '' ) && 'run-abc' === ( $args['meta_value'] ?? '' ) )
|
||||
? [ 101, 102 ]
|
||||
: []
|
||||
);
|
||||
$this->seed_gen_log_posts_for_run( [ 101, 102 ] );
|
||||
|
||||
$stats = \PRAutoBlogger_Research_Reaper::reap();
|
||||
|
||||
$this->assertSame( 1, $stats['reaped'] );
|
||||
$this->assertSame( 0, $stats['deleted'] );
|
||||
$this->assertSame( 0, $stats['skipped'] );
|
||||
$this->assertCount(
|
||||
2,
|
||||
$this->gen_log_inserts(),
|
||||
'One amortized row per post should be inserted into the generation_log.'
|
||||
);
|
||||
// Original orphan deleted by amortize_research_costs.
|
||||
$this->assertNotEmpty( array_filter( $this->deleted_rows, static fn( $d ) => ( $d[1]['id'] ?? null ) === 99 ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* 2. Orphan within the 1-hour grace window is left alone — a live
|
||||
* pipeline may still be in the middle of its own amortize step.
|
||||
*/
|
||||
public function test_skips_orphan_within_grace_period(): void {
|
||||
// The primary SELECT filters on `created_at < (now - 1h)`, so a
|
||||
// fresh orphan is excluded from the result set at the DB boundary.
|
||||
// We simulate that by returning an empty orphan list.
|
||||
$this->seed_orphans( [] );
|
||||
|
||||
Functions\when( 'get_posts' )->justReturn( [ 101, 102 ] );
|
||||
|
||||
$stats = \PRAutoBlogger_Research_Reaper::reap();
|
||||
|
||||
$this->assertSame( 0, $stats['reaped'] );
|
||||
$this->assertSame( 0, $stats['deleted'] );
|
||||
$this->assertSame( 0, $stats['skipped'] );
|
||||
$this->assertEmpty( $this->inserted_rows );
|
||||
$this->assertEmpty( $this->deleted_rows );
|
||||
}
|
||||
|
||||
/**
|
||||
* 3. Orphan older than 7 days with zero matching posts is deleted
|
||||
* outright — research cost is sunk with nothing to attribute to.
|
||||
*/
|
||||
public function test_deletes_orphan_with_no_articles_after_7_days(): void {
|
||||
$stale_ts = gmdate( 'Y-m-d H:i:s', time() - ( 8 * DAY_IN_SECONDS ) );
|
||||
$this->seed_orphans( [ [ 'id' => 77, 'run_id' => 'run-dead', 'created_at' => $stale_ts ] ] );
|
||||
|
||||
// No posts via meta, no posts via gen_log.
|
||||
Functions\when( 'get_posts' )->justReturn( [] );
|
||||
$this->seed_gen_log_posts_for_run( [] );
|
||||
|
||||
$stats = \PRAutoBlogger_Research_Reaper::reap();
|
||||
|
||||
$this->assertSame( 0, $stats['reaped'] );
|
||||
$this->assertSame( 1, $stats['deleted'] );
|
||||
$this->assertNotEmpty( array_filter( $this->deleted_rows, static fn( $d ) => ( $d[1]['id'] ?? null ) === 77 ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* 4. When `_prautoblogger_run_id` post_meta is absent (legacy posts
|
||||
* predating v0.8.1), the reaper falls back to querying gen_log for
|
||||
* distinct post_ids carrying the same run_id.
|
||||
*/
|
||||
public function test_falls_back_to_gen_log_when_post_meta_missing(): void {
|
||||
$old_ts = gmdate( 'Y-m-d H:i:s', time() - ( 2 * HOUR_IN_SECONDS ) );
|
||||
$this->seed_orphans( [ [ 'id' => 55, 'run_id' => 'run-legacy', 'created_at' => $old_ts, 'estimated_cost' => 0.30 ] ] );
|
||||
|
||||
// Meta lookup returns nothing.
|
||||
Functions\when( 'get_posts' )->justReturn( [] );
|
||||
// But gen_log has two posts with this run_id.
|
||||
$this->seed_gen_log_posts_for_run( [ 201, 202 ] );
|
||||
|
||||
$stats = \PRAutoBlogger_Research_Reaper::reap();
|
||||
|
||||
$this->assertSame( 1, $stats['reaped'] );
|
||||
$this->assertCount( 2, $this->gen_log_inserts() );
|
||||
}
|
||||
|
||||
/**
|
||||
* 5. A second reap run is a no-op — the first run deleted the orphan
|
||||
* and inserted amortized per-article rows, so the scan returns empty.
|
||||
*/
|
||||
public function test_idempotent_on_repeat_run(): void {
|
||||
// Simulate "already reaped" state: scan returns no orphans.
|
||||
$this->seed_orphans( [] );
|
||||
|
||||
$stats_1 = \PRAutoBlogger_Research_Reaper::reap();
|
||||
$stats_2 = \PRAutoBlogger_Research_Reaper::reap();
|
||||
|
||||
$this->assertSame( 0, $stats_1['reaped'] );
|
||||
$this->assertSame( 0, $stats_2['reaped'] );
|
||||
$this->assertEmpty( $this->inserted_rows );
|
||||
$this->assertEmpty( $this->deleted_rows );
|
||||
}
|
||||
}
|
||||
@@ -25,8 +25,7 @@ class ContentRequestTest extends BaseTestCase {
|
||||
1000,
|
||||
2500,
|
||||
'Technology and innovation',
|
||||
[ 'politics', 'religion' ],
|
||||
'Always open with a hook question.'
|
||||
[ 'politics', 'religion' ]
|
||||
);
|
||||
|
||||
$this->assertSame( $idea, $request->get_idea() );
|
||||
@@ -36,7 +35,6 @@ 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() );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,25 +63,6 @@ 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.
|
||||
*/
|
||||
|
||||
432
tests/unit/Providers/CloudflareImageProviderTest.php
Normal file
432
tests/unit/Providers/CloudflareImageProviderTest.php
Normal file
@@ -0,0 +1,432 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Cloudflare_Image_Provider.
|
||||
*
|
||||
* Validates the image provider interface implementation methods.
|
||||
* All HTTP calls are mocked — no real API calls.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Providers
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Providers;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class CloudflareImageProviderTest extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* Fake PNG payload — bytes don't need to be a real PNG, just non-empty.
|
||||
* Using a short string keeps failure messages readable.
|
||||
*/
|
||||
private const FAKE_IMAGE_BYTES = 'FAKE_PNG_BYTES';
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
// PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL constant is defined in
|
||||
// prautoblogger.php — define here for test isolation.
|
||||
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL' ) ) {
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL', 'flux-1-schnell' );
|
||||
}
|
||||
|
||||
$this->stub_get_option( [
|
||||
'prautoblogger_cloudflare_ai_token' => '',
|
||||
'prautoblogger_cloudflare_account_id' => 'test-account-uuid',
|
||||
'prautoblogger_image_model' => 'flux-1-schnell',
|
||||
'prautoblogger_log_level' => 'info',
|
||||
] );
|
||||
|
||||
Functions\when( 'wp_salt' )->justReturn( 'test_salt_key_for_unit_tests' );
|
||||
Functions\when( 'is_wp_error' )->justReturn( false );
|
||||
Functions\when( 'wp_remote_retrieve_header' )->justReturn( '' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: configure the plaintext API token the provider will eventually
|
||||
* see after decryption. We round-trip through the real Encryption class
|
||||
* (rather than stubbing it) so this test catches any regressions in the
|
||||
* encrypt → option → decrypt flow at the same time.
|
||||
*/
|
||||
private function with_token( string $plaintext ): void {
|
||||
$ciphertext = \PRAutoBlogger_Encryption::encrypt( $plaintext );
|
||||
$this->stub_get_option( [
|
||||
'prautoblogger_cloudflare_ai_token' => $ciphertext,
|
||||
'prautoblogger_cloudflare_account_id' => 'test-account-uuid',
|
||||
'prautoblogger_image_model' => 'flux-1-schnell',
|
||||
'prautoblogger_log_level' => 'info',
|
||||
] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider implements the interface.
|
||||
*/
|
||||
public function test_provider_implements_interface(): void {
|
||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
||||
$this->assertInstanceOf( \PRAutoBlogger_Image_Provider_Interface::class, $provider );
|
||||
}
|
||||
|
||||
/**
|
||||
* get_provider_name returns the stable machine identifier.
|
||||
*/
|
||||
public function test_provider_name_is_stable_identifier(): void {
|
||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
||||
$this->assertSame( 'cloudflare_workers_ai', $provider->get_provider_name() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Dimension validation rejects inputs outside the [64, 2048] envelope
|
||||
* before any HTTP call is made.
|
||||
*/
|
||||
public function test_generate_image_rejects_out_of_range_dimensions(): void {
|
||||
$this->with_token( 'test-token' );
|
||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
||||
|
||||
$this->expectException( \InvalidArgumentException::class );
|
||||
$provider->generate_image( 'a prompt', 32, 1080 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty prompts are rejected — an empty string burns budget on
|
||||
* Workers AI for a deterministic "bad request" result.
|
||||
*/
|
||||
public function test_generate_image_rejects_empty_prompt(): void {
|
||||
$this->with_token( 'test-token' );
|
||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
||||
|
||||
$this->expectException( \InvalidArgumentException::class );
|
||||
$provider->generate_image( ' ', 1080, 1080 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Happy path — raw image bytes come back with expected metadata shape.
|
||||
*/
|
||||
public function test_generate_image_returns_raw_bytes_and_metadata(): void {
|
||||
$this->with_token( 'test-token' );
|
||||
|
||||
Functions\when( 'wp_remote_post' )->justReturn( [
|
||||
'body' => self::FAKE_IMAGE_BYTES,
|
||||
'response' => [ 'code' => 200 ],
|
||||
'headers' => [ 'content-type' => 'image/png' ],
|
||||
] );
|
||||
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
|
||||
Functions\when( 'wp_remote_retrieve_body' )->justReturn( self::FAKE_IMAGE_BYTES );
|
||||
Functions\when( 'wp_remote_retrieve_header' )->alias(
|
||||
function ( $_response, $name ) {
|
||||
return 'content-type' === strtolower( (string) $name ) ? 'image/png' : '';
|
||||
}
|
||||
);
|
||||
|
||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
||||
$result = $provider->generate_image( 'a prompt', 1080, 1080, [ 'seed' => 42 ] );
|
||||
|
||||
$this->assertSame( self::FAKE_IMAGE_BYTES, $result['bytes'] );
|
||||
$this->assertSame( 'image/png', $result['mime_type'] );
|
||||
$this->assertSame( 1080, $result['width'] );
|
||||
$this->assertSame( 1080, $result['height'] );
|
||||
$this->assertSame( 42, $result['seed'] );
|
||||
$this->assertSame( '@cf/black-forest-labs/flux-1-schnell', $result['model'] );
|
||||
$this->assertGreaterThan( 0.0, $result['cost_usd'] );
|
||||
$this->assertGreaterThanOrEqual( 0, $result['latency_ms'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Happy path where Workers AI wraps the image in a JSON envelope with
|
||||
* base64-encoded bytes under `result.image`. The provider must decode
|
||||
* and return the raw bytes so the pipeline never has to branch.
|
||||
*/
|
||||
public function test_generate_image_decodes_json_envelope(): void {
|
||||
$this->with_token( 'test-token' );
|
||||
|
||||
$encoded = base64_encode( self::FAKE_IMAGE_BYTES );
|
||||
$body = wp_json_encode( [ 'result' => [ 'image' => $encoded ], 'success' => true ] );
|
||||
|
||||
Functions\when( 'wp_remote_post' )->justReturn( [
|
||||
'body' => $body,
|
||||
'response' => [ 'code' => 200 ],
|
||||
'headers' => [ 'content-type' => 'application/json' ],
|
||||
] );
|
||||
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
|
||||
Functions\when( 'wp_remote_retrieve_body' )->justReturn( $body );
|
||||
Functions\when( 'wp_remote_retrieve_header' )->alias(
|
||||
function ( $_response, $name ) {
|
||||
return 'content-type' === strtolower( (string) $name ) ? 'application/json' : '';
|
||||
}
|
||||
);
|
||||
|
||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
||||
$result = $provider->generate_image( 'a prompt', 600, 600 );
|
||||
|
||||
$this->assertSame( self::FAKE_IMAGE_BYTES, $result['bytes'] );
|
||||
$this->assertSame( 'image/png', $result['mime_type'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* A 401 must NOT retry — auth errors are deterministic and retrying
|
||||
* burns budget. The provider should throw on the first response.
|
||||
*
|
||||
* We can't assert "exactly one HTTP call" without injecting a call
|
||||
* counter, so we assert that the throw message contains the status.
|
||||
*/
|
||||
public function test_generate_image_fails_loudly_on_401(): void {
|
||||
$this->with_token( 'test-token' );
|
||||
|
||||
Functions\when( 'wp_remote_post' )->justReturn( [
|
||||
'body' => '{"errors":[{"message":"bad token"}]}',
|
||||
'response' => [ 'code' => 401 ],
|
||||
'headers' => [],
|
||||
] );
|
||||
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 401 );
|
||||
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"errors":[{"message":"bad token"}]}' );
|
||||
|
||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
||||
|
||||
try {
|
||||
$provider->generate_image( 'a prompt', 1080, 1080 );
|
||||
$this->fail( 'Expected RuntimeException for HTTP 401.' );
|
||||
} catch ( \RuntimeException $e ) {
|
||||
$this->assertStringContainsString( '401', $e->getMessage() );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Response shape must be one of: image/* bytes OR JSON envelope with
|
||||
* base64 image. Anything else is a contract breach — fail loudly.
|
||||
*/
|
||||
public function test_generate_image_throws_on_unexpected_response_shape(): void {
|
||||
$this->with_token( 'test-token' );
|
||||
|
||||
Functions\when( 'wp_remote_post' )->justReturn( [
|
||||
'body' => '<!doctype html><html><body>oops</body></html>',
|
||||
'response' => [ 'code' => 200 ],
|
||||
'headers' => [ 'content-type' => 'text/html' ],
|
||||
] );
|
||||
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
|
||||
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '<!doctype html><html><body>oops</body></html>' );
|
||||
Functions\when( 'wp_remote_retrieve_header' )->alias(
|
||||
function ( $_response, $name ) {
|
||||
return 'content-type' === strtolower( (string) $name ) ? 'text/html' : '';
|
||||
}
|
||||
);
|
||||
|
||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
||||
$this->expectException( \RuntimeException::class );
|
||||
$provider->generate_image( 'a prompt', 1080, 1080 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Cost estimation is positive for in-range dimensions and scales with
|
||||
* area — a 1080² image costs more than a 600² image on the same model.
|
||||
*/
|
||||
public function test_estimate_cost_scales_with_area(): void {
|
||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
||||
|
||||
$small = $provider->estimate_cost( 600, 600, [ 'model' => 'flux-1-schnell' ] );
|
||||
$large = $provider->estimate_cost( 1080, 1080, [ 'model' => 'flux-1-schnell' ] );
|
||||
|
||||
$this->assertGreaterThan( 0.0, $small );
|
||||
$this->assertGreaterThan( $small, $large );
|
||||
}
|
||||
|
||||
/**
|
||||
* [dev] is meaningfully pricier than [schnell] at the same dimensions —
|
||||
* the ~4x multiplier is the reason we default to schnell.
|
||||
*/
|
||||
public function test_estimate_cost_dev_more_expensive_than_schnell(): void {
|
||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
||||
|
||||
$schnell = $provider->estimate_cost( 1080, 1080, [ 'model' => 'flux-1-schnell' ] );
|
||||
$dev = $provider->estimate_cost( 1080, 1080, [ 'model' => 'flux-1-dev' ] );
|
||||
|
||||
$this->assertGreaterThan( $schnell, $dev );
|
||||
}
|
||||
|
||||
/**
|
||||
* validate_credentials_detailed reports 'token_empty' debug tag when
|
||||
* no token is configured, without making an HTTP call.
|
||||
*/
|
||||
public function test_validate_credentials_reports_missing_token(): void {
|
||||
// Default setUp leaves token empty.
|
||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
||||
$result = $provider->validate_credentials_detailed();
|
||||
|
||||
$this->assertSame( 'error', $result['status'] );
|
||||
$this->assertSame( 'token_empty', $result['debug'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* validate_credentials_detailed reports 'account_id_empty' when the
|
||||
* account ID option is blank, without touching the network.
|
||||
*/
|
||||
public function test_validate_credentials_reports_missing_account_id(): void {
|
||||
$this->stub_get_option( [
|
||||
'prautoblogger_cloudflare_ai_token' => 'not-empty',
|
||||
'prautoblogger_cloudflare_account_id' => '',
|
||||
'prautoblogger_log_level' => 'info',
|
||||
] );
|
||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
||||
$result = $provider->validate_credentials_detailed();
|
||||
|
||||
$this->assertSame( 'error', $result['status'] );
|
||||
$this->assertSame( 'account_id_empty', $result['debug'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* validate_credentials_detailed must never invoke the generation endpoint.
|
||||
* Asserted by tracking every wp_remote_post call: a real-image call is
|
||||
* the only thing that POSTs to `/ai/run/...`. Validator uses GET on
|
||||
* `/ai/models/search`, which is the free read-only path.
|
||||
*/
|
||||
public function test_validate_credentials_never_calls_generate_endpoint(): void {
|
||||
$this->with_token( 'test-token' );
|
||||
|
||||
$post_calls = [];
|
||||
Functions\when( 'wp_remote_post' )->alias(
|
||||
function ( $url, $args ) use ( &$post_calls ) {
|
||||
$post_calls[] = $url;
|
||||
return [ 'body' => '', 'response' => [ 'code' => 500 ] ];
|
||||
}
|
||||
);
|
||||
Functions\when( 'wp_remote_get' )->justReturn( [
|
||||
'body' => '{"result":[]}',
|
||||
'response' => [ 'code' => 200 ],
|
||||
] );
|
||||
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
|
||||
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"result":[]}' );
|
||||
|
||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
||||
$provider->validate_credentials_detailed();
|
||||
|
||||
$this->assertSame( [], $post_calls, 'validate_credentials_detailed() must not POST to /ai/run/ endpoints.' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry path: a 500 followed by a 200 must yield the 200 payload.
|
||||
* Exercises the exponential-backoff loop end-to-end without sleeping
|
||||
* (bootstrap sets PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS to 0).
|
||||
*/
|
||||
public function test_generate_image_retries_on_5xx_then_succeeds(): void {
|
||||
$this->with_token( 'test-token' );
|
||||
|
||||
$response_codes = [ 500, 200 ];
|
||||
$response_bodies = [ 'upstream hiccup', self::FAKE_IMAGE_BYTES ];
|
||||
|
||||
Functions\when( 'wp_remote_post' )->justReturn( [
|
||||
'body' => self::FAKE_IMAGE_BYTES,
|
||||
'response' => [ 'code' => 200 ],
|
||||
'headers' => [ 'content-type' => 'image/png' ],
|
||||
] );
|
||||
Functions\when( 'wp_remote_retrieve_response_code' )->alias(
|
||||
function () use ( &$response_codes ) {
|
||||
return array_shift( $response_codes ) ?? 200;
|
||||
}
|
||||
);
|
||||
Functions\when( 'wp_remote_retrieve_body' )->alias(
|
||||
function () use ( &$response_bodies ) {
|
||||
return array_shift( $response_bodies ) ?? self::FAKE_IMAGE_BYTES;
|
||||
}
|
||||
);
|
||||
// wp_remote_retrieve_header is called only on the success attempt —
|
||||
// the 5xx branch returns before any header lookup. One justReturn
|
||||
// for the content-type is enough; retry-after lookups accept the
|
||||
// same stub and will coerce to (int) 0.
|
||||
Functions\when( 'wp_remote_retrieve_header' )->justReturn( 'image/png' );
|
||||
|
||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
||||
$result = $provider->generate_image( 'a prompt', 1080, 1080 );
|
||||
|
||||
$this->assertSame( self::FAKE_IMAGE_BYTES, $result['bytes'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry exhaustion: persistent 502s must throw after MAX_RETRIES attempts
|
||||
* rather than hanging indefinitely.
|
||||
*/
|
||||
public function test_generate_image_fails_after_max_retries_on_persistent_5xx(): void {
|
||||
$this->with_token( 'test-token' );
|
||||
|
||||
$call_count = 0;
|
||||
Functions\when( 'wp_remote_post' )->alias(
|
||||
function () use ( &$call_count ) {
|
||||
++$call_count;
|
||||
return [
|
||||
'body' => 'bad gateway',
|
||||
'response' => [ 'code' => 502 ],
|
||||
'headers' => [],
|
||||
];
|
||||
}
|
||||
);
|
||||
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 502 );
|
||||
Functions\when( 'wp_remote_retrieve_body' )->justReturn( 'bad gateway' );
|
||||
|
||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
||||
|
||||
try {
|
||||
$provider->generate_image( 'a prompt', 1080, 1080 );
|
||||
$this->fail( 'Expected RuntimeException after retry exhaustion.' );
|
||||
} catch ( \RuntimeException $e ) {
|
||||
$this->assertStringContainsString( '502', $e->getMessage() );
|
||||
}
|
||||
$this->assertSame(
|
||||
PRAUTOBLOGGER_MAX_RETRIES,
|
||||
$call_count,
|
||||
'Provider must attempt exactly PRAUTOBLOGGER_MAX_RETRIES times before giving up.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 429 triggers the same retry loop as 5xx (rate-limited, not deterministic
|
||||
* error). The provider must retry and eventually succeed if the second
|
||||
* attempt returns 200.
|
||||
*/
|
||||
public function test_generate_image_retries_on_429(): void {
|
||||
$this->with_token( 'test-token' );
|
||||
|
||||
$response_codes = [ 429, 200 ];
|
||||
$response_bodies = [ 'slow down', self::FAKE_IMAGE_BYTES ];
|
||||
|
||||
Functions\when( 'wp_remote_post' )->justReturn( [
|
||||
'body' => self::FAKE_IMAGE_BYTES,
|
||||
'response' => [ 'code' => 200 ],
|
||||
'headers' => [ 'content-type' => 'image/png' ],
|
||||
] );
|
||||
Functions\when( 'wp_remote_retrieve_response_code' )->alias(
|
||||
function () use ( &$response_codes ) {
|
||||
return array_shift( $response_codes ) ?? 200;
|
||||
}
|
||||
);
|
||||
Functions\when( 'wp_remote_retrieve_body' )->alias(
|
||||
function () use ( &$response_bodies ) {
|
||||
return array_shift( $response_bodies ) ?? self::FAKE_IMAGE_BYTES;
|
||||
}
|
||||
);
|
||||
// Content-type is only read on the success attempt; retry-after is
|
||||
// read on the 429 attempt and (int)-coerces anything non-numeric
|
||||
// back to 0, falling through to exponential backoff.
|
||||
Functions\when( 'wp_remote_retrieve_header' )->justReturn( 'image/png' );
|
||||
|
||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
||||
$result = $provider->generate_image( 'a prompt', 1080, 1080 );
|
||||
|
||||
$this->assertSame( self::FAKE_IMAGE_BYTES, $result['bytes'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* validate_credentials_detailed returns 'ok' on a 200 from the models
|
||||
* listing endpoint.
|
||||
*/
|
||||
public function test_validate_credentials_ok_on_200(): void {
|
||||
$this->with_token( 'test-token' );
|
||||
Functions\when( 'wp_remote_get' )->justReturn( [
|
||||
'body' => '{"result":[{"id":"@cf/black-forest-labs/flux-1-schnell"}]}',
|
||||
'response' => [ 'code' => 200 ],
|
||||
] );
|
||||
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
|
||||
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"result":[]}' );
|
||||
|
||||
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
|
||||
$result = $provider->validate_credentials_detailed();
|
||||
|
||||
$this->assertSame( 'ok', $result['status'] );
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
<?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 ) );
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_OpenRouter_Image_Provider.
|
||||
*
|
||||
* Validates provider interface compliance, response parsing, and error handling.
|
||||
* All HTTP calls are mocked — no real API calls.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Providers
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Providers;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class OpenRouterImageProviderTest extends BaseTestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
// Provide a valid encrypted API key.
|
||||
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
|
||||
static $options = [
|
||||
'prautoblogger_openrouter_api_key' => 'enc:fake-encrypted-key',
|
||||
'prautoblogger_image_model' => 'black-forest-labs/flux-2-pro',
|
||||
'prautoblogger_ai_gateway_base_url' => '',
|
||||
];
|
||||
return $options[ $key ] ?? $default;
|
||||
} );
|
||||
|
||||
Functions\when( 'is_wp_error' )->alias( function ( $thing ) {
|
||||
return $thing instanceof \WP_Error;
|
||||
} );
|
||||
Functions\when( 'esc_html' )->returnArg();
|
||||
Functions\when( 'esc_html__' )->returnArg();
|
||||
|
||||
// Mock encryption: decrypt returns a valid sk-or- key.
|
||||
if ( ! class_exists( 'PRAutoBlogger_Encryption' ) ) {
|
||||
eval( '
|
||||
class PRAutoBlogger_Encryption {
|
||||
public static function is_encrypted( $v ) { return 0 === strpos( $v, "enc:" ); }
|
||||
public static function decrypt( $v ) { return "sk-or-test-key-123"; }
|
||||
public static function encrypt( $v ) { return "enc:" . $v; }
|
||||
}
|
||||
' );
|
||||
}
|
||||
}
|
||||
|
||||
/** Provider name matches expected identifier. */
|
||||
public function test_get_provider_name(): void {
|
||||
$provider = new \PRAutoBlogger_OpenRouter_Image_Provider();
|
||||
$this->assertSame( 'openrouter', $provider->get_provider_name() );
|
||||
}
|
||||
|
||||
/** estimate_cost returns a positive float. */
|
||||
public function test_estimate_cost_returns_float(): void {
|
||||
$provider = new \PRAutoBlogger_OpenRouter_Image_Provider();
|
||||
$cost = $provider->estimate_cost( 1200, 632 );
|
||||
$this->assertIsFloat( $cost );
|
||||
$this->assertGreaterThan( 0, $cost );
|
||||
}
|
||||
|
||||
/** Empty prompt throws InvalidArgumentException. */
|
||||
public function test_generate_image_empty_prompt_throws(): void {
|
||||
$provider = new \PRAutoBlogger_OpenRouter_Image_Provider();
|
||||
$this->expectException( \InvalidArgumentException::class );
|
||||
$provider->generate_image( '', 1200, 632 );
|
||||
}
|
||||
|
||||
/** Dimension below minimum throws InvalidArgumentException. */
|
||||
public function test_generate_image_tiny_dimensions_throws(): void {
|
||||
$provider = new \PRAutoBlogger_OpenRouter_Image_Provider();
|
||||
$this->expectException( \InvalidArgumentException::class );
|
||||
$provider->generate_image( 'A test prompt', 10, 10 );
|
||||
}
|
||||
|
||||
/** Dimension above maximum throws InvalidArgumentException. */
|
||||
public function test_generate_image_huge_dimensions_throws(): void {
|
||||
$provider = new \PRAutoBlogger_OpenRouter_Image_Provider();
|
||||
$this->expectException( \InvalidArgumentException::class );
|
||||
$provider->generate_image( 'A test prompt', 5000, 5000 );
|
||||
}
|
||||
|
||||
/** Successful generation returns correct array structure. */
|
||||
public function test_generate_image_success(): void {
|
||||
// Build a fake OpenRouter response with base64 image data.
|
||||
$fake_bytes = 'fake-image-data';
|
||||
$b64 = base64_encode( $fake_bytes );
|
||||
$data_url = 'data:image/png;base64,' . $b64;
|
||||
$body = wp_json_encode( [
|
||||
'choices' => [ [
|
||||
'message' => [
|
||||
'role' => 'assistant',
|
||||
'content' => '',
|
||||
'images' => [ [
|
||||
'type' => 'image_url',
|
||||
'image_url' => [ 'url' => $data_url ],
|
||||
] ],
|
||||
],
|
||||
] ],
|
||||
] );
|
||||
|
||||
Functions\when( 'wp_remote_post' )->justReturn( [
|
||||
'response' => [ 'code' => 200 ],
|
||||
'headers' => [],
|
||||
'body' => $body,
|
||||
] );
|
||||
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
|
||||
Functions\when( 'wp_remote_retrieve_body' )->justReturn( $body );
|
||||
Functions\when( 'wp_remote_retrieve_header' )->justReturn( '' );
|
||||
Functions\when( 'wp_parse_url' )->alias( function ( $url, $component = -1 ) {
|
||||
return parse_url( $url, $component );
|
||||
} );
|
||||
Functions\when( 'add_action' )->justReturn( true );
|
||||
Functions\when( 'remove_action' )->justReturn( true );
|
||||
Functions\when( 'home_url' )->justReturn( 'https://peptiderepo.com' );
|
||||
|
||||
$provider = new \PRAutoBlogger_OpenRouter_Image_Provider();
|
||||
$result = $provider->generate_image( 'A peptide research infographic', 1200, 632 );
|
||||
|
||||
$this->assertArrayHasKey( 'bytes', $result );
|
||||
$this->assertArrayHasKey( 'mime_type', $result );
|
||||
$this->assertArrayHasKey( 'model', $result );
|
||||
$this->assertArrayHasKey( 'cost_usd', $result );
|
||||
$this->assertArrayHasKey( 'latency_ms', $result );
|
||||
$this->assertSame( $fake_bytes, $result['bytes'] );
|
||||
$this->assertSame( 'image/png', $result['mime_type'] );
|
||||
$this->assertSame( 1200, $result['width'] );
|
||||
$this->assertSame( 632, $result['height'] );
|
||||
}
|
||||
|
||||
/** 4xx error throws RuntimeException. */
|
||||
public function test_generate_image_4xx_throws(): void {
|
||||
Functions\when( 'wp_remote_post' )->justReturn( [
|
||||
'response' => [ 'code' => 401 ],
|
||||
'headers' => [],
|
||||
'body' => '{"error":"unauthorized"}',
|
||||
] );
|
||||
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 401 );
|
||||
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"error":"unauthorized"}' );
|
||||
Functions\when( 'wp_remote_retrieve_header' )->justReturn( '' );
|
||||
Functions\when( 'wp_parse_url' )->alias( function ( $url, $component = -1 ) {
|
||||
return parse_url( $url, $component );
|
||||
} );
|
||||
Functions\when( 'add_action' )->justReturn( true );
|
||||
Functions\when( 'remove_action' )->justReturn( true );
|
||||
Functions\when( 'home_url' )->justReturn( 'https://peptiderepo.com' );
|
||||
|
||||
$provider = new \PRAutoBlogger_OpenRouter_Image_Provider();
|
||||
$this->expectException( \RuntimeException::class );
|
||||
$this->expectExceptionMessage( 'HTTP 401' );
|
||||
$provider->generate_image( 'A test prompt', 1200, 632 );
|
||||
}
|
||||
}
|
||||
@@ -48,9 +48,6 @@ class OpenRouterProviderTest extends BaseTestCase {
|
||||
);
|
||||
Functions\when( 'set_transient' )->justReturn( true );
|
||||
|
||||
// Stub URL parsing.
|
||||
Functions\when( 'wp_parse_url' )->alias( 'parse_url' );
|
||||
|
||||
// Stub HTTP functions for API calls.
|
||||
Functions\when( 'wp_remote_post' )->justReturn( [
|
||||
'body' => '{"choices":[{"message":{"content":"test"},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user