Compare commits

..

30 Commits

Author SHA1 Message Date
peptiderepo
9202f81a90 style: auto-fix PHPCS violations via phpcbf 2026-05-30 00:05:07 +00:00
peptiderepo
5eb9e598b3 Commit 1: editorial image prompt + style-template setting
Pivot the image pipeline from the single-panel comic style to a text-free
editorial scientific illustration, and replace the Style Suffix setting with
an editable Style Template setting. Commit 1 of the in-plugin editorial image
pipeline (no composer yet; pipeline otherwise unchanged).

- New constant PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE (brief §7 editorial
  template with the single {{ topic_summary }} token).
- New setting prautoblogger_image_style_template (textarea) replaces the
  Style Suffix field; single-token validation on save (brief A5); old
  prautoblogger_image_style_suffix mirrored to _deprecated for one cycle and
  not deleted (one-time migration prautoblogger_migrated_style_template_v0160).
- REWRITER_SYSTEM_PROMPT now emits a 1-2 sentence editorial topic/mechanism
  summary as the SCENE (no comic gag), keeping the CAPTION line for the
  unchanged HTML-caption-below-image path; scene/caption parsing contract
  preserved.
- New PRAutoBlogger_Image_Template_Filler substitutes the scene into the
  template's {{ topic_summary }} slot across all three entry points; strips
  control chars + clamps length (A5); degrades safely on missing token or
  empty summary so a blank/token-only prompt is never sent to the provider
  (A5/A6).
- Version bump 0.15.1 -> 0.16.0 + CHANGELOG + ARCHITECTURE.md updates.

Agent-Session: image-editorial-commit1
2026-05-30 00:04:26 +00:00
peptiderepo
ac60c3d3a6 ci: make smoke gate informational (#144)
Smoke runs but does not block prod promotion until triage completes.

Agent-Session: cto retry-merge-smoke-informational
2026-05-14 21:34:06 +07:00
peptiderepo
1955f92859 Fix cost-tracker SELECT against canonical schema (v0.15.1) (#142)
Author: Engineer PRAutoBlogger
QA: 0bac52d clean (0 P0/P1/P2, 3 P3 nits)
Fixes WP DB error notice in AI Models settings tab present since v0.11.0.

Agent-Session: cto merge-cost-tracker-fix
2026-05-14 16:15:00 +07:00
peptiderepo
96f0248d81 ci: add smoke gate + deploy summary 2026-04-26 11:55:52 +07:00
peptiderepo
19849a5eaf ci: standardize PHP matrix to 8.1/8.2/8.3 2026-04-26 11:55:51 +07:00
peptiderepo
d9317ff567 fix: correct happy-path sync test — taskType, WP response helpers, shared store
Three bugs in test_sync_success_caches_and_returns_models:
1. Mock data lacked taskType=imageInference so normalize_models() filtered
   everything out → sync() returned false (empty result).
2. wp_remote_retrieve_response_code and wp_remote_retrieve_body not mocked
   → Brain\Monkey MissingFunctionExpectations.
3. is_wp_error not mocked.
4. get_option/update_option used separate static stores so update_option
   write was invisible to the subsequent get_option in get_models().
2026-04-26 04:50:02 +00:00
peptiderepo
30282035b5 fix: add patchwork.json to allow time() mocking in catalog tests
RunwareModelCatalogTest mocks time() via Patchwork/Brain\Monkey. Without
declaring it in redefinable-internals, Patchwork throws NotUserDefined
on every test in that suite (18 failures).
2026-04-26 04:47:46 +00:00
peptiderepo
6db6ffd7f5 fix: stub get_option/update_option in ImageModelRegistryTest for catalog delegation
get_models() now delegates to PRAutoBlogger_Runware_Model_Catalog which
calls get_option() to check WP cache. Brain\Monkey threw MissingFunction
Expectations on all registry tests. Added setUp() stub: get_option returns
false (no cache) so catalog falls back to hardcoded list; update_option
stubbed to prevent side-effect errors.
2026-04-26 04:45:59 +00:00
peptiderepo
1cdb41224b fix: add PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER to test bootstrap
Catalog class references this constant; test suite was throwing
"Undefined constant" on all image pipeline tests (18 errors).
2026-04-26 04:43:36 +00:00
peptiderepo
b2bc4be9c2 fix: correct array nesting in settings-fields-extended (CI lint failure)
Orphaned array entry for runware_catalog_sync field was placed after the
closing ); of the fields array instead of inside it. PHP parse error on
line 268. Moved entry to correct position inside the array.
2026-04-26 04:41:25 +00:00
peptiderepo
73ac6d12b4 feat: live Runware model sync with 24h cache and Sync Now button (v0.15.0)
Replace hardcoded model registry with live-sync system.

New PRAutoBlogger_Runware_Model_Catalog class fetches the Runware model
catalog daily, caches in WP options (24h TTL), and falls back to the
hardcoded 15-model list if the API is unavailable. Sync Now button
in PRAdmin for on-demand refresh. Eliminates the drift that caused
silent model fallback bugs (v0.13.8, v0.14.0).
2026-04-26 11:38:59 +07:00
peptiderepo
61975d4551 fix: expand Runware pricing whitelist to all 15 registry models (v0.14.0)
All Runware model selections except schnell and dev were silently falling
back to runware:100@1 because COST_PER_IMAGE only had 2 entries while the
image model registry was expanded to 15 models in v0.13.8.

Fix: add all 15 model IDs to COST_PER_IMAGE and DEFAULT_STEPS.
2026-04-26 11:14:28 +07:00
peptiderepo
ca82e395f4 fix: model picker price sort broken for image models (v0.13.9)
applySort() always read input_price_per_m for price sorting, but image
models carry cost_per_image instead — input_price_per_m is undefined on
them, so every image model got an effective price of 0 and the sort was a
no-op.

Fix: detect image models via cost_per_image !== undefined and use that
field when present; fall back to input_price_per_m for text models.

Files changed:
- assets/js/model-picker.js — applySort price branch
- prautoblogger.php        — version bump 0.13.8 → 0.13.9
- CHANGELOG.md             — release notes
2026-04-26 02:36:56 +00:00
peptiderepo
3f60ecd0ff feat: expand image model registry to 21 models (v0.13.8)
Adds 13 new Runware text-to-image models and openai/gpt-5.4-image-2 via OpenRouter.
All models live-verified 2026-04-26.
2026-04-26 03:49:25 +07:00
peptiderepo
046d57877c fix: rebalance Posts list column widths (v0.13.7)
35% title + 255px fixed PRAB cols left ~50px per auto column.
Replace with a balanced budget across all columns so Author,
Categories, Tags, and Date all get enough room to render horizontally:
  title 22%, author 6%, categories 7%, tags 9%, date 8%,
  writing_model/image_model 85px each, cost 50px, peptide_topics 75px
2026-04-24 14:44:15 +00:00
peptiderepo
c628fc8cbb fix: use width not min-width for Peptide Topics column (v0.13.6)
table-layout: fixed ignores min-width — the WP posts list table uses
fixed layout so min-width: 80px had no effect. Changed to width: 80px
which is honoured under fixed-layout tables.
2026-04-24 14:13:05 +00:00
peptiderepo
6e9d2e3928 style: fix SQL string indentation (phpcbf) 2026-04-24 14:07:57 +00:00
peptiderepo
066e533f09 fix: cost column SQL error + Peptide Topics column squish (v0.13.5)
Two bugs in class-post-list-columns.php:

1. Cost always showed $0.00: a PHP '// phpcs:ignore' comment was
   placed inside the SQL string literal, causing MariaDB syntax error
   on every render_cost_cell() query. Moved comment to the line above
   wpdb->prepare() where it belongs.

2. Peptide Topics column header compressed to near-zero width by the
   three fixed-width PRAB columns (Writing Model + Image Model + Cost =
   255px). Added min-width: 80px to .column-taxonomy-peptide_topic so
   the header renders horizontally.
2026-04-24 14:06:23 +00:00
peptiderepo
7924b47913 fix: pin price column to right edge of row (v0.13.4)
Root cause: .ab-mp-price and .ab-mp-ctx were siblings in .ab-mp-row-meta
with price first. Description text is variable-length, so the meta div
grew/shrank per row, shifting the price left/right.

Fix:
- Move price to last child in .ab-mp-row-meta so its right edge is
  always pinned to the row's fixed right edge
- Add flex-shrink: 0 to .ab-mp-price so it never compresses
- Cap .ab-mp-ctx at max-width 200px with text-overflow: ellipsis
- Swap column header order (Context, then Price) to match new DOM order
2026-04-24 09:34:47 +00:00
peptiderepo
a5a370779a fix: align price column header with data cells (v0.13.3)
.ab-mp-col-price was missing min-width and right-alignment even though
.ab-mp-price had both. The header sat left while values sat right,
so they didn't track the same column edge. Added min-width: 90px and
justify-content: flex-end to match.
2026-04-24 09:16:10 +00:00
peptiderepo
86f6801079 feat(model-picker): sortable column headers — Model, Price, Context
- Add .ab-mp-col-headers bar between search and list with three
  clickable column headers: Model (sorts by name), Price in/out
  (sorts by input_price_per_m), Context (sorts by context_length)
- Track sortState at module level so preference persists across
  open/close; clicking same column toggles asc/desc
- Deprecated models always sink to bottom regardless of sort column
- Add CSS for .ab-mp-col-headers, .ab-mp-col-header, .ab-mp-sort-arrow,
  .ab-mp-col-active; widths mirror existing row flex layout so columns
  visually align with data

Bumps version 0.13.1 → 0.13.2
2026-04-24 09:05:54 +00:00
peptiderepo
89c2bafeec fix: right-align price column in model picker modal (v0.13.1)
Add min-width: 90px and text-align: right to .ab-mp-price so prices
like $0.0006/image and $0.0800/image stack on a consistent right edge
instead of floating at uneven offsets.
2026-04-24 09:00:36 +00:00
peptiderepo
1d46ace22f ci: split deploy.yml into staging→production pipeline with approval gate 2026-04-24 13:11:23 +07:00
peptiderepo
d465d6f1cf ci: add phpcbf auto-fix step — silently corrects engineer PHPCS violations on PR branches 2026-04-24 12:35:51 +07:00
peptiderepo
a8ba7a8ea9 feat: Wave 2 eval harness + image pipeline instrumentation (v0.13.0)
Closes #119

- Extract PRAutoBlogger_Image_Scene_Parser from image prompt builder (331→300 lines)
- Add frozen eval dataset (20 peptide topics at eval/dataset.json)
- Add LLM-as-judge scorer (gemini-flash-1.5 via OpenRouter, 3 axes)
- Add eval runner orchestrator with dry-run and Opik push support
- Add WP-CLI command: wp prautoblogger opik:eval [--limit=<n>] [--dry-run]
- Add image pipeline Opik instrumentation (image_prompt_rewrite span)
- Add content generator eval mode (suppresses publish + image gen)
- Version: v0.13.0
2026-04-24 12:03:33 +07:00
peptiderepo
c1d22fd275 fix: Opik autoloader gap — services/opik/ not scanned (v0.12.3)
class-autoloader.php listed services/ but not services/opik/. All four Opik classes live in that subdirectory. Composer classmap covered it (tests passed); WP runtime autoloader would fatal in production on first Opik class reference. Adds services/opik/ to the scan list. Bumps to v0.12.3.
2026-04-24 11:08:23 +07:00
peptiderepo
b9dfc29436 fix: image style suffix empty-string bypass + image cost run_id mismatch (v0.12.2)
Bug 1: extract get_style_suffix() helper that treats '' as "use default", applied to all 3 call sites in class-image-prompt-builder.php.

Bug 2: thread $cost_tracker through ArticleWorker → Publisher → PostAssembler → ImagePipeline so image costs share the article's run_id in wp_prautoblogger_generation_log.

Version: 0.12.1 → 0.12.2
Reviewed-by: peptiderepo (CTO)
QA: qa-reviews/prautoblogger/2026-04-23-7669a9c1.md
2026-04-24 01:36:46 +07:00
peptiderepo
58710a0263 fix: use UUID v7 for Opik trace and span IDs (v0.12.1)
Opik requires version 7 UUIDs. wp_generate_uuid4() was producing v4
which the API rejected with 422. Replaced both call sites in
class-opik-trace-context.php with generate_uuid7(), a private static
method implementing RFC 9562 §5.7.

Fixes: smoke test 422 {"errors":["Trace id must be a version 7 UUID"]}
2026-04-23 22:57:28 +07:00
peptiderepo
fbbb387311 feat: Opik LLM observability tracing skeleton (Wave 1) — v0.12.0
Wires PRAutoblogger into Comet Opik cloud for per-call tracing.

- Thin PHP REST client (class-opik-client.php, 276 LOC)
- Async fire-and-forget dispatch via wp_schedule_single_event
- Feature flag PRAUTOBLOGGER_OPIK_ENABLED defaults OFF
- One trace per article, 5 spans across content-generator + chief-editor
- Exponential backoff 2s/4s/8s, max 3 retries, drops on failure
- Admin settings page (api-key configured indicator only, never rendered)
- ARCHITECTURE.md + CONVENTIONS.md updated

PR #113 — QA gate: PASS (qa-reviews/prautoblogger/2026-04-23-0d8dc67.md)
2026-04-23 22:36:24 +07:00
37 changed files with 2823 additions and 413 deletions

View File

@@ -21,7 +21,7 @@ jobs:
strategy:
fail-fast: false
matrix:
php: ['7.4', '8.1', '8.3']
php: ['8.1', '8.2', '8.3']
steps:
- uses: actions/checkout@v4
- name: Setup PHP ${{ matrix.php }}
@@ -31,7 +31,7 @@ jobs:
coverage: none
- name: Syntax check all PHP files
# Exclude .github/mu-plugins — those run on the production server (PHP 8+)
# and may use PHP 8+ syntax (union types etc.) that 7.4 rejects.
# and may use PHP 8+ syntax (union types etc.) that older versions reject.
run: |
set -e
find . -name '*.php' \
@@ -44,9 +44,14 @@ jobs:
phpcs:
name: PHPCS (WordPress standards)
runs-on: ubuntu-latest
continue-on-error: false # now strict after PHPCS cleanup in v0.8.3
continue-on-error: false # strict after PHPCS cleanup in v0.8.3
permissions:
contents: write # allows phpcbf auto-fix commits back to the PR branch
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref || github.ref_name }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
@@ -55,22 +60,34 @@ jobs:
tools: composer:v2, phpcs
- name: Install WordPress Coding Standards
run: |
# Allow the PHPCS composer-installer plugin (auto-registers standards).
composer global config --no-plugins allow-plugins.dealerdirect/phpcodesniffer-composer-installer true
# WPCS 3.x requires PHPCSExtra (for the Universal.* and NormalizedArrays.* sniffs)
# AND phpcsutils. Installing the installer itself lets it auto-register the standards path.
composer global require --no-interaction \
"dealerdirect/phpcodesniffer-composer-installer:^1.0" \
"wp-coding-standards/wpcs:^3.0" \
"phpcsstandards/phpcsutils:^1.0" \
"phpcsstandards/phpcsextra:^1.0"
- name: Run PHPCS
- name: Auto-fix with phpcbf (commit back to branch if changed)
# phpcbf exits 1 when it makes fixes, 0 when nothing to fix — both are success here.
# Commits made via GITHUB_TOKEN do not trigger a new CI run (GitHub prevents loops).
run: |
phpcbf --standard=phpcs.xml \
--extensions=php \
--ignore=vendor/*,tests/*,node_modules/* \
includes/ prautoblogger.php uninstall.php || true
if ! git diff --quiet; then
git config user.name "peptiderepo"
git config user.email "peptiderepo@users.noreply.github.com"
git add -A
git commit -m "style: auto-fix PHPCS violations via phpcbf"
git push
fi
- name: Run PHPCS (only non-auto-fixable violations can fail here)
run: |
phpcs --standard=phpcs.xml \
--extensions=php \
--ignore=vendor/*,tests/*,node_modules/* \
--warning-severity=0 \
--report=summary \
--report=full \
includes/ prautoblogger.php uninstall.php
phpunit:
@@ -79,7 +96,7 @@ jobs:
strategy:
fail-fast: false
matrix:
php: ['7.4', '8.1', '8.3']
php: ['8.1', '8.2', '8.3']
steps:
- uses: actions/checkout@v4
- name: Setup PHP ${{ matrix.php }}

View File

@@ -4,21 +4,20 @@ on:
push:
branches: [main]
concurrency:
group: deploy-prautoblogger-${{ github.ref }}
cancel-in-progress: true
jobs:
validate:
name: PHP Lint Gate
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.1'
coverage: none
- name: PHP Lint Check
run: |
set -e
@@ -29,38 +28,71 @@ jobs:
-not -path './.github/*' \
-print0 | xargs -0 -n1 -P4 php -l > /dev/null
deploy:
deploy-staging:
name: Deploy to Staging
needs: validate
name: Deploy to Production
runs-on: ubuntu-latest
environment: staging
steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: actions/checkout@v4
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -p ${{ secrets.SSH_PORT }} ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
- name: Deploy via rsync
- name: Deploy via rsync → staging
run: |
rsync -avz --delete \
--exclude '.git' \
--exclude '.github' \
--exclude 'tests' \
--exclude 'phpunit.xml.dist' \
--exclude 'composer.json' \
--exclude 'composer.lock' \
--exclude 'vendor' \
--exclude '.distignore' \
--exclude 'ARCHITECTURE.md' \
--exclude 'CONVENTIONS.md' \
--exclude '.git' --exclude '.github' \
--exclude 'tests' --exclude 'phpunit.xml.dist' \
--exclude 'composer.json' --exclude 'composer.lock' \
--exclude 'vendor' --exclude '.distignore' \
--exclude 'ARCHITECTURE.md' --exclude 'CONVENTIONS.md' \
-e "ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }}" \
./ ${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_HOST }}:~/domains/peptiderepo.com/public_html/staging/wp-content/plugins/prautoblogger/
- name: Staging health check
run: |
sleep 5
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://staging.peptiderepo.com/ || echo "000")
echo "Staging responded: HTTP $STATUS"
[ "$STATUS" = "000" ] && echo "Warning: staging DNS may still be propagating" || true
- name: Cleanup
if: always()
run: rm -f ~/.ssh/deploy_key
smoke:
name: Smoke tests (staging)
needs: deploy-staging
continue-on-error: true
uses: peptiderepo/peptide-e2e/.github/workflows/smoke.yml@main
secrets:
STAGING_WP_ADMIN_USER: ${{ secrets.STAGING_WP_ADMIN_USER }}
STAGING_WP_ADMIN_PASSWORD: ${{ secrets.STAGING_WP_ADMIN_PASSWORD }}
deploy-production:
name: Deploy to Production
needs: [validate, deploy-staging]
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -p ${{ secrets.SSH_PORT }} ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
- name: Deploy via rsync → production
run: |
rsync -avz --delete \
--exclude '.git' --exclude '.github' \
--exclude 'tests' --exclude 'phpunit.xml.dist' \
--exclude 'composer.json' --exclude 'composer.lock' \
--exclude 'vendor' --exclude '.distignore' \
--exclude 'ARCHITECTURE.md' --exclude 'CONVENTIONS.md' \
-e "ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }}" \
./ ${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_HOST }}:~/domains/peptiderepo.com/public_html/wp-content/plugins/prautoblogger/
- name: Purge LiteSpeed Cache
run: |
ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
@@ -68,22 +100,20 @@ jobs:
"cd ~/domains/peptiderepo.com/public_html && \
wp litespeed-purge all 2>/dev/null || \
(find wp-content/litespeed/ -type f -delete 2>/dev/null; touch .lscache_purge 2>/dev/null) || true"
- name: Health check
- name: Production health check
run: |
sleep 5
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://peptiderepo.com)
# 200 = normal healthy response.
# 403 = peptide-starter-theme v1.5.2 CF-peer validator rejecting this
# GHA-runner direct-to-origin request as non-Cloudflare traffic.
# That rejection is the validator working — itself a positive
# liveness signal. Real user traffic via Cloudflare gets 200.
if [ "$STATUS" != "200" ] && [ "$STATUS" != "403" ]; then
echo "ERROR: Site returned unexpected HTTP $STATUS after deploy"
exit 1
fi
echo "Site is responsive (HTTP $STATUS)"
echo "Production healthy (HTTP $STATUS)"
- name: Cleanup
if: always()
run: rm -f ~/.ssh/deploy_key
- name: Deploy summary
run: |
echo "## Deployment Complete" >> $GITHUB_STEP_SUMMARY
echo "- **Commit:** \`${GITHUB_SHA::7}\`" >> $GITHUB_STEP_SUMMARY
echo "- **Deployed to:** peptiderepo.com" >> $GITHUB_STEP_SUMMARY

View File

@@ -147,6 +147,7 @@ prautoblogger/
│ │ ├── class-post-assembler.php # Post-creation helpers: taxonomy, log linking, images, sanitization
│ │ ├── class-image-pipeline.php # Orchestrates A/B image generation (parallel via batch)
│ │ ├── class-image-prompt-builder.php # Generates visual prompts from article/source data
│ │ ├── class-image-template-filler.php # Fills the editorial style template's {{ topic_summary }} slot (v0.16.0)
│ │ ├── 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)
@@ -437,13 +438,14 @@ All prefixed with `prautoblogger_`:
| `prautoblogger_schedule_time` | Daily generation time (HH:MM, default: '03:00') |
| `prautoblogger_image_model` | Image model slug from `Image_Model_Registry::get_models()`; provider is derived from this on save |
| `prautoblogger_image_provider` | Derived from the chosen model on save (v0.8.0+); not editable in admin UI |
| `prautoblogger_image_prompt_instructions` | System prompt given to the image rewriter LLM (v0.8.0+); falls back to `Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT` when empty |
| `prautoblogger_image_prompt_instructions` | System prompt given to the image rewriter LLM (v0.8.0+); falls back to `Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT` when empty. v0.16.0: the default now instructs the LLM to emit a 1-2 sentence editorial topic/mechanism summary as the SCENE (was a comic gag) |
| `prautoblogger_image_nsfw_retry` | Toggle: retry NSFW-blocked image slots with a rule-based fallback prompt (default: '1') |
| `prautoblogger_migrated_image_provider_v080` | One-shot migration flag — auto-heals mismatched provider/model pairs on first admin_init after v0.8.0 upgrade |
| `prautoblogger_article_font_family` | Font family key: 'default', 'inter', 'georgia', 'merriweather', 'lora', 'open_sans', 'roboto', 'system' |
| `prautoblogger_article_font_size` | Body font size in px (0 = theme default). Recommended: 16–18. |
| `prautoblogger_table_borders` | Toggle: add borders/padding/striping to tables (default: '1') |
| `prautoblogger_image_style_suffix` | Text appended to every image prompt (default: CEO-locked 90s infomercial prompt) |
| `prautoblogger_image_style_template` | Full image prompt template (v0.16.0+); contains exactly one `{{ topic_summary }}` token filled per-article with the rewriter scene. Default = `PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE` (editorial scientific illustration). Replaces the Style Suffix. Validated on save (single-token, brief A5) |
| `prautoblogger_image_style_suffix` | DEPRECATED (v0.16.0). Former comic style suffix appended to every image prompt; no longer read by the builder. Mirrored to `prautoblogger_image_style_suffix_deprecated` for one cycle and not deleted yet |
| `prautoblogger_openrouter_model_registry` | Normalized OpenRouter model list (JSON array, daily refresh, serves as durable cache) |
| `prautoblogger_openrouter_model_registry_fetched_at` | Unix timestamp of last successful model registry refresh |
@@ -476,7 +478,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` |
| Runware (FLUX.1 via runware.ai) | **Default** image backend (v0.9.0+): schnell ~$0.0006/image, dev ~$0.02/image. True parallel generation via curl_multi. **v0.15.0: live model catalog sync** from `/v1/models` endpoint (task-based API); normalized to Image_Model_Registry shape; cached with 24h TTL; on-demand refresh via AJAX. | API key (encrypted in wp_options) | Account-level quotas | `providers/class-runware-image-provider.php`, `providers/class-runware-image-pricing.php`, `providers/class-runware-image-support.php`, `providers/class-runware-image-batch.php`, `providers/class-runware-model-catalog.php` |
---
@@ -575,6 +577,16 @@ Reddit rejected our OAuth API application (April 2026). We initially switched to
The admin model picker (v1) needs to list OpenRouter models with pricing and capabilities. We fetch `https://openrouter.ai/api/v1/models` (free, unauthenticated) once daily and store the normalized payload in a WP option fronted by a 24h transient. On stale-and-fetch-fails, we serve last-good + surface a warning. The registry class (`class-open-router-model-registry.php`) takes all config via constructor (option name, transient name, endpoint URL) — no PRAUTOBLOGGER_* constants inside the class body. Phase 2 lifts it into a shared Composer package with zero internal edits. Phase 3 adds a parallel `Cloudflare_WorkersAI_Model_Registry` behind the same interface. Capability vocabulary: `text→text`, `text+image→text`, `text+audio→text`, `text→image`, `text→audio`, `text→video`, `text→embedding`. Cost: $0/month.
### #20: Editorial illustration prompt + style-template setting (v0.16.0, May 2026)
Commit 1 of the in-plugin editorial image pipeline (brief `docs/proposals/2026-05-29-image-pipeline-in-plugin-brief.md`). The image style pivots from a single-panel newspaper comic to a **text-free editorial scientific illustration**. Two changes ship here; the deterministic PHP composer is a later commit.
1. **Prompt structure.** `Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT` now asks the rewriter LLM for a concise 1-2 sentence topic/mechanism summary (one concrete centered focal subject, no text/people-as-gag/logos) as the SCENE, keeping the short CAPTION line for the unchanged HTML-caption-below-image path. The scene/caption parsing contract (`Image_Scene_Parser`) is unchanged.
2. **Template substitution.** The old `trim($scene . ' ' . $style_suffix)` concat is replaced by `PRAutoBlogger_Image_Template_Filler::fill($scene)` in all three entry points (`build_article_prompt`, `build_source_prompt`, `build_fallback_prompt`). The filler resolves the active template (admin override → default), strips control chars and clamps the summary length (brief A5), then substitutes the single `{{ topic_summary }}` token. Degradation (brief A5/A6): if the template lacks exactly one token it appends the summary and logs a warning; if the summary is empty it emits the style-only prompt — a blank or token-only prompt is never sent to the provider.
3. **Setting.** New textarea `prautoblogger_image_style_template` (default `PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE`) replaces the Style Suffix field. On save it is sanitised with `sanitize_textarea_field` and validated to contain exactly one token (`Image_Template_Filler::sanitize_for_save`). The old `prautoblogger_image_style_suffix` is mirrored to `..._deprecated` for one cycle and not deleted (one-time migration `prautoblogger_migrated_style_template_v0160`).
Provider/model are unchanged — only the prompt text changes, so production keeps emitting via Runware FLUX.1 schnell but now produces text-free editorial base images.
### #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).

View File

@@ -5,6 +5,211 @@ 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.16.0] - 2026-05-29
### Changed
- **Editorial image prompt (Commit 1 of the in-plugin editorial image pipeline).**
The image style pivots from a single-panel newspaper comic to a text-free
editorial scientific illustration. The rewriter LLM now emits a concise
1-2 sentence topic/mechanism summary as the SCENE (one centered focal
subject, no text/people-as-gag/logos), keeping the short CAPTION line for the
unchanged HTML-caption-below-image behavior. The scene/caption parsing
contract is unchanged. Provider/model are unchanged — production keeps
generating via Runware FLUX.1 schnell but now produces text-free editorial
base images. See `docs/proposals/2026-05-29-image-pipeline-in-plugin-brief.md`.
- The prompt builder now substitutes the rewriter scene into an editable style
template's `{{ topic_summary }}` slot via the new
`PRAutoBlogger_Image_Template_Filler`, replacing the old
`scene + style suffix` concatenation, across all three entry points
(`build_article_prompt`, `build_source_prompt`, `build_fallback_prompt`).
Before substitution the scene is stripped of control characters and
length-clamped (brief A5). If the template lacks exactly one token the
summary is appended and a warning logged; if the summary is empty the
style-only prompt is emitted — a blank or token-only prompt is never sent to
the provider (brief A5/A6).
### Added
- New setting **Style Template** (`prautoblogger_image_style_template`,
textarea) replacing the Style Suffix field. Default =
`PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE` (the editorial template). On save
it is validated to contain exactly one `{{ topic_summary }}` token; an invalid
template keeps the previous value and surfaces a settings error.
### Deprecated
- `prautoblogger_image_style_suffix` is deprecated and no longer read by the
prompt builder. A one-time migration (`prautoblogger_migrated_style_template_v0160`)
mirrors its value to `prautoblogger_image_style_suffix_deprecated` for one
version cycle and seeds the new template default. The old option is NOT
deleted this cycle.
## [0.15.1] - 2026-05-14
### Fixed
- Cost preview in AI Models settings tab no longer prints a WordPress database
error notice; query now uses canonical column names (`prompt_tokens`,
`completion_tokens`, `created_at`). Bug present since v0.11.0 (2026-04-23).
## [0.14.0] - 2026-04-26
## [0.15.0] - 2026-04-26
### Added
- **Live Runware Model Catalog Sync**: New `class-runware-model-catalog.php`
fetches the live Runware model list via their `/v1` endpoint (task-based API),
filters to `taskType=imageInference` (text-to-image only, excluding inpainting),
normalizes to the Image_Model_Registry shape, and caches in `prautoblogger_runware_model_cache`
with a 24-hour TTL. Fixes the pattern from v0.13.8 and v0.14.0 where the
hardcoded Runware model list was a maintenance burden and gaps (missing models,
wrong prices) went unnoticed until customer images generation broke.
- Daily WP-Cron sync (`prautoblogger_sync_runware_models`) scheduled on activation
and unscheduled on deactivation.
- On-demand "Sync now" AJAX button in PRAdmin (Images settings section) with
nonce protection and `manage_options` cap check. Refreshes model count and
last-synced timestamp in the UI without full page reload.
- Smart caching + fallback strategy: if cache is < 24h old, use it; if stale,
trigger a sync; if sync fails, use the last-known-good cache; if no cache,
fall back to hardcoded list (never returns empty array).
- Pricing merge: Runware API may not expose per-image cost. The catalog pulls
costs from `class-runware-image-pricing.php::COST_PER_IMAGE` on every sync,
ensuring pricing data is always authoritative and up-to-date.
- Unit tests for `PRAutoBlogger_Runware_Model_Catalog`: sync success/failure,
fallback on API unreachable, stale cache behavior, cost merge logic.
- `class-image-model-registry.php` refactored: `get_models()` now delegates to
the catalog for Runware models + static OpenRouter list. Extracted fallback
lists into private `get_runware_fallback_models()` and `get_openrouter_models()`
for clarity.
### Fixed
- Silent model list gaps no longer possible: Runware no longer depends on manual
model list maintenance in the pricing class. The live catalog is the source of
truth; pricing is merged in on every sync.
### Changed
- `class-image-model-registry.php`: `get_models()` is now a thin wrapper around
the catalog + OpenRouter static list. Breaking change: callers that expect a
specific static list should note the new dynamic Runware side.
### Fixed
- Runware image generation now correctly uses the admin-selected model instead
of silently falling back to FLUX.1 schnell (runware:100@1) for all non-schnell
and non-dev selections. Root cause: `class-runware-image-pricing.php` only
had 2 models in its `COST_PER_IMAGE` whitelist (schnell + dev), but the
image model registry was expanded to 15 Runware models in v0.13.8 without a
matching update to the pricing class. Any model ID not in the whitelist caused
`resolve_model()` to fall back to schnell, so every Stable Diffusion, FLUX.2,
HiDream, TwinFlow, Z-Image, Qwen-Image, Krea, and GLM-Image selection was
silently downgraded.
- Added all 15 Runware model IDs to `COST_PER_IMAGE` and `DEFAULT_STEPS` with
correct per-image costs and step counts sourced from runware.ai/pricing (April 2026).
- Added `@see` cross-reference to `class-image-model-registry.php` in pricing
class docblock to prevent future drift.
## [0.13.9] - 2026-04-26
### Fixed
- Model picker sort by price now works correctly for image models — previously
\`applySort\` always read \`input_price_per_m\` (undefined on image models),
so every image had an effective price of 0 and price sort was a no-op.
Fix: detect image models via \`cost_per_image\` and use that field instead.
## [0.13.8] - 2026-04-26
### Added
- Image model picker expanded from 7 to 21 models
- New Runware text-to-image models: Stable Diffusion 3, HiDream-I1 Full/Dev/Fast,
FLUX.1 Krea [dev], Qwen-Image, FLUX.2 [dev], FLUX.2 [klein] 9B, FLUX.2 [klein] 4B,
GLM-Image, Z-Image, Z-Image-Turbo, TwinFlow Z-Image-Turbo
- New OpenRouter model: openai/gpt-5.4-image-2
- Registry now includes last-verified date and update instructions in source comment
All notable changes to PRAutoBlogger will be documented in this file.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project uses [Semantic Versioning](https://semver.org/).
## [0.13.7] - 2026-04-24
### Fixed
- Posts list column layout: title was set to 35% which, combined with 3 fixed-width PRAB columns, left ~50px for every remaining column (Author, Categories, Tags, Date all word-wrapping). Replaced with a balanced budget: title 22%, author 6%, categories 7%, tags 9%, date 8%, PRAB columns 85/85/50px, Peptide Topics 75px.
## [0.13.6] - 2026-04-24
### Fixed
- Peptide Topics column still rendering vertically: WordPress posts table uses `table-layout: fixed` which ignores `min-width` entirely. Changed to explicit `width: 80px` which is respected under fixed-layout tables.
## [0.13.5] - 2026-04-24
### Fixed
- Cost column in Posts list was always showing $0.00: a PHP `// phpcs:ignore` comment was embedded inside the SQL string literal, causing a MariaDB syntax error on every query. Moved comment above the `prepare()` call.
- Peptide Topics column header in Posts list was being squeezed to near-zero width by the fixed-width PRAB columns. Added `min-width: 80px` to `.column-taxonomy-peptide_topic`.
## [0.13.4] - 2026-04-24
### Fixed
- Model picker price column now consistently right-aligned. Root cause: price and description were siblings inside .ab-mp-row-meta, with description first and variable-length — this shifted price left/right per row. Fix: moved price to last position with flex-shrink: 0 so its right edge is always pinned to the row edge; capped description at max-width 200px with ellipsis; swapped column headers to match new DOM order.
## [0.13.3] - 2026-04-24
### Fixed
- Price column header now right-aligned with `min-width: 90px` to match data cells — header and prices now track the same column edge.
## [0.13.2] - 2026-04-24
### Changed
- Model picker: added sortable column headers (Model, Price, Context); clicking a header sorts the list ascending/descending; active column highlighted with sort arrow; sort preference persists while the picker is open.
## [0.12.3] — 2026-04-24
## [0.13.1] - 2026-04-24
### Fixed
- Model picker price column now right-aligned with a minimum width so values like `$0.0006/image` and `$0.0800/image` stack cleanly instead of floating at uneven offsets.
## [0.13.0] - 2026-04-24
### Added
- **Wave 2 Eval Harness**: Complete evaluation framework for PRAutoBlogger output quality
- Frozen eval dataset (`eval/dataset.json`) with 20 curated peptide topics
- LLM-as-judge scorer (`class-opik-eval-scorer.php`) using Gemini Flash 1.5
- Eval runner orchestrator (`class-opik-eval-runner.php`) with Opik experiment support
- WP-CLI command: `wp prautoblogger opik:eval [--limit=<n>] [--dry-run]`
- Image pipeline instrumentation: Opik span wrapping for `image_prompt_rewrite` step
- **Image Prompt Builder Refactor**: Extracted scene/caption parsing into `PRAutoBlogger_Image_Scene_Parser`
- Three static methods: `parse_scene_and_caption()`, `extract_first_paragraph()`, `synthesize_visual_concepts_fallback()`
- Cleaner separation of concerns; builder now ~255 lines (was 331)
- **Content Generator Eval Mode**: Added `$eval_mode` parameter to suppress publishing and image generation side effects
- **Trace Context Integration**: Optional trace instrumentation in `PRAutoBlogger_Image_Prompt_Builder` constructor
### Changed
- `PRAutoBlogger_Image_Prompt_Builder` constructor now accepts optional `PRAutoBlogger_Opik_Trace_Context` parameter
- `PRAutoBlogger_Image_Pipeline` constructor passes trace context through to prompt builder
- Image prompt rewrite LLM calls now emit Opik spans when trace context is available
### Fixed
- Image prompt builder now properly delegates to static parser methods
### Fixed
- **Opik autoloader gap** — `class-autoloader.php` listed `services/` as a scan directory but not `services/opik/`, where all four Opik classes live (`Opik_Client`, `Opik_Trace_Context`, `Opik_Span_Queue`, `Opik_Dispatcher`). Composer's classmap (used in PHPUnit) covers `includes/` recursively so tests passed, but the WordPress runtime autoloader would fatal on first Opik class reference in production. Added `services/opik/` to the scan list.
## [0.12.2] — 2026-04-24
### Fixed
- **Image style suffix empty-string bypass** — `get_option()` for `prautoblogger_image_style_suffix` only returns the default constant when the key is absent from `wp_options`; an empty string stored in the DB returns `''`, bypassing fallback entirely. Root cause of post #675 rendering photorealistically instead of as a newspaper comic. Fixed all three call sites in `class-image-prompt-builder.php` (lines 85, 107, 272) by extracting a private helper `get_style_suffix()` that treats both absent AND empty string as "use default".
- **Image cost logged under wrong run_id** — `class-image-pipeline.php` created a fresh cost tracker when invoked without an argument, so image costs were logged under a different `run_id` than the article's other stages. Cost breakdowns aggregating by `run_id` silently excluded the image line item. Fixed by threading the article worker's cost tracker through `PRAutoBlogger_Publisher` and `PRAutoBlogger_Post_Assembler::attach_generated_images()` to the image pipeline constructor. Image costs now share the same `run_id` as analysis, draft, review, and llm_research stages.
## [0.12.1] — 2026-04-23
### Fixed
- **Opik UUID v7 bug** — `PRAutoBlogger_Opik_Trace_Context` was generating trace and
span IDs with `wp_generate_uuid4()` (UUID v4). Opik's REST API requires UUID v7 and
rejects v4 with HTTP 400 "Trace id must be a version 7 UUID". Added a private static
`generate_uuid7()` method using `microtime()` + `random_bytes()` and replaced both
call sites. All trace and span submissions now succeed.
## [0.12.0] — 2026-04-23
### Added

View File

@@ -142,6 +142,7 @@
color: var(--ab-text-muted, #64748b);
font-variant-numeric: tabular-nums;
white-space: nowrap;
min-width: 90px; text-align: right; flex-shrink: 0;
}
.ab-mp-ctx {
@@ -150,6 +151,7 @@
background: var(--ab-bg, #f8fafc);
padding: 2px 6px; border-radius: 4px;
white-space: nowrap; min-width: 36px; text-align: center;
max-width: 200px; overflow: hidden; text-overflow: ellipsis;
}
/* ── Footer ─────────────────────────────────────────────────────────── */
@@ -204,3 +206,59 @@
.ab-mp-row { padding: 10px 14px; }
.ab-mp-row-meta { gap: 8px; }
}
/* ── Column headers (sortable) ──────────────────────────────────────── */
.ab-mp-col-headers {
display: flex; align-items: center; justify-content: space-between;
padding: 5px 20px;
border-bottom: 1px solid var(--ab-border, #e2e8f0);
background: var(--ab-bg, #f8fafc);
gap: 12px; /* must match .ab-mp-row gap */
}
.ab-mp-col-headers-meta {
display: flex; align-items: center;
gap: 16px; /* must match .ab-mp-row-meta gap */
flex-shrink: 0;
}
.ab-mp-col-header {
display: inline-flex; align-items: center;
font-size: 10px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ab-text-muted, #64748b);
cursor: pointer; user-select: none;
background: none; border: none; padding: 3px 0;
white-space: nowrap; line-height: 1.2;
transition: color 0.1s ease;
}
.ab-mp-col-header:hover {
color: var(--ab-text, #1e293b);
}
.ab-mp-col-header.ab-mp-col-active {
color: var(--ab-primary, #4f46e5);
}
/* "Model" header spans the left flex-1 area */
.ab-mp-col-model {
flex: 1; min-width: 0;
}
/* "Price" header width matches .ab-mp-price */
.ab-mp-col-price {
font-variant-numeric: tabular-nums;
min-width: 90px; justify-content: flex-end;
}
/* "Context" header width matches .ab-mp-ctx (min-width 36px + padding) */
.ab-mp-col-ctx {
min-width: 48px; justify-content: center; text-align: center;
}
.ab-mp-sort-arrow {
font-size: 9px; opacity: 0.5;
transition: opacity 0.1s ease;
}
.ab-mp-col-header.ab-mp-col-active .ab-mp-sort-arrow {
opacity: 1;
}

View File

@@ -1,9 +1,10 @@
/**
* Model picker popup for PRAutoBlogger admin settings.
*
* Fetches the OpenRouter model registry via AJAX, then renders a searchable
* popup overlay when the user clicks a model-select trigger button. Shows
* name, provider, input/output cost, and context window per model.
* Fetches the OpenRouter model registry via AJAX, then renders a searchable,
* sortable popup overlay when the user clicks a model-select trigger button.
* Columns: Model, Price (in / out per 1M tokens), Context window.
* Each column header is clickable to sort ascending/descending.
*
* @see admin/class-admin-page.php — Renders model_select fields and localizes config.
* @see services/class-open-router-model-registry.php — Server-side registry source.
@@ -11,54 +12,57 @@
(function ($) {
'use strict';
var config = window.prautobloggerAdmin || {};
var cachedModels = null;
var config = window.prautobloggerAdmin || {};
var cachedModels = null;
var fetchInProgress = null;
// Persists across open/close so the user's preferred sort is remembered.
var sortState = { col: 'name', dir: 'asc' };
/**
* Fetch models from the server (cached after first call).
*
* @param {string} capability Filter capability (e.g. 'text→text').
* @return {jQuery.Deferred} Resolves with model array.
*/
function fetchModels(capability) {
function fetchModels( capability ) {
// Image models are embedded in the page config — no AJAX needed.
if (capability === 'image_generation') {
if ( capability === 'image_generation' ) {
var imgModels = config.imageModels || [];
return $.Deferred().resolve(imgModels);
return $.Deferred().resolve( imgModels );
}
if (cachedModels) {
return $.Deferred().resolve(filterModels(cachedModels, capability));
if ( cachedModels ) {
return $.Deferred().resolve( filterModels( cachedModels, capability ) );
}
if (fetchInProgress) {
return fetchInProgress.then(function () {
return filterModels(cachedModels, capability);
});
if ( fetchInProgress ) {
return fetchInProgress.then( function () {
return filterModels( cachedModels, capability );
} );
}
fetchInProgress = $.ajax({
url: config.ajaxUrl,
fetchInProgress = $.ajax( {
url: config.ajaxUrl,
method: 'POST',
data: {
action: 'prautoblogger_get_models',
nonce: config.modelsNonce
nonce: config.modelsNonce
},
timeout: 15000
}).then(function (response) {
} ).then( function ( response ) {
fetchInProgress = null;
if (response.success && Array.isArray(response.data)) {
if ( response.success && Array.isArray( response.data ) ) {
cachedModels = response.data;
return filterModels(cachedModels, capability);
return filterModels( cachedModels, capability );
}
cachedModels = [];
return [];
}, function () {
fetchInProgress = null;
cachedModels = [];
cachedModels = [];
return [];
});
} );
return fetchInProgress;
}
@@ -70,11 +74,49 @@
* @param {string} capability Capability string to match.
* @return {Array} Filtered models.
*/
function filterModels(models, capability) {
if (!capability) return models;
return models.filter(function (m) {
return m.capabilities && m.capabilities.indexOf(capability) !== -1;
});
function filterModels( models, capability ) {
if ( ! capability ) return models;
return models.filter( function ( m ) {
return m.capabilities && m.capabilities.indexOf( capability ) !== -1;
} );
}
/**
* Sort a model array according to the current sortState.
* Deprecated models always sink to the bottom regardless of column.
*
* @param {Array} arr Model array to sort (not mutated — returns a new array).
* @return {Array} Sorted copy.
*/
function applySort( arr ) {
var col = sortState.col;
var dir = sortState.dir;
return arr.slice().sort( function ( a, b ) {
// Deprecated always last.
if ( a.deprecated !== b.deprecated ) {
return a.deprecated ? 1 : -1;
}
var valA, valB;
if ( col === 'price' ) {
// Image models use cost_per_image; text models use input_price_per_m.
valA = ( a.cost_per_image !== undefined ) ? a.cost_per_image : ( a.input_price_per_m || 0 );
valB = ( b.cost_per_image !== undefined ) ? b.cost_per_image : ( b.input_price_per_m || 0 );
} else if ( col === 'context' ) {
valA = a.context_length || 0;
valB = b.context_length || 0;
} else {
// Default: sort by model name.
valA = ( a.name || '' ).toLowerCase();
valB = ( b.name || '' ).toLowerCase();
}
if ( valA < valB ) return dir === 'asc' ? -1 : 1;
if ( valA > valB ) return dir === 'asc' ? 1 : -1;
return 0;
} );
}
/**
@@ -83,11 +125,11 @@
* @param {number} price Price per 1M tokens.
* @return {string} Formatted string like "$0.25" or "Free".
*/
function formatPrice(price) {
if (!price || price <= 0) return 'Free';
if (price < 0.01) return '$' + price.toFixed(4);
if (price < 1) return '$' + price.toFixed(2);
return '$' + price.toFixed(2);
function formatPrice( price ) {
if ( ! price || price <= 0 ) return 'Free';
if ( price < 0.01 ) return '$' + price.toFixed( 4 );
if ( price < 1 ) return '$' + price.toFixed( 2 );
return '$' + price.toFixed( 2 );
}
/**
@@ -96,10 +138,10 @@
* @param {number} ctx Context length in tokens.
* @return {string} Formatted string like "128K" or "1M".
*/
function formatContext(ctx) {
if (!ctx) return '—';
if (ctx >= 1000000) return (ctx / 1000000).toFixed(0) + 'M';
if (ctx >= 1000) return Math.round(ctx / 1000) + 'K';
function formatContext( ctx ) {
if ( ! ctx ) return '—';
if ( ctx >= 1000000 ) return ( ctx / 1000000 ).toFixed( 0 ) + 'M';
if ( ctx >= 1000 ) return Math.round( ctx / 1000 ) + 'K';
return ctx.toString();
}
@@ -109,45 +151,47 @@
* @param {Object} model Normalized model record.
* @return {string} HTML string.
*/
function renderModelRow(model) {
var deprecated = model.deprecated ? ' ab-mp-deprecated' : '';
function renderModelRow( model ) {
var deprecated = model.deprecated ? ' ab-mp-deprecated' : '';
var isImageModel = model.cost_per_image !== undefined && model.cost_per_image > 0;
var priceHtml, priceTitle, metaRight;
if (isImageModel) {
priceHtml = '$' + model.cost_per_image.toFixed(4) + '/image';
if ( isImageModel ) {
priceHtml = '$' + model.cost_per_image.toFixed( 4 ) + '/image';
priceTitle = 'Cost per generated image';
metaRight = model.description ? '<span class="ab-mp-ctx" title="Details">' + escHtml(model.description) + '</span>' : '';
metaRight = model.description
? '<span class="ab-mp-ctx" title="Details">' + escHtml( model.description ) + '</span>'
: '';
} else {
priceHtml = formatPrice(model.input_price_per_m) + ' / ' + formatPrice(model.output_price_per_m);
priceHtml = formatPrice( model.input_price_per_m ) + ' / ' + formatPrice( model.output_price_per_m );
priceTitle = 'Input / Output per 1M tokens';
metaRight = '<span class="ab-mp-ctx" title="Context window">' + formatContext(model.context_length) + '</span>';
metaRight = '<span class="ab-mp-ctx" title="Context window">' + formatContext( model.context_length ) + '</span>';
}
return (
'<div class="ab-mp-row' + deprecated + '" data-model-id="' + escAttr(model.id) + '">' +
'<div class="ab-mp-row' + deprecated + '" data-model-id="' + escAttr( model.id ) + '">' +
'<div class="ab-mp-row-main">' +
'<span class="ab-mp-name">' + escHtml(model.name) + '</span>' +
'<span class="ab-mp-provider">' + escHtml(model.provider) + '</span>' +
(model.deprecated ? '<span class="ab-mp-dep-badge">deprecated</span>' : '') +
'<span class="ab-mp-name">' + escHtml( model.name ) + '</span>' +
'<span class="ab-mp-provider">' + escHtml( model.provider ) + '</span>' +
( model.deprecated ? '<span class="ab-mp-dep-badge">deprecated</span>' : '' ) +
'</div>' +
'<div class="ab-mp-row-meta">' +
'<span class="ab-mp-price" title="' + priceTitle + '">' + priceHtml + '</span>' +
metaRight +
'<span class="ab-mp-price" title="' + priceTitle + '">' + priceHtml + '</span>' +
'</div>' +
'</div>'
);
}
/** Minimal HTML escaping for display. */
function escHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
function escHtml( str ) {
if ( ! str ) return '';
return str.replace( /&/g, '&amp;' ).replace( /</g, '&lt;' ).replace( />/g, '&gt;' );
}
/** Escape a string for use in an HTML attribute. */
function escAttr(str) {
return escHtml(str).replace(/"/g, '&quot;');
function escAttr( str ) {
return escHtml( str ).replace( /"/g, '&quot;' );
}
/**
@@ -155,16 +199,16 @@
*
* @param {jQuery} $trigger The .ab-mp-trigger button that was clicked.
*/
function openPicker($trigger) {
var fieldId = $trigger.data('field-id');
var capability = $trigger.data('capability') || '';
function openPicker( $trigger ) {
var fieldId = $trigger.data( 'field-id' );
var capability = $trigger.data( 'capability' ) || '';
// Build overlay + popup structure.
var isImageCap = capability === 'image_generation';
var priceLabel = isImageCap ? 'Cost per generated image' : 'Prices per 1M tokens (in / out)';
var $overlay = $('<div class="ab-mp-overlay"></div>');
var $popup = $(
// ── Build overlay + popup ───────────────────────────────────────
var $overlay = $( '<div class="ab-mp-overlay"></div>' );
var $popup = $(
'<div class="ab-mp-popup">' +
'<div class="ab-mp-header">' +
'<span class="ab-mp-title">Select Model</span>' +
@@ -173,6 +217,13 @@
'<div class="ab-mp-search-wrap">' +
'<input type="text" class="ab-mp-search" placeholder="Search models…" autocomplete="off" />' +
'</div>' +
'<div class="ab-mp-col-headers">' +
'<button type="button" class="ab-mp-col-header ab-mp-col-model" data-col="name">Model <span class="ab-mp-sort-arrow"></span></button>' +
'<div class="ab-mp-col-headers-meta">' +
'<button type="button" class="ab-mp-col-header ab-mp-col-ctx" data-col="context">Context <span class="ab-mp-sort-arrow"></span></button>' +
'<button type="button" class="ab-mp-col-header ab-mp-col-price" data-col="price">Price (in / out) <span class="ab-mp-sort-arrow"></span></button>' +
'</div>' +
'</div>' +
'<div class="ab-mp-list"><div class="ab-mp-loading">Loading models…</div></div>' +
'<div class="ab-mp-footer">' +
'<span class="ab-mp-count"></span>' +
@@ -181,105 +232,132 @@
'</div>'
);
$overlay.append($popup);
$('body').append($overlay);
$overlay.append( $popup );
$( 'body' ).append( $overlay );
var $search = $popup.find('.ab-mp-search');
var $list = $popup.find('.ab-mp-list');
var $count = $popup.find('.ab-mp-count');
var allModels = [];
var $search = $popup.find( '.ab-mp-search' );
var $list = $popup.find( '.ab-mp-list' );
var $count = $popup.find( '.ab-mp-count' );
var $colHeaders = $popup.find( '.ab-mp-col-headers' );
var allModels = [];
var currentQuery = '';
// Focus search after render.
setTimeout(function () { $search.focus(); }, 50);
setTimeout( function () { $search.focus(); }, 50 );
// Fetch and render model list.
fetchModels(capability).then(function (models) {
allModels = models;
renderList(models, '');
});
/** Render filtered model list. */
function renderList(models, query) {
var filtered = models;
if (query) {
var q = query.toLowerCase();
filtered = models.filter(function (m) {
return (m.name && m.name.toLowerCase().indexOf(q) !== -1) ||
(m.id && m.id.toLowerCase().indexOf(q) !== -1) ||
(m.provider && m.provider.toLowerCase().indexOf(q) !== -1);
});
}
// Sort: non-deprecated first, then by provider + name.
filtered.sort(function (a, b) {
if (a.deprecated !== b.deprecated) return a.deprecated ? 1 : -1;
var pa = (a.provider || '').toLowerCase();
var pb = (b.provider || '').toLowerCase();
if (pa !== pb) return pa < pb ? -1 : 1;
return (a.name || '').toLowerCase().localeCompare((b.name || '').toLowerCase());
});
if (filtered.length === 0) {
$list.html('<div class="ab-mp-empty">No models found.</div>');
} else {
var html = '';
for (var i = 0; i < filtered.length; i++) {
html += renderModelRow(filtered[i]);
// ── Sort header indicators ──────────────────────────────────────
/** Update active-column highlight and sort arrow glyphs. */
function updateSortHeaders() {
$colHeaders.find( '.ab-mp-col-header' ).each( function () {
var col = $( this ).data( 'col' );
var $arrow = $( this ).find( '.ab-mp-sort-arrow' );
if ( col === sortState.col ) {
$( this ).addClass( 'ab-mp-col-active' );
$arrow.text( sortState.dir === 'asc' ? ' ▲' : ' ▼' );
} else {
$( this ).removeClass( 'ab-mp-col-active' );
$arrow.text( ' ⇅' );
}
$list.html(html);
}
$count.text(filtered.length + ' model' + (filtered.length !== 1 ? 's' : ''));
} );
}
// Search input handler (debounced).
// ── Render ──────────────────────────────────────────────────────
/** Render filtered + sorted model list. */
function renderList( models, query ) {
var filtered = models;
if ( query ) {
var q = query.toLowerCase();
filtered = models.filter( function ( m ) {
return ( m.name && m.name.toLowerCase().indexOf( q ) !== -1 ) ||
( m.id && m.id.toLowerCase().indexOf( q ) !== -1 ) ||
( m.provider && m.provider.toLowerCase().indexOf( q ) !== -1 );
} );
}
filtered = applySort( filtered );
if ( filtered.length === 0 ) {
$list.html( '<div class="ab-mp-empty">No models found.</div>' );
} else {
var html = '';
for ( var i = 0; i < filtered.length; i++ ) {
html += renderModelRow( filtered[ i ] );
}
$list.html( html );
}
$count.text( filtered.length + ' model' + ( filtered.length !== 1 ? 's' : '' ) );
updateSortHeaders();
}
// Initial render after data loads.
fetchModels( capability ).then( function ( models ) {
allModels = models;
renderList( allModels, currentQuery );
} );
// ── Column header sort clicks ───────────────────────────────────
$colHeaders.on( 'click', '.ab-mp-col-header', function () {
var col = $( this ).data( 'col' );
if ( sortState.col === col ) {
sortState.dir = sortState.dir === 'asc' ? 'desc' : 'asc';
} else {
sortState.col = col;
sortState.dir = 'asc';
}
renderList( allModels, currentQuery );
} );
// ── Search (debounced) ──────────────────────────────────────────
var searchTimer = null;
$search.on('input', function () {
var q = $(this).val();
clearTimeout(searchTimer);
searchTimer = setTimeout(function () {
renderList(allModels, q);
}, 150);
});
$search.on( 'input', function () {
currentQuery = $( this ).val();
clearTimeout( searchTimer );
searchTimer = setTimeout( function () {
renderList( allModels, currentQuery );
}, 150 );
} );
// Select a model.
$list.on('click', '.ab-mp-row', function () {
var id = $(this).data('model-id');
var model = allModels.find(function (m) { return m.id === id; });
if (!model) return;
// ── Select a model ──────────────────────────────────────────────
$list.on( 'click', '.ab-mp-row', function () {
var id = $( this ).data( 'model-id' );
var model = null;
for ( var i = 0; i < allModels.length; i++ ) {
if ( allModels[ i ].id === id ) { model = allModels[ i ]; break; }
}
if ( ! model ) return;
// Update hidden input + display.
$('#' + fieldId).val(model.id);
$trigger.find('.ab-mp-display-name').text(model.name || model.id);
$( '#' + fieldId ).val( model.id );
$trigger.find( '.ab-mp-display-name' ).text( model.name || model.id );
var isImageModel = model.cost_per_image !== undefined && model.cost_per_image > 0;
var priceText = isImageModel
? '$' + model.cost_per_image.toFixed(4) + '/image'
: formatPrice(model.input_price_per_m) + ' / ' + formatPrice(model.output_price_per_m);
$trigger.find('.ab-mp-display-price').text(priceText);
var priceText = isImageModel
? '$' + model.cost_per_image.toFixed( 4 ) + '/image'
: formatPrice( model.input_price_per_m ) + ' / ' + formatPrice( model.output_price_per_m );
$trigger.find( '.ab-mp-display-price' ).text( priceText );
closePicker();
});
} );
// Close handlers.
// ── Close ───────────────────────────────────────────────────────
function closePicker() {
$overlay.remove();
}
$popup.find('.ab-mp-close').on('click', closePicker);
$overlay.on('click', function (e) {
if ($(e.target).is('.ab-mp-overlay')) closePicker();
});
$(document).on('keydown.abModelPicker', function (e) {
if (e.key === 'Escape') {
$popup.find( '.ab-mp-close' ).on( 'click', closePicker );
$overlay.on( 'click', function ( e ) {
if ( $( e.target ).is( '.ab-mp-overlay' ) ) closePicker();
} );
$( document ).on( 'keydown.abModelPicker', function ( e ) {
if ( e.key === 'Escape' ) {
closePicker();
$(document).off('keydown.abModelPicker');
$( document ).off( 'keydown.abModelPicker' );
}
});
} );
}
// Bind click on all model picker triggers.
$(document).on('click', '.ab-mp-trigger', function (e) {
$( document ).on( 'click', '.ab-mp-trigger', function ( e ) {
e.preventDefault();
openPicker($(this));
});
openPicker( $( this ) );
} );
})(jQuery);
} )( jQuery );

142
eval/dataset.json Normal file
View File

@@ -0,0 +1,142 @@
[
{
"id": "bpc157-healing",
"topic": "BPC-157 healing protocols for tendons and gut lining",
"expected_keywords": ["BPC-157", "tendon", "gut", "healing"],
"min_words": 700,
"style": "comic"
},
{
"id": "tb500-muscle-recovery",
"topic": "TB-500 for accelerated muscle recovery and repair",
"expected_keywords": ["TB-500", "muscle", "recovery", "growth"],
"min_words": 700,
"style": "comic"
},
{
"id": "ghk-cu-skin-collagen",
"topic": "GHK-Cu peptide for skin collagen synthesis and anti-aging",
"expected_keywords": ["GHK-Cu", "collagen", "skin", "aging"],
"min_words": 700,
"style": "comic"
},
{
"id": "semaglutide-glp1-weight",
"topic": "GLP-1 receptor agonists like Semaglutide for metabolic health",
"expected_keywords": ["Semaglutide", "GLP-1", "weight", "metabolism"],
"min_words": 700,
"style": "comic"
},
{
"id": "ipamorelin-growth-hormone",
"topic": "Ipamorelin for natural growth hormone stimulation and recovery",
"expected_keywords": ["Ipamorelin", "growth hormone", "HGH", "secretagogue"],
"min_words": 700,
"style": "comic"
},
{
"id": "hexarelin-strength-gains",
"topic": "Hexarelin peptide for lean muscle and strength improvement",
"expected_keywords": ["Hexarelin", "muscle", "strength", "lean"],
"min_words": 700,
"style": "comic"
},
{
"id": "semax-cognitive-enhancement",
"topic": "Semax nootropic peptide for cognitive function and focus",
"expected_keywords": ["Semax", "cognition", "focus", "neuroprotection"],
"min_words": 700,
"style": "comic"
},
{
"id": "selank-anxiety-stress",
"topic": "Selank peptide for anxiety reduction and stress management",
"expected_keywords": ["Selank", "anxiety", "stress", "mood"],
"min_words": 700,
"style": "comic"
},
{
"id": "oxytocin-social-bonding",
"topic": "Oxytocin peptide hormone and its role in social connection",
"expected_keywords": ["Oxytocin", "social", "bonding", "neuropeptide"],
"min_words": 700,
"style": "comic"
},
{
"id": "kisspeptin-fertility-hormone",
"topic": "Kisspeptin's role in reproductive health and hormone signaling",
"expected_keywords": ["Kisspeptin", "fertility", "hormone", "reproduction"],
"min_words": 700,
"style": "comic"
},
{
"id": "melanotan-tanning-peptide",
"topic": "Melanotan peptide for pigmentation and UV protection",
"expected_keywords": ["Melanotan", "melanin", "tanning", "skin"],
"min_words": 700,
"style": "comic"
},
{
"id": "cerebrolysin-neuroprotection",
"topic": "Cerebrolysin for brain health and neuroprotection",
"expected_keywords": ["Cerebrolysin", "brain", "neuroprotection", "cognition"],
"min_words": 700,
"style": "comic"
},
{
"id": "epithalon-telomerase-aging",
"topic": "Epithalon peptide and telomerase activation for longevity",
"expected_keywords": ["Epithalon", "telomerase", "aging", "longevity"],
"min_words": 700,
"style": "comic"
},
{
"id": "thymosin-immune-function",
"topic": "Thymosin alpha-1 for immune system enhancement",
"expected_keywords": ["Thymosin", "immune", "T-cells", "immunity"],
"min_words": 700,
"style": "comic"
},
{
"id": "igf1-muscle-growth-aging",
"topic": "IGF-1 peptide for muscle growth, recovery, and anti-aging",
"expected_keywords": ["IGF-1", "muscle", "growth", "aging"],
"min_words": 700,
"style": "comic"
},
{
"id": "mechano-growth-factor",
"topic": "MGF (Mechano Growth Factor) for muscle hypertrophy",
"expected_keywords": ["MGF", "mechano", "muscle", "hypertrophy"],
"min_words": 700,
"style": "comic"
},
{
"id": "ll37-antimicrobial-peptide",
"topic": "LL-37 antimicrobial peptide for immune and skin health",
"expected_keywords": ["LL-37", "antimicrobial", "immune", "peptide"],
"min_words": 700,
"style": "comic"
},
{
"id": "lactoferrin-iron-immunity",
"topic": "Lactoferrin peptide for iron absorption and immune support",
"expected_keywords": ["Lactoferrin", "iron", "immune", "absorption"],
"min_words": 700,
"style": "comic"
},
{
"id": "collagen-peptides-joints",
"topic": "Collagen peptides for joint health and connective tissue repair",
"expected_keywords": ["Collagen", "peptides", "joints", "connective tissue"],
"min_words": 700,
"style": "comic"
},
{
"id": "ckgp-cognitive-longevity",
"topic": "Cognitive peptides and nootropic stacks for brain longevity",
"expected_keywords": ["peptide", "cognitive", "nootropic", "brain"],
"min_words": 700,
"style": "comic"
}
]

View File

@@ -330,6 +330,11 @@ class PRAutoBlogger_Admin_Page {
if ( 'prautoblogger_image_model' === $option_name ) {
return $this->sanitize_image_model( (string) $value );
}
if ( 'prautoblogger_image_style_template' === $option_name ) {
// Multi-line template: validation + textarea-safe sanitisation
// (incl. the single-token check, brief A5) lives in the filler.
return PRAutoBlogger_Image_Template_Filler::sanitize_for_save( (string) $value );
}
return sanitize_text_field( (string) $value );
}

View File

@@ -6,20 +6,25 @@ 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.
* What: Returns a merged list of Runware models (from live catalog sync) and
* OpenRouter models (static list). No API discovery for OpenRouter — updated
* manually when new models are available.
* As of 2026-04-26, registry includes 15 Runware models (live) + 6 OpenRouter
* models (static), ordered cheapest-to-most-expensive within each provider.
* Who calls it: PRAutoBlogger_Admin_Page (model picker UI + save-time provider
* derivation) and PRAutoBlogger_Image_Pipeline (provider lookup).
* Dependencies: None.
* Dependencies: PRAutoBlogger_Runware_Model_Catalog (live Runware models),
* hardcoded fallback lists for resilience.
*
* @see admin/class-settings-fields-extended.php — Image settings reference this registry.
* @see core/class-image-pipeline.php — Derives provider from picked model.
* @see providers/class-runware-image-provider.php — Runware FLUX backend.
* @see providers/class-runware-model-catalog.php — Live model sync and caching.
*/
class PRAutoBlogger_Image_Model_Registry {
/**
* Get all available image generation models.
* Get all available image generation models (Runware live + OpenRouter static).
*
* Each entry contains:
* - id: Model identifier used in API calls.
@@ -35,64 +40,10 @@ class PRAutoBlogger_Image_Model_Registry {
* @return array<int, array<string, mixed>>
*/
public static function get_models(): array {
return array(
array(
'id' => 'runware:100@1',
'name' => 'FLUX.1 schnell (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0006,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Default. Fast, very cheap, comic-friendly fidelity.', 'prautoblogger' ),
),
array(
'id' => 'runware:101@1',
'name' => 'FLUX.1 dev (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.02,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Higher fidelity FLUX. ~30x schnell cost.', 'prautoblogger' ),
),
array(
'id' => 'google/gemini-2.5-flash-image',
'name' => 'Gemini 2.5 Flash Image (Nano Banana)',
'provider' => 'openrouter',
'cost_per_image' => 0.005,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Mid-tier quality, proven.', 'prautoblogger' ),
),
array(
'id' => 'google/gemini-3.1-flash-image-preview',
'name' => 'Gemini 3.1 Flash Image (Nano Banana 2)',
'provider' => 'openrouter',
'cost_per_image' => 0.008,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Latest Google. Better quality.', 'prautoblogger' ),
),
array(
'id' => 'google/gemini-3-pro-image-preview',
'name' => 'Gemini 3 Pro Image (Nano Banana Pro)',
'provider' => 'openrouter',
'cost_per_image' => 0.03,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Highest quality Google. Premium.', 'prautoblogger' ),
),
array(
'id' => 'openai/gpt-5-image-mini',
'name' => 'GPT-5 Image Mini',
'provider' => 'openrouter',
'cost_per_image' => 0.02,
'capabilities' => array( 'image_generation' ),
'description' => __( 'OpenAI budget image model.', 'prautoblogger' ),
),
array(
'id' => 'openai/gpt-5-image',
'name' => 'GPT-5 Image',
'provider' => 'openrouter',
'cost_per_image' => 0.08,
'capabilities' => array( 'image_generation' ),
'description' => __( 'OpenAI premium. Photorealistic.', 'prautoblogger' ),
),
);
$catalog = new PRAutoBlogger_Runware_Model_Catalog();
$runware_models = $catalog->get_models();
$openrouter_models = self::get_openrouter_models();
return array_merge( $runware_models, $openrouter_models );
}
/**
@@ -110,4 +61,204 @@ class PRAutoBlogger_Image_Model_Registry {
}
return '';
}
/**
* Hardcoded Runware model list used as fallback when the live catalog
* sync fails or API is unreachable. Kept in sync with actual Runware
* pricing page (runware.ai/pricing). This is the source of truth for
* pricing data when the Runware API doesn't expose cost information.
*
* Last verified: 2026-04-26. To update: check runware.ai/pricing for new
* text-to-image models and their costs.
*
* @return array<int, array<string, mixed>>
*/
public static function get_runware_fallback_models(): array {
return array(
array(
'id' => 'runware:100@1',
'name' => 'FLUX.1 schnell (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0006,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Default. Fast, very cheap, comic-friendly fidelity.', 'prautoblogger' ),
),
array(
'id' => 'runware:twinflow-z-image-turbo@0',
'name' => 'TwinFlow Z-Image-Turbo (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0006,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Fast text-to-image, same cost as schnell.', 'prautoblogger' ),
),
array(
'id' => 'runware:400@2',
'name' => 'FLUX.2 klein 9B (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.00078,
'capabilities' => array( 'image_generation' ),
'description' => __( 'FLUX.2 compact 9B. Stronger than schnell at similar cost.', 'prautoblogger' ),
),
array(
'id' => 'runware:400@4',
'name' => 'FLUX.2 klein 4B (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0006,
'capabilities' => array( 'image_generation' ),
'description' => __( 'FLUX.2 compact 4B. Newer architecture at schnell price.', 'prautoblogger' ),
),
array(
'id' => 'runware:5@1',
'name' => 'Stable Diffusion 3 (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0013,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Stable Diffusion 3. Sharp text, complex scenes.', 'prautoblogger' ),
),
array(
'id' => 'runware:z-image@turbo',
'name' => 'Z-Image Turbo (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0013,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Fast photorealistic generation.', 'prautoblogger' ),
),
array(
'id' => 'runware:101@1',
'name' => 'FLUX.1 dev (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.02,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Higher fidelity FLUX.1. ~30x schnell cost.', 'prautoblogger' ),
),
array(
'id' => 'runware:97@3',
'name' => 'HiDream-I1 Fast (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0038,
'capabilities' => array( 'image_generation' ),
'description' => __( 'HiDream-I1 Fast. Low latency, 17B quality.', 'prautoblogger' ),
),
array(
'id' => 'runware:97@2',
'name' => 'HiDream-I1 Dev (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0045,
'capabilities' => array( 'image_generation' ),
'description' => __( 'HiDream-I1 Dev. Strong prompt alignment.', 'prautoblogger' ),
),
array(
'id' => 'runware:z-image@0',
'name' => 'Z-Image (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0045,
'capabilities' => array( 'image_generation' ),
'description' => __( 'High-quality Z-Image foundation model.', 'prautoblogger' ),
),
array(
'id' => 'runware:400@1',
'name' => 'FLUX.2 dev (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0051,
'capabilities' => array( 'image_generation' ),
'description' => __( 'FLUX.2 dev. Controllable open text-to-image. From $0.0051.', 'prautoblogger' ),
),
array(
'id' => 'runware:108@1',
'name' => 'Qwen-Image (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0058,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Alibaba Qwen-Image. Strong text rendering in generated images.', 'prautoblogger' ),
),
array(
'id' => 'runware:97@1',
'name' => 'HiDream-I1 Full (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.009,
'capabilities' => array( 'image_generation' ),
'description' => __( 'HiDream-I1 Full. 17B high-fidelity, LoRA support.', 'prautoblogger' ),
),
array(
'id' => 'runware:107@1',
'name' => 'FLUX.1 Krea dev (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0098,
'capabilities' => array( 'image_generation' ),
'description' => __( 'FLUX.1 Krea Dev. Photorealistic open-weight generation.', 'prautoblogger' ),
),
array(
'id' => 'runware:glm-image@0',
'name' => 'GLM-Image (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0225,
'capabilities' => array( 'image_generation' ),
'description' => __( 'GLM-Image. Hybrid autoregressive+diffusion, excellent text rendering.', 'prautoblogger' ),
),
);
}
/**
* Hardcoded OpenRouter image model list. Left static for now because
* OpenRouter's catalog is large (hundreds of models) with noisy filtering.
* Future iteration will add smart filtering strategy (output_modalities,
* pricing, rate limits).
*
* Last verified: 2026-04-26. To update: check openrouter.ai/api/v1/models
* filtering to output_modalities containing 'image'.
*
* @return array<int, array<string, mixed>>
*/
private static function get_openrouter_models(): array {
return array(
array(
'id' => 'google/gemini-2.5-flash-image',
'name' => 'Gemini 2.5 Flash Image (OpenRouter)',
'provider' => 'openrouter',
'cost_per_image' => 0.005,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Mid-tier quality, proven.', 'prautoblogger' ),
),
array(
'id' => 'google/gemini-3.1-flash-image-preview',
'name' => 'Gemini 3.1 Flash Image (OpenRouter)',
'provider' => 'openrouter',
'cost_per_image' => 0.008,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Latest Google Flash image. Better quality.', 'prautoblogger' ),
),
array(
'id' => 'openai/gpt-5-image-mini',
'name' => 'GPT-5 Image Mini (OpenRouter)',
'provider' => 'openrouter',
'cost_per_image' => 0.02,
'capabilities' => array( 'image_generation' ),
'description' => __( 'OpenAI budget image model.', 'prautoblogger' ),
),
array(
'id' => 'openai/gpt-5.4-image-2',
'name' => 'GPT-5.4 Image 2 (OpenRouter)',
'provider' => 'openrouter',
'cost_per_image' => 0.02,
'capabilities' => array( 'image_generation' ),
'description' => __( 'OpenAI GPT-5.4 image generation.', 'prautoblogger' ),
),
array(
'id' => 'google/gemini-3-pro-image-preview',
'name' => 'Gemini 3 Pro Image (OpenRouter)',
'provider' => 'openrouter',
'cost_per_image' => 0.03,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Highest quality Google image. Premium.', 'prautoblogger' ),
),
array(
'id' => 'openai/gpt-5-image',
'name' => 'GPT-5 Image (OpenRouter)',
'provider' => 'openrouter',
'cost_per_image' => 0.08,
'capabilities' => array( 'image_generation' ),
'description' => __( 'OpenAI premium. Photorealistic.', 'prautoblogger' ),
),
);
}
}

View File

@@ -111,10 +111,15 @@ class PRAutoBlogger_Post_List_Columns {
return;
}
echo '<style>'
. '.wp-list-table .column-title{width:35%;word-wrap:break-word;overflow-wrap:break-word}'
. '.column-prab_writing_model{width:100px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}'
. '.column-prab_image_model{width:100px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}'
. '.column-prab_cost{width:55px;white-space:nowrap;overflow:visible}'
. '.wp-list-table .column-title{width:22%}'
. '.column-author{width:6%}'
. '.column-categories{width:7%}'
. '.column-tags{width:9%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}'
. '.column-date{width:8%}'
. '.column-prab_writing_model{width:85px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}'
. '.column-prab_image_model{width:85px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}'
. '.column-prab_cost{width:50px;white-space:nowrap;overflow:visible}'
. '.column-taxonomy-peptide_topic{width:75px}'
. '.prab-cost-wrap{position:relative;display:inline-block}'
. '.prab-cost-trigger{font-size:12px;color:#2271b1;text-decoration:underline;cursor:pointer}'
. '.prab-cost-popover{display:none;position:absolute;right:0;top:100%;z-index:9999;'
@@ -182,8 +187,9 @@ class PRAutoBlogger_Post_List_Columns {
// 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 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
FROM {$table} WHERE post_id = %d ORDER BY id ASC",
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT stage, model, prompt_tokens, completion_tokens, estimated_cost
FROM {$table} WHERE post_id = %d ORDER BY id ASC",
$post_id
),
ARRAY_A

View File

@@ -8,7 +8,7 @@ declare(strict_types=1);
*
* What: Declarative field definitions for operational settings sections.
* Who calls it: PRAutoBlogger_Settings_Fields::get_fields() merges these in.
* Dependencies: PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL, PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX constants,
* Dependencies: PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL, PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE constants,
* PRAutoBlogger_Article_Typography (font choices), PRAutoBlogger_Image_Model_Registry.
*
* @see admin/class-settings-fields.php — Core fields and sections; calls get_extended_fields().
@@ -235,12 +235,12 @@ class PRAutoBlogger_Settings_Fields_Extended {
'icon' => '🔑',
),
array(
'id' => 'prautoblogger_image_style_suffix',
'label' => __( 'Style Suffix', 'prautoblogger' ),
'id' => 'prautoblogger_image_style_template',
'label' => __( 'Style Template', 'prautoblogger' ),
'type' => 'textarea',
'section' => 'prautoblogger_images',
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX,
'description' => __( 'Appended to every image prompt. Controls visual look. Changing mid-run causes visible style drift.', 'prautoblogger' ),
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE,
'description' => __( 'The full image prompt template. Must contain exactly one {{ topic_summary }} token, which is filled with a 1-2 sentence topic/mechanism summary per article. The rest is brand-locked editorial style. Replaces the old Style Suffix (now deprecated). Changing mid-run causes visible style drift.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_image_prompt_instructions',
@@ -248,7 +248,7 @@ class PRAutoBlogger_Settings_Fields_Extended {
'type' => 'textarea',
'section' => 'prautoblogger_images',
'default' => PRAutoBlogger_Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT,
'description' => __( 'System prompt given to the rewriter LLM that turns each article into a SCENE + CAPTION for the image generator. Changing this reshapes the look of all future images. Leave blank to use the default.', 'prautoblogger' ),
'description' => __( 'System prompt given to the rewriter LLM that turns each article into a SCENE (a 1-2 sentence editorial topic/mechanism summary, substituted into the Style Template) plus a CAPTION (HTML text shown below the image). Changing this reshapes the look of all future images. Leave blank to use the default.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_image_nsfw_retry',
@@ -258,6 +258,13 @@ class PRAutoBlogger_Settings_Fields_Extended {
'default' => '1',
'description' => __( 'When the provider rejects an image prompt as NSFW, retry once with a generic fallback scene built from the article title. Disable to fail fast if the filter gets trigger-happy.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_runware_model_catalog',
'label' => __( 'Runware Model Catalog', 'prautoblogger' ),
'type' => 'runware_catalog_sync',
'section' => 'prautoblogger_images',
'description' => __( 'Live model catalog sync status and on-demand refresh.', 'prautoblogger' ),
),
);
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
/**
* WP-CLI commands for PRAutoBlogger.
*
* Registers custom wp-cli commands exposed to the plugin.
*/
class PRAutoBlogger_WP_CLI_Commands {
/**
* Register WP-CLI commands.
*
* Called on plugins_loaded hook.
*/
public static function register(): void {
if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {
return;
}
// Command: wp prautoblogger opik:eval
\WP_CLI::add_command(
'prautoblogger opik:eval',
array( self::class, 'opik_eval_command' ),
array(
'shortdesc' => 'Run Opik evals on the frozen dataset',
'synopsis' => array(
array(
'type' => 'assoc',
'name' => 'limit',
'description' => 'Max items to run (0 = all)',
'optional' => true,
'default' => '0',
),
array(
'type' => 'flag',
'name' => 'dry-run',
'description' => 'Skip Opik API push; score locally only',
'optional' => true,
),
),
)
);
}
/**
* Run eval on the frozen dataset.
*
* @param array $args Positional args (unused).
* @param array $assoc_args Associative args: limit, dry-run.
*
* @return void
*/
public static function opik_eval_command( array $args, array $assoc_args ): void {
// Check if Opik is enabled.
if ( empty( PRAUTOBLOGGER_OPIK_API_KEY ) || empty( PRAUTOBLOGGER_OPIK_WORKSPACE ) ) {
if ( ! isset( $assoc_args['dry-run'] ) ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_echo -- CLI output
echo "Warning: Opik is not configured. Running with --dry-run only.\n";
$assoc_args['dry-run'] = true;
}
}
$limit = absint( $assoc_args['limit'] ?? 0 );
$dry_run = isset( $assoc_args['dry-run'] );
$runner = new PRAutoBlogger_Opik_Eval_Runner();
$result = $runner->run( $limit, $dry_run );
exit( $result['items_run'] > 0 ? 0 : 1 );
}
}

View File

@@ -130,6 +130,11 @@ class PRAutoBlogger_Activator {
wp_schedule_event( $tomorrow, 'daily', 'prautoblogger_reap_orphan_research_rows' );
}
}
// v0.15.0: daily Runware model catalog sync.
if ( ! wp_next_scheduled( 'prautoblogger_sync_runware_models' ) ) {
wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'prautoblogger_sync_runware_models' );
}
}
/**

View File

@@ -151,6 +151,53 @@ class PRAutoBlogger_Ajax_Handlers {
wp_send_json_success( $results );
}
/**
* AJAX handler: sync Runware model catalog on demand.
*
* Nonce-protected and requires manage_options capability.
* Returns the updated cache timestamp and model count.
*
* Side effects: makes HTTP request to Runware API, writes WP options.
*/
public function on_ajax_sync_runware_models_now(): void {
check_ajax_referer( 'prautoblogger_sync_runware_models_now', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
return;
}
try {
$catalog = new PRAutoBlogger_Runware_Model_Catalog();
$success = $catalog->sync();
if ( ! $success ) {
wp_send_json_error( array( 'message' => __( 'Catalog sync failed. Check logs for details.', 'prautoblogger' ) ) );
return;
}
$models = $catalog->get_models();
$synced_at = $catalog->get_last_synced_at();
$synced_at_str = $synced_at
? wp_date( __( 'Y-m-d H:i:s', 'prautoblogger' ), $synced_at )
: __( 'Never', 'prautoblogger' );
wp_send_json_success(
array(
'message' => __( 'Runware model catalog synced successfully.', 'prautoblogger' ),
'model_count' => count( $models ),
'synced_at' => $synced_at_str,
)
);
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->error(
sprintf( 'AJAX sync Runware models failed: %s (%s)', $e->getMessage(), get_class( $e ) ),
'runware-catalog'
);
wp_send_json_error( array( 'message' => $e->getMessage() ) );
}
}
/**
* Get the shared model registry instance.
*

1
includes/class-deactivator.php Executable file → Normal file
View File

@@ -39,6 +39,7 @@ class PRAutoBlogger_Deactivator {
'prautoblogger_daily_generation',
'prautoblogger_collect_metrics',
'prautoblogger_reap_orphan_research_rows',
'prautoblogger_sync_runware_models',
);
foreach ( $hooks as $hook ) {

View File

@@ -55,6 +55,7 @@ class PRAutoBlogger {
$this->register_cron_hooks();
$this->register_frontend_hooks();
$this->register_ajax_hooks();
$this->register_cli_hooks();
/** Fires after PRAutoBlogger has finished registering all hooks. */
do_action( 'prautoblogger_loaded' );
@@ -115,6 +116,8 @@ class PRAutoBlogger {
$registry = $this->executor->get_model_registry();
add_action( 'prautoblogger_refresh_model_registry', array( $registry, 'refresh' ) );
// v0.15.0: daily Runware model catalog sync to keep the live model list fresh.
add_action( 'prautoblogger_sync_runware_models', array( new PRAutoBlogger_Runware_Model_Catalog(), 'sync' ) );
// Opik observability: async trace/span dispatch.
add_action(
@@ -178,6 +181,7 @@ class PRAutoBlogger {
add_action( 'wp_ajax_prautoblogger_test_connection', array( $this->ajax_handlers, 'on_ajax_test_connection' ) );
add_action( 'wp_ajax_prautoblogger_get_models', array( $this->ajax_handlers, 'on_ajax_get_models' ) );
add_action( 'wp_ajax_prautoblogger_refresh_models', array( new PRAutoBlogger_Model_Registry_Refresh( $this->ajax_handlers->get_registry() ), 'handle' ) );
add_action( 'wp_ajax_prautoblogger_sync_runware_models_now', array( $this->ajax_handlers, 'on_ajax_sync_runware_models_now' ) );
$review_queue = new PRAutoBlogger_Review_Queue();
add_action( 'wp_ajax_prautoblogger_approve_post', array( $review_queue, 'on_ajax_approve_post' ) );
@@ -263,6 +267,24 @@ class PRAutoBlogger {
update_option( 'prautoblogger_migrated_style_suffix_v4', '1' );
}
// One-time migration (v0.16.0): editorial pivot. The comic Style Suffix
// is replaced by the editorial Style Template. Mirror the old suffix
// value into a deprecated-keyed option for one version cycle (so a
// customised value is recoverable / auditable) and seed the new
// template with its editorial default. The old option is intentionally
// NOT deleted this cycle. See docs/proposals/2026-05-29-image-pipeline-in-plugin-brief.md §8 Commit 1.
if ( ! get_option( 'prautoblogger_migrated_style_template_v0160' ) ) {
$old_suffix = get_option( 'prautoblogger_image_style_suffix', '' );
if ( '' !== $old_suffix && false === get_option( 'prautoblogger_image_style_suffix_deprecated', false ) ) {
update_option( 'prautoblogger_image_style_suffix_deprecated', $old_suffix );
}
// Only seed the new template if the admin has not already set one.
if ( '' === (string) get_option( 'prautoblogger_image_style_template', '' ) ) {
update_option( 'prautoblogger_image_style_template', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE );
}
update_option( 'prautoblogger_migrated_style_template_v0160', '1' );
}
// v0.9.0 — Runware as default image model. v0.10.0 — remove CF Workers AI.
PRAutoBlogger_Activator::migrate_default_image_v090();
PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
@@ -294,6 +316,11 @@ class PRAutoBlogger {
return $schedules;
}
/** Register WP-CLI commands. */
private function register_cli_hooks(): void {
PRAutoBlogger_WP_CLI_Commands::register();
}
/**
* Block false update notifications from wordpress.org.
* Our plugin slug may collide with a different plugin in the directory.

View File

@@ -138,13 +138,13 @@ class PRAutoBlogger_Article_Worker {
: $content;
if ( $auto_publish ) {
$publisher->publish( $final, $idea, $review, $run_id );
$publisher->publish( $final, $idea, $review, $run_id, $this->cost_tracker );
++$result['published'];
} else {
$publisher->save_as_draft( $final, $idea, $review, $run_id );
$publisher->save_as_draft( $final, $idea, $review, $run_id, $this->cost_tracker );
}
} else {
$publisher->save_as_draft( $content, $idea, $review, $run_id );
$publisher->save_as_draft( $content, $idea, $review, $run_id, $this->cost_tracker );
++$result['rejected'];
PRAutoBlogger_Logger::instance()->info(
'Article rejected by editor: ' . $idea->get_topic(),

View File

@@ -29,7 +29,10 @@ class PRAutoBlogger_Content_Generator {
* @param PRAutoBlogger_Article_Idea $idea The scored idea to generate content for.
* @return string Generated HTML content.
*/
public function generate( PRAutoBlogger_Article_Idea $idea ): string {
public function generate( PRAutoBlogger_Article_Idea $idea, bool $eval_mode = false ): string {
// In eval mode, suppress publishing and image generation side effects.
define( 'PRAUTOBLOGGER_EVAL_MODE', $eval_mode );
$mode = get_option( 'prautoblogger_writing_pipeline', 'multi_step' );
$request = new PRAutoBlogger_Content_Request(
@@ -285,4 +288,14 @@ class PRAutoBlogger_Content_Generator {
return $response['content'];
}
/**
* Generate content in eval mode (no publishing, no image generation).
*
* @param PRAutoBlogger_Article_Idea $idea The scored idea to generate content for.
* @return string Generated HTML content.
*/
public function generate_eval( PRAutoBlogger_Article_Idea $idea ): string {
return $this->generate( $idea, true );
}
}

View File

@@ -235,19 +235,19 @@ class PRAutoBlogger_Cost_Tracker {
);
}
$cutoff_time = time() - ( $days * DAY_IN_SECONDS );
$stage_list = implode( ',', array_map( array( $wpdb, 'prepare' ), array_fill( 0, count( $stages ), '%s' ), $stages ) );
$table_name = $wpdb->prefix . 'prautoblogger_generation_log';
$cutoff_datetime = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) );
$stage_list = implode( ',', array_map( array( $wpdb, 'prepare' ), array_fill( 0, count( $stages ), '%s' ), $stages ) );
$table_name = $wpdb->prefix . 'prautoblogger_generation_log';
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Prepared stage list via array_map + prepare
$query = $wpdb->prepare(
"SELECT AVG(input_tokens) as avg_input,
AVG(output_tokens) as avg_output,
"SELECT AVG(prompt_tokens) as avg_prompt,
AVG(completion_tokens) as avg_completion,
COUNT(*) as sample_count
FROM $table_name
WHERE stage IN ( $stage_list )
AND timestamp >= %d",
$cutoff_time
AND created_at >= %s",
$cutoff_datetime
);
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- phpcs pragma above
@@ -262,8 +262,8 @@ class PRAutoBlogger_Cost_Tracker {
}
return array(
'avg_prompt_tokens' => (float) ( $result['avg_input'] ?? 0 ),
'avg_completion_tokens' => (float) ( $result['avg_output'] ?? 0 ),
'avg_prompt_tokens' => (float) ( $result['avg_prompt'] ?? 0 ),
'avg_completion_tokens' => (float) ( $result['avg_completion'] ?? 0 ),
'sample_size' => (int) ( $result['sample_count'] ?? 0 ),
);
}

View File

@@ -47,12 +47,14 @@ class PRAutoBlogger_Image_Pipeline {
*/
public function __construct(
?PRAutoBlogger_Image_Provider_Interface $provider = null,
?PRAutoBlogger_Cost_Tracker $cost_tracker = null
?PRAutoBlogger_Cost_Tracker $cost_tracker = null,
?PRAutoBlogger_Opik_Trace_Context $trace_context = null
) {
$this->provider = $provider ?? self::create_default_provider();
$this->prompt_builder = new PRAutoBlogger_Image_Prompt_Builder();
$this->prompt_builder = new PRAutoBlogger_Image_Prompt_Builder( $trace_context );
$this->sideloader = new PRAutoBlogger_Image_Media_Sideloader();
$this->cost_tracker = $cost_tracker ?? new PRAutoBlogger_Cost_Tracker();
$this->trace_context = $trace_context;
}
/**

View File

@@ -6,13 +6,16 @@ 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
* Uses a cheap LLM (via OpenRouter) to distill article content into a concise
* 1-2 sentence editorial topic/mechanism summary, then substitutes that summary
* into the admin-editable editorial style template (the {{ topic_summary }}
* slot) to produce a text-free image-gen prompt. Falls back to rule-based
* synthesis if the LLM call fails, so image generation never blocks on a
* prompt-rewriting outage.
*
* Triggered by: PRAutoBlogger_Image_Pipeline during content generation run.
* Dependencies: PRAutoBlogger_OpenRouter_Provider (LLM call),
* PRAutoBlogger_Image_Template_Filler (template substitution),
* PRAutoBlogger_Cost_Tracker (logs prompt-rewrite cost),
* PRAutoBlogger_Logger (diagnostics).
*
@@ -27,34 +30,39 @@ class PRAutoBlogger_Image_Prompt_Builder {
* prompts. Exposed as a `public const` so the admin-settings layer can
* use it as the default value for `prautoblogger_image_prompt_instructions`.
* The option, when non-empty, wins at call time (see rewrite_via_llm).
*
* Editorial pivot (v0.16.0): the model emits a concise topic/mechanism
* summary as the SCENE — concrete, single centered focal subject, no text,
* no people-as-gag, no logos — which the prompt builder substitutes into
* the editorial style template's {{ topic_summary }} slot. The CAPTION line
* is preserved unchanged for the existing HTML-caption-below-image path.
*/
public const REWRITER_SYSTEM_PROMPT = <<<'PROMPT'
You are a comedy writer and single-panel cartoon creator, like Gary Larson (The Far Side) meets science humor.
You write subject descriptions for a text-free editorial science illustration about peptides, supplements, or biohacking.
Given an article title and summary about peptides, supplements, or biohacking, create a SINGLE-PANEL COMIC concept. Output TWO parts separated by a blank line:
Given an article title and summary, output TWO parts separated by a blank line:
SCENE: One sentence describing the visual gag — a funny, absurd, or ironic situation related to the article's topic. Favor CONCRETE, HUMAN-SCALE scenes: people reacting to things, doctors and patients, scientists in a lab, gym bros with peptide vials, partners at a kitchen table, ordinary objects in ordinary rooms. Keep it simple — one clear visual joke, 1-3 characters max.
SCENE: A concise 1-2 sentence description of the article's core topic or mechanism, written as the SUBJECT of a single editorial illustration. Name ONE concrete, centered focal subject and the few supporting visual elements around it (e.g. a labelled-free vial and dropper, a stylised receptor on a cell membrane, a molecular chain, a dosing syringe, an organ cross-section). Favor a clear central focal point with margin around it so the image crops well. This is image-gen direction, not prose.
CAPTION: A short, punchy caption or speech bubble line (under 15 words) that delivers the punchline. The humor should be dry, nerdy, and accessible — the reader should chuckle even if they only half-understand the science.
CAPTION: A short, informative caption line (under 15 words) for display as HTML text below the image. Plain and editorial, not a joke.
Rules:
- The joke MUST relate to the article's actual subject matter, not generic science humor
- DO NOT personify molecules, peptides, hormones, proteins, or other chemical structures (no "anthropomorphic molecule", no "cartoon hormone"). Image models render anthropomorphized chemistry as incoherent blobs. If the article is about a substance, show a PERSON reacting to or using the substance; if the article is abstract (a mechanism, a process, a concept), fall back on a human observer reacting to its effect.
- Characters can have simple cartoon faces (round heads, dot eyes, expressive eyebrows)
- Keep the scene physically simple — few objects, clear staging, easy to read at thumbnail size
- The caption should work as a standalone joke even without the image
- No logos, watermarks, or branding
- The SCENE must depict the article's actual subject/mechanism, not a generic science scene.
- NO text, words, captions, speech bubbles, labels, or logos described in the SCENE — the illustration is text-free; text is rendered separately.
- NO people used as a visual gag and NO cartoon/comic framing. A human anatomical element (a cell, an organ, a hand holding a vial) is fine if it is the legitimate subject.
- Do NOT personify molecules, peptides, hormones, or proteins (no "smiling molecule"); depict them as clean stylised diagrams or structures.
- Keep the staging simple with one clear focal subject, readable at small sizes.
- Output ONLY the scene and caption. No preamble, no explanation.
Example output format:
A muscular gym bro stares, deflated, at a tiny glass vial in his hand while his friend squats a barbell effortlessly in the background.
A single glass peptide vial on a clean surface with a fine dropper above it, a faint stylised molecular chain arcing behind as a backdrop, centered with generous negative space.
"They told me the peptide did the work, not the squats."
A concentrated look at how the compound is prepared before use.
PROMPT;
/**
* Max tokens for the rewriter response. Comic concepts need more room
* for the scene description plus the caption punchline.
* Max tokens for the rewriter response. Enough room for the 1-2 sentence
* topic/mechanism summary plus the short editorial caption line.
*/
private const REWRITER_MAX_TOKENS = 180;
@@ -65,12 +73,29 @@ PROMPT;
*/
private ?PRAutoBlogger_OpenRouter_Provider $llm = null;
/**
* Opik trace context for instrumentation.
*
* @var PRAutoBlogger_Opik_Trace_Context|null
*/
private ?PRAutoBlogger_Opik_Trace_Context $trace_context = null;
/**
* Constructor.
*
* @param PRAutoBlogger_Opik_Trace_Context|null $trace_context Optional trace context for instrumentation.
*/
public function __construct( ?PRAutoBlogger_Opik_Trace_Context $trace_context = null ) {
$this->trace_context = $trace_context;
}
/**
* Build a visual prompt from finished article content.
*
* Tries LLM rewriting first; falls back to rule-based synthesis on
* failure. Splits scene (for image gen) from caption (HTML below the
* image) and appends the style suffix.
* failure. Splits scene (the topic summary, for image gen) from caption
* (HTML below the image) and substitutes the scene into the editorial
* style template's {{ topic_summary }} slot.
*
* @param array{post_title?: string, post_content?: string, suggested_title?: string} $article_data
* @return array{prompt: string, caption: string}
@@ -78,14 +103,12 @@ PROMPT;
public function build_article_prompt( array $article_data ): array {
$title = $article_data['post_title'] ?? $article_data['suggested_title'] ?? 'Product';
$content = $article_data['post_content'] ?? '';
$first_para = $this->extract_first_paragraph( $content );
$first_para = PRAutoBlogger_Image_Scene_Parser::extract_first_paragraph( $content );
$parsed = $this->rewrite_via_llm( $title, $first_para );
$style_suffix = get_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
return array(
'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ),
'prompt' => PRAutoBlogger_Image_Template_Filler::fill( $parsed['scene'] ),
'caption' => $parsed['caption'],
);
}
@@ -104,10 +127,8 @@ PROMPT;
$parsed = $this->rewrite_via_llm( $title, $context );
$style_suffix = get_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
return array(
'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ),
'prompt' => PRAutoBlogger_Image_Template_Filler::fill( $parsed['scene'] ),
'caption' => $parsed['caption'],
);
}
@@ -142,7 +163,24 @@ PROMPT;
$user_message .= "\n\nSummary: {$context}";
}
$span_id = null;
try {
if ( null !== $this->trace_context ) {
$span_id = $this->trace_context->start_span(
array(
'name' => 'image_prompt_rewrite',
'type' => 'llm',
'model' => get_option( 'prautoblogger_analysis_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL ),
'provider' => 'openrouter',
'input' => array(
'title' => $title,
'context' => substr( $context, 0, 200 ),
),
)
);
}
$llm = $this->get_llm_provider();
$model = get_option( 'prautoblogger_analysis_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL );
$system = $this->resolve_system_prompt();
@@ -188,8 +226,24 @@ PROMPT;
'image_prompt_builder'
);
if ( null !== $span_id && null !== $this->trace_context ) {
$this->trace_context->end_span(
$span_id,
array(
'output' => array(
'raw_response' => substr( $raw, 0, 200 ),
),
'usage' => array(
'prompt_tokens' => $result['prompt_tokens'] ?? 0,
'completion_tokens' => $result['completion_tokens'] ?? 0,
'total_tokens' => ( $result['prompt_tokens'] ?? 0 ) + ( $result['completion_tokens'] ?? 0 ),
),
)
);
}
if ( '' !== $raw ) {
return $this->parse_scene_and_caption( $raw );
return PRAutoBlogger_Image_Scene_Parser::parse_scene_and_caption( $raw );
}
} catch ( \Throwable $e ) {
// LLM failure is not fatal — fall back to rule-based synthesis.
@@ -199,64 +253,7 @@ PROMPT;
);
}
return $this->synthesize_visual_concepts_fallback( $title, $context );
}
/**
* Parse the LLM response into separate scene and caption parts.
*
* Expected format from the LLM:
* Scene description text here.
*
* "Caption punchline here."
*
* Falls back gracefully: if no blank-line separator is found, the entire
* response becomes the scene with an empty caption.
*
* @param string $raw Raw LLM response text.
* @return array{scene: string, caption: string}
*/
private function parse_scene_and_caption( string $raw ): array {
// Split on blank line (the format the system prompt requests).
$parts = preg_split( '/\n\s*\n/', $raw, 2 );
if ( count( $parts ) >= 2 ) {
$scene = trim( $parts[0] );
$caption = trim( $parts[1] );
// Strip surrounding quotes from caption — the LLM wraps it in quotes.
$caption = trim( $caption, '"\'' );
return array(
'scene' => $scene,
'caption' => $caption,
);
}
// No blank-line separator found — treat entire response as scene.
return array(
'scene' => trim( $raw ),
'caption' => '',
);
}
/**
* Extract the first paragraph from HTML content.
*
* Removes all tags and captures the text up to the first double-newline
* or 200 characters, whichever comes first.
*
* @param string $html HTML content.
* @return string Plain text of the first paragraph.
*/
private function extract_first_paragraph( string $html ): string {
$text = wp_strip_all_tags( $html );
$paras = preg_split( '/\n\n+/', $text );
$para = isset( $paras[0] ) ? trim( $paras[0] ) : '';
if ( strlen( $para ) > 200 ) {
$para = substr( $para, 0, 200 ) . '...';
}
return $para;
return PRAutoBlogger_Image_Scene_Parser::synthesize_visual_concepts_fallback( $title, $context );
}
/**
@@ -268,36 +265,13 @@ PROMPT;
* @return array{prompt: string, caption: string}
*/
public function build_fallback_prompt( string $title ): array {
$parsed = $this->synthesize_visual_concepts_fallback( $title, '' );
$style_suffix = get_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
$parsed = PRAutoBlogger_Image_Scene_Parser::synthesize_visual_concepts_fallback( $title, '' );
return array(
'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ),
'prompt' => PRAutoBlogger_Image_Template_Filler::fill( $parsed['scene'] ),
'caption' => $parsed['caption'],
);
}
/**
* Rule-based fallback when LLM rewriting is unavailable.
*
* Kept deliberately simple — this only runs if OpenRouter is down.
*
* @param string $title Main heading / topic.
* @param string $supporting_text Additional context.
* @return array{scene: string, caption: string} Scene for image gen, caption for HTML.
*/
private function synthesize_visual_concepts_fallback( string $title, string $supporting_text ): array {
// Fallback produces a simple comic concept when the LLM is unavailable.
$scene = sprintf(
'A cartoon scientist in a lab coat looking bewildered while examining something related to: %s.',
$title
);
return array(
'scene' => trim( $scene ),
'caption' => 'Science is full of surprises.',
);
}
/** Lazy-load the OpenRouter provider. */
private function get_llm_provider(): PRAutoBlogger_OpenRouter_Provider {
if ( null === $this->llm ) {

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
/**
* Parses LLM output into scene/caption pairs and synthesises fallback prompts
* from article titles when the LLM call fails.
*
* Who calls it: PRAutoBlogger_Image_Prompt_Builder (static calls).
* Dependencies: None.
*/
class PRAutoBlogger_Image_Scene_Parser {
/**
* Parse the LLM response into separate scene and caption parts.
*
* Expected format from the LLM:
* Scene description text here.
*
* "Caption punchline here."
*
* Falls back gracefully: if no blank-line separator is found, the entire
* response becomes the scene with an empty caption.
*
* @param string $raw Raw LLM response text.
* @return array{scene: string, caption: string}
*/
public static function parse_scene_and_caption( string $raw ): array {
// Split on blank line (the format the system prompt requests).
$parts = preg_split( '/\n\s*\n/', $raw, 2 );
if ( count( $parts ) >= 2 ) {
$scene = trim( $parts[0] );
$caption = trim( $parts[1] );
// Strip surrounding quotes from caption — the LLM wraps it in quotes.
$caption = trim( $caption, '"\'' );
return array(
'scene' => $scene,
'caption' => $caption,
);
}
// No blank-line separator found — treat entire response as scene.
return array(
'scene' => trim( $raw ),
'caption' => '',
);
}
/**
* Extract the first paragraph from HTML content.
*
* Removes all tags and captures the text up to the first double-newline
* or 200 characters, whichever comes first.
*
* @param string $html HTML content.
* @return string Plain text of the first paragraph.
*/
public static function extract_first_paragraph( string $html ): string {
$text = wp_strip_all_tags( $html );
$paras = preg_split( '/\n\n+/', $text );
$para = isset( $paras[0] ) ? trim( $paras[0] ) : '';
if ( strlen( $para ) > 200 ) {
$para = substr( $para, 0, 200 ) . '...';
}
return $para;
}
/**
* Rule-based fallback when LLM rewriting is unavailable.
*
* Kept deliberately simple — this only runs if OpenRouter is down. Produces
* a text-free editorial-illustration subject (not a comic) so the prompt
* builder always has a non-empty topic summary to substitute into the
* style template, never shipping a blank prompt to the provider (brief A6).
*
* @param string $title Main heading / topic.
* @param string $supporting_text Additional context.
* @return array{scene: string, caption: string} Scene for image gen, caption for HTML.
*/
public static function synthesize_visual_concepts_fallback( string $title, string $supporting_text ): array {
// Fallback produces a simple editorial-illustration subject when the LLM is unavailable.
$scene = sprintf(
'A clean, centered editorial science illustration representing the topic of %s, with a single clear focal subject and generous negative space.',
$title
);
return array(
'scene' => trim( $scene ),
'caption' => '',
);
}
}

View File

@@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Fills the admin-editable editorial image-prompt template with the
* rewriter-produced topic summary, replacing the old "scene + style suffix"
* concatenation.
*
* The template (option `prautoblogger_image_style_template`, default
* PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE) contains exactly one
* `{{ topic_summary }}` token. This class:
* - resolves the active template (admin override wins; blank → default),
* - sanitises and length-clamps the topic summary (brief A5),
* - substitutes the token, and
* - degrades safely (brief A5/A6) so a blank or token-only prompt is never
* emitted to the image provider.
*
* Who calls it: PRAutoBlogger_Image_Prompt_Builder (static call from each of
* build_article_prompt / build_source_prompt / build_fallback_prompt).
* Dependencies: PRAutoBlogger_Logger (diagnostics only).
*
* @see core/class-image-prompt-builder.php — Sole caller.
* @see ARCHITECTURE.md — Image generation data flow.
*/
class PRAutoBlogger_Image_Template_Filler {
/** The single substitution token the template must contain. */
public const TOKEN = '{{ topic_summary }}';
/**
* Max characters retained from the rewriter topic summary before it is
* substituted into the template. Keeps the final provider prompt bounded
* even if the rewriter ignores its length guidance.
*/
public const MAX_SUMMARY_CHARS = 320;
/**
* Build the final image-generation prompt from a rewriter scene.
*
* @param string $scene Raw topic/mechanism summary from the rewriter (the
* "scene" half of the scene/caption contract).
* @return string Provider-ready prompt; never empty.
*/
public static function fill( string $scene ): string {
$template = self::resolve_template();
$summary = self::sanitize_summary( $scene );
// Brief A6: never ship a blank or token-only prompt. If the rewriter
// produced nothing usable, return the template with the token removed
// only as a last resort — but the caller guards this by passing a
// rule-based fallback scene, so an empty summary here is unexpected.
if ( '' === $summary ) {
PRAutoBlogger_Logger::instance()->warning(
'Editorial prompt fill received an empty topic summary; emitting style-only prompt.',
'image_template_filler'
);
return self::strip_token( $template );
}
$token_count = substr_count( $template, self::TOKEN );
if ( 1 === $token_count ) {
return trim( str_replace( self::TOKEN, $summary, $template ) );
}
// Brief A5: admin removed or duplicated the token. Fall back to
// appending the summary to the style text and log a warning rather
// than shipping a malformed prompt.
PRAutoBlogger_Logger::instance()->warning(
sprintf(
'Image style template has %d "%s" tokens (expected 1); appending summary instead of substituting.',
$token_count,
self::TOKEN
),
'image_template_filler'
);
$base = self::strip_token( $template );
return trim( $base . ' ' . $summary );
}
/**
* Sanitise and validate the admin-submitted style template on save.
*
* Multi-line, so sanitised with sanitize_textarea_field() (not
* sanitize_text_field(), which would strip newlines). Per brief A5 the
* template must contain exactly one TOKEN; otherwise the previous value is
* kept and a settings error is surfaced. A blank submission falls back to
* the editorial default so image generation can never be bricked.
*
* @param string $value Submitted template.
* @return string Sanitised, validated template.
*/
public static function sanitize_for_save( string $value ): string {
$clean = sanitize_textarea_field( $value );
if ( '' === trim( $clean ) ) {
return PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE;
}
$token_count = substr_count( $clean, self::TOKEN );
if ( 1 !== $token_count ) {
add_settings_error(
'prautoblogger_image_style_template',
'prautoblogger_image_style_template_token',
sprintf(
/* translators: 1: required token, 2: number of tokens found. */
esc_html__( 'The Style Template must contain exactly one %1$s token (found %2$d). Keeping the previous value.', 'prautoblogger' ),
esc_html( self::TOKEN ),
(int) $token_count
)
);
return (string) get_option( 'prautoblogger_image_style_template', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE );
}
return $clean;
}
/**
* Resolve the active template: admin override wins; blank → default.
*
* @return string Non-empty template string.
*/
private static function resolve_template(): string {
$override = (string) get_option( 'prautoblogger_image_style_template', '' );
if ( '' !== trim( $override ) ) {
return $override;
}
return PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE;
}
/**
* Strip control characters and clamp length of the rewriter summary
* (brief A5). Whitespace runs (including newlines) are collapsed to single
* spaces so the summary sits cleanly inside the template.
*
* @param string $scene Raw rewriter scene.
* @return string Sanitised, length-clamped summary (possibly empty).
*/
private static function sanitize_summary( string $scene ): string {
// Remove control characters (C0/C1) except via whitespace collapse below.
$clean = preg_replace( '/[\x00-\x1F\x7F]+/u', ' ', $scene );
if ( null === $clean ) {
// preg_replace can return null on bad UTF-8; degrade conservatively.
$clean = preg_replace( '/[\x00-\x1F\x7F]+/', ' ', $scene );
$clean = is_string( $clean ) ? $clean : '';
}
// Collapse whitespace runs to single spaces.
$collapsed = preg_replace( '/\s+/u', ' ', $clean );
$clean = is_string( $collapsed ) ? $collapsed : $clean;
$clean = trim( $clean );
if ( '' === $clean ) {
return '';
}
// Clamp length (multibyte-aware where available).
if ( function_exists( 'mb_strlen' ) ) {
if ( mb_strlen( $clean ) > self::MAX_SUMMARY_CHARS ) {
$clean = rtrim( mb_substr( $clean, 0, self::MAX_SUMMARY_CHARS ) ) . '...';
}
} elseif ( strlen( $clean ) > self::MAX_SUMMARY_CHARS ) {
$clean = rtrim( substr( $clean, 0, self::MAX_SUMMARY_CHARS ) ) . '...';
}
return $clean;
}
/**
* Remove every occurrence of the token from a template, tidying the
* leftover whitespace so the residual style text is still well-formed.
*
* @param string $template Template possibly containing the token.
* @return string Token-free, trimmed template.
*/
private static function strip_token( string $template ): string {
$stripped = str_replace( self::TOKEN, '', $template );
$stripped = preg_replace( '/[ \t]+/', ' ', $stripped );
$stripped = is_string( $stripped ) ? $stripped : $template;
return trim( $stripped );
}
}

View File

@@ -232,11 +232,12 @@ class PRAutoBlogger_Post_Assembler {
* Generate and attach images to a published post.
* Non-blocking — logs errors but doesn't re-throw since the post is already created.
*
* @param int $post_id Post ID.
* @param PRAutoBlogger_Article_Idea $idea Article idea (contains source IDs).
* @param array $post_data Post data array (for article content).
* @param int $post_id Post ID.
* @param PRAutoBlogger_Article_Idea $idea Article idea (contains source IDs).
* @param array $post_data Post data array (for article content).
* @param ?PRAutoBlogger_Cost_Tracker $cost_tracker Optional cost tracker for image generation cost logging.
*/
public static function attach_generated_images( int $post_id, PRAutoBlogger_Article_Idea $idea, array $post_data ): void {
public static function attach_generated_images( int $post_id, PRAutoBlogger_Article_Idea $idea, array $post_data, ?PRAutoBlogger_Cost_Tracker $cost_tracker = null ): void {
// Fetch the original Reddit source data for Image B's source-driven prompt.
// The idea's source_ids point to rows in the source_data table collected
// during the pipeline's collection step.
@@ -247,7 +248,7 @@ class PRAutoBlogger_Post_Assembler {
// internally, immediately after each image generates. This
// ensures attachment persists even if the process times out
// before this method returns.
$result = ( new PRAutoBlogger_Image_Pipeline() )->generate_and_attach_images( $post_id, $post_data, $source_data );
$result = ( new PRAutoBlogger_Image_Pipeline( null, $cost_tracker ) )->generate_and_attach_images( $post_id, $post_data, $source_data );
if ( ! empty( $result['errors'] ) ) {
foreach ( $result['errors'] as $error ) {

View File

@@ -28,6 +28,7 @@ class PRAutoBlogger_Publisher {
* @param PRAutoBlogger_Article_Idea $idea The original article idea.
* @param PRAutoBlogger_Editorial_Review $review The editor's review.
* @param string|null $run_id Pipeline run ID for log linking.
* @param ?PRAutoBlogger_Cost_Tracker $cost_tracker Optional cost tracker for image generation cost logging.
* @return int The created post ID.
* @throws \RuntimeException If post creation fails.
*/
@@ -35,9 +36,10 @@ class PRAutoBlogger_Publisher {
string $content,
PRAutoBlogger_Article_Idea $idea,
PRAutoBlogger_Editorial_Review $review,
?string $run_id = null
?string $run_id = null,
?PRAutoBlogger_Cost_Tracker $cost_tracker = null
): int {
return $this->create_post( $content, $idea, $review, 'publish', $run_id );
return $this->create_post( $content, $idea, $review, 'publish', $run_id, $cost_tracker );
}
/**
@@ -47,6 +49,7 @@ class PRAutoBlogger_Publisher {
* @param PRAutoBlogger_Article_Idea $idea The original article idea.
* @param PRAutoBlogger_Editorial_Review $review The editor's review with rejection notes.
* @param string|null $run_id Pipeline run ID for log linking.
* @param ?PRAutoBlogger_Cost_Tracker $cost_tracker Optional cost tracker for image generation cost logging.
* @return int The created post ID.
* @throws \RuntimeException If post creation fails.
*/
@@ -54,9 +57,10 @@ class PRAutoBlogger_Publisher {
string $content,
PRAutoBlogger_Article_Idea $idea,
PRAutoBlogger_Editorial_Review $review,
?string $run_id = null
?string $run_id = null,
?PRAutoBlogger_Cost_Tracker $cost_tracker = null
): int {
return $this->create_post( $content, $idea, $review, 'draft', $run_id );
return $this->create_post( $content, $idea, $review, 'draft', $run_id, $cost_tracker );
}
/**
@@ -75,7 +79,8 @@ class PRAutoBlogger_Publisher {
PRAutoBlogger_Article_Idea $idea,
PRAutoBlogger_Editorial_Review $review,
string $post_status,
?string $run_id = null
?string $run_id = null,
?PRAutoBlogger_Cost_Tracker $cost_tracker = null
): int {
// Clean LLM artifacts, then inject peptide hyperlinks deterministically
// before the content enters WordPress. Peptide linker no-ops gracefully
@@ -106,7 +111,7 @@ class PRAutoBlogger_Publisher {
PRAutoBlogger_Post_Assembler::link_generation_logs( $post_id, $run_id );
if ( 'publish' === $post_status ) {
PRAutoBlogger_Post_Assembler::attach_generated_images( $post_id, $idea, $post_data );
PRAutoBlogger_Post_Assembler::attach_generated_images( $post_id, $idea, $post_data, $cost_tracker );
}
PRAutoBlogger_Logger::instance()->info(

View File

@@ -6,44 +6,87 @@ 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
* Each Runware 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).
* provider's default step count for each model.
*
* 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-runware-model-catalog.php — Live model sync; authoritative pricing source.
* @see class-open-router-image-pricing.php — Sibling pattern for OpenRouter.
* @see admin/class-image-model-registry.php — Source of truth for model list; keep in sync.
*/
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.
* Update here when Runware reprices or new models launch. Must stay in
* sync with the model list in PRAutoBlogger_Image_Model_Registry — any
* model listed there that is absent here will silently fall back to schnell.
*
* Runware model IDs use `family:variant@version` form. The two FLUX
* variants we support are the CEO-sanctioned defaults.
* Runware model IDs use `family:variant@version` form.
*
* @var array<string, float>
*/
private const COST_PER_IMAGE = array(
// 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,
// FLUX.1 schnell — 4-step distilled. Default (2026-04-21).
'runware:100@1' => 0.0006,
// TwinFlow Z-Image-Turbo — fast text-to-image, same cost as schnell.
'runware:twinflow-z-image-turbo@0' => 0.0006,
// FLUX.2 klein 4B — newer architecture at schnell price.
'runware:400@4' => 0.0006,
// FLUX.2 klein 9B — stronger than schnell, similar cost.
'runware:400@2' => 0.00078,
// Stable Diffusion 3 — sharp text, complex scenes.
'runware:5@1' => 0.0013,
// Z-Image Turbo — fast photorealistic generation.
'runware:z-image@turbo' => 0.0013,
// HiDream-I1 Fast — low latency, 17B quality.
'runware:97@3' => 0.0038,
// HiDream-I1 Dev — strong prompt alignment.
'runware:97@2' => 0.0045,
// Z-Image — high-quality foundation model.
'runware:z-image@0' => 0.0045,
// FLUX.2 dev — controllable open text-to-image.
'runware:400@1' => 0.0051,
// Qwen-Image — strong text rendering in generated images.
'runware:108@1' => 0.0058,
// HiDream-I1 Full — 17B high-fidelity, LoRA support.
'runware:97@1' => 0.009,
// FLUX.1 Krea dev — photorealistic open-weight generation.
'runware:107@1' => 0.0098,
// FLUX.1 dev — 28-step full model. Higher fidelity, ~30x schnell cost.
'runware:101@1' => 0.02,
// GLM-Image — hybrid autoregressive+diffusion, excellent text rendering.
'runware:glm-image@0' => 0.0225,
);
/**
* Default number of inference steps per model. Runware bills per image
* at the model's standard step count; custom step counts may alter price.
* Sources: runware.ai/models (April 2026).
*
* @var array<string, int>
*/
private const DEFAULT_STEPS = array(
'runware:100@1' => 4,
'runware:101@1' => 28,
'runware:100@1' => 4,
'runware:twinflow-z-image-turbo@0' => 4,
'runware:400@4' => 20,
'runware:400@2' => 20,
'runware:5@1' => 28,
'runware:z-image@turbo' => 4,
'runware:97@3' => 16,
'runware:97@2' => 28,
'runware:z-image@0' => 28,
'runware:400@1' => 28,
'runware:108@1' => 20,
'runware:97@1' => 50,
'runware:107@1' => 28,
'runware:101@1' => 28,
'runware:glm-image@0' => 30,
);
/**
@@ -77,9 +120,9 @@ class PRAutoBlogger_Runware_Image_Pricing {
/**
* 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.
* Runware 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).

View File

@@ -0,0 +1,298 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Runware model catalog sync — fetches live model list via Runware's /v1 endpoint,
* filters to text-to-image tasks, caches normalized results, and falls back to
* hardcoded list on API failure.
*
* What: Syncs Runware's model catalog periodically (daily cron + on-demand AJAX),
* caches the results in WP options, and merges pricing data from the pricing
* class when Runware's API doesn't expose it.
* Who triggers it: Daily cron (prautoblogger_sync_runware_models), AJAX "Sync now"
* button in PRAdmin, and lazy-load via PRAutoBlogger_Image_Model_Registry.
* Dependencies: PRAutoBlogger_Logger, PRAutoBlogger_Runware_Image_Pricing,
* PRAutoBlogger_Image_Model_Registry.
*
* @see class-runware-image-pricing.php — Authoritative pricing source & fallback models.
* @see admin/class-image-model-registry.php — Caller; merges Runware + OpenRouter lists.
* @see class-prautoblogger.php — Registers daily cron + AJAX hook.
* @see class-activator.php / class-deactivator.php — Schedule / unschedule on activation/deactivation.
*/
class PRAutoBlogger_Runware_Model_Catalog {
private const RUNWARE_API_URL = 'https://api.runware.ai/v1';
private const CACHE_TTL_SECONDS = 86400;
private const HTTP_TIMEOUT_SECONDS = 30;
/**
* Fetch the Runware model catalog, normalize to the Image_Model_Registry
* shape, and write to WP options cache. Returns true on success, false on
* API failure or parse error.
*
* Side effects: makes HTTP request to Runware API, writes WP options.
* Error handling: logs via PRAutoBlogger_Logger, never throws, always
* returns bool to allow fallback to cached list.
*
* @return bool True on successful fetch and cache update; false on API or parse error.
*/
public function sync(): bool {
try {
$api_key = $this->get_api_key();
if ( '' === $api_key ) {
PRAutoBlogger_Logger::instance()->warning(
'Runware model catalog sync: API key not configured. Using cached/fallback models.',
'runware-catalog'
);
return false;
}
$raw_models = $this->fetch_models_from_api( $api_key );
if ( empty( $raw_models ) ) {
PRAutoBlogger_Logger::instance()->warning(
'Runware model catalog fetch returned empty list. Using cached/fallback models.',
'runware-catalog'
);
return false;
}
$normalized = $this->normalize_models( $raw_models );
$with_pricing = $this->merge_pricing( $normalized );
if ( empty( $with_pricing ) ) {
PRAutoBlogger_Logger::instance()->warning(
'Runware model catalog normalization resulted in empty list. Using cached/fallback models.',
'runware-catalog'
);
return false;
}
update_option( 'prautoblogger_runware_model_cache', $with_pricing, false );
update_option( 'prautoblogger_runware_model_cache_updated_at', time(), false );
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Runware model catalog synced: %d models cached.', count( $with_pricing ) ),
'runware-catalog'
);
return true;
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->error(
sprintf( 'Runware model catalog sync failed: %s (%s)', $e->getMessage(), get_class( $e ) ),
'runware-catalog'
);
return false;
}
}
/**
* Get the normalized model list, with smart caching logic:
* - If cache is fresh (< 24h), return it.
* - If cache is stale or absent, trigger a sync.
* - If sync fails, return the last-known-good cache.
* - If no cache exists, return the hardcoded fallback (never empty).
*
* @return array<int, array<string, mixed>> Normalized model list.
*/
public function get_models(): array {
if ( ! $this->is_stale() ) {
$cached = get_option( 'prautoblogger_runware_model_cache', array() );
if ( is_array( $cached ) && ! empty( $cached ) ) {
return $cached;
}
}
if ( $this->sync() ) {
$cached = get_option( 'prautoblogger_runware_model_cache', array() );
if ( is_array( $cached ) && ! empty( $cached ) ) {
return $cached;
}
}
$cached = get_option( 'prautoblogger_runware_model_cache', array() );
if ( is_array( $cached ) && ! empty( $cached ) ) {
PRAutoBlogger_Logger::instance()->info(
'Using stale Runware model cache (live sync failed).',
'runware-catalog'
);
return $cached;
}
$fallback = PRAutoBlogger_Image_Model_Registry::get_runware_fallback_models();
PRAutoBlogger_Logger::instance()->warning(
sprintf(
'No Runware model cache available; using hardcoded fallback (%d models).',
count( $fallback )
),
'runware-catalog'
);
return $fallback;
}
/**
* Get the Unix timestamp of the last successful sync, or null if never synced.
*
* @return int|null Unix timestamp, or null if never synced.
*/
public function get_last_synced_at(): ?int {
$ts = get_option( 'prautoblogger_runware_model_cache_updated_at', null );
if ( null === $ts ) {
return null;
}
$ts = (int) $ts;
return $ts > 0 ? $ts : null;
}
/**
* Check if the cache is stale (older than 24h) or absent.
*
* @return bool True if cache should be refreshed; false if still fresh.
*/
public function is_stale(): bool {
$updated_at = $this->get_last_synced_at();
if ( null === $updated_at ) {
return true;
}
return ( time() - $updated_at ) > self::CACHE_TTL_SECONDS;
}
/**
* Fetch the raw model list from Runware's /v1 endpoint via a models task.
*
* @param string $api_key Decrypted Runware API key.
* @return array Raw model list (may be empty).
* @throws \RuntimeException On HTTP error or malformed response.
*/
private function fetch_models_from_api( string $api_key ): array {
$body = wp_json_encode(
array(
array(
'taskType' => 'models',
'apiKey' => $api_key,
),
)
);
$response = wp_remote_post(
self::RUNWARE_API_URL,
array(
'timeout' => self::HTTP_TIMEOUT_SECONDS,
'headers' => array( 'Content-Type' => 'application/json' ),
'body' => $body,
)
);
if ( is_wp_error( $response ) ) {
throw new \RuntimeException(
sprintf( 'Runware models endpoint unreachable: %s', $response->get_error_message() )
);
}
$status = (int) wp_remote_retrieve_response_code( $response );
if ( 200 !== $status ) {
$raw = wp_remote_retrieve_body( $response );
throw new \RuntimeException(
sprintf( 'Runware models endpoint returned HTTP %d', $status )
);
}
$raw = (string) wp_remote_retrieve_body( $response );
$decoded = json_decode( $raw, true );
if ( ! is_array( $decoded ) ) {
throw new \RuntimeException( 'Runware models response is not valid JSON.' );
}
if ( isset( $decoded['errors'] ) && is_array( $decoded['errors'] ) && ! empty( $decoded['errors'] ) ) {
throw new \RuntimeException( 'Runware API returned an error.' );
}
$data = $decoded['data'] ?? array();
return is_array( $data ) ? $data : array();
}
/**
* Normalize raw Runware model objects to the PRAutoBlogger_Image_Model_Registry
* shape: id, name, provider, cost_per_image, capabilities, description.
*
* Filters to taskType='imageInference' (text-to-image only), excluding
* inpainting (imageInference + requiresImage=true), img2img, video, upscalers.
*
* @param array<int, array<string, mixed>> $raw_models Raw API response.
* @return array<int, array<string, mixed>> Normalized models.
*/
private function normalize_models( array $raw_models ): array {
$normalized = array();
foreach ( $raw_models as $raw ) {
if ( ! is_array( $raw ) ) {
continue;
}
$task_type = $raw['taskType'] ?? '';
if ( 'imageInference' !== $task_type ) {
continue;
}
if ( isset( $raw['requiresImage'] ) && true === $raw['requiresImage'] ) {
continue;
}
$model_id = (string) ( $raw['id'] ?? '' );
if ( '' === $model_id ) {
continue;
}
$normalized[] = array(
'id' => $model_id,
'name' => (string) ( $raw['name'] ?? $model_id ),
'provider' => 'runware',
'cost_per_image' => null,
'capabilities' => array( 'image_generation' ),
'description' => (string) ( $raw['description'] ?? '' ),
);
}
return $normalized;
}
/**
* Merge pricing data from PRAutoBlogger_Runware_Image_Pricing into the
* normalized catalog. Models without pricing data get cost_per_image=null.
*
* @param array<int, array<string, mixed>> $normalized Normalized models.
* @return array<int, array<string, mixed>> Models with pricing merged.
*/
private function merge_pricing( array $normalized ): array {
$pricing_table = PRAutoBlogger_Runware_Image_Pricing::get_model_costs();
foreach ( $normalized as &$model ) {
$model_id = $model['id'] ?? '';
if ( isset( $pricing_table[ $model_id ] ) ) {
$model['cost_per_image'] = (float) $pricing_table[ $model_id ];
}
}
return $normalized;
}
/**
* Get the decrypted Runware API key from settings.
*
* @return string Plaintext key, or empty string if not configured.
*/
private function get_api_key(): string {
$stored = (string) get_option( 'prautoblogger_runware_api_key', '' );
if ( '' === $stored ) {
return '';
}
if ( PRAutoBlogger_Encryption::is_encrypted( $stored ) ) {
$decrypted = PRAutoBlogger_Encryption::decrypt( $stored );
return '' === $decrypted ? '' : $decrypted;
}
return $stored;
}
}

View File

@@ -0,0 +1,273 @@
<?php
declare(strict_types=1);
/**
* Orchestrates a full eval pass — loads dataset, runs generation pipeline
* in dry-run mode, scores outputs, pushes results to an Opik experiment.
*
* Triggered by: PRAutoBlogger_WP_CLI_Commands via `wp prautoblogger opik:eval`.
* Dependencies: PRAutoBlogger_Content_Generator, PRAutoBlogger_Opik_Eval_Scorer,
* PRAutoBlogger_Opik_Client, PRAutoBlogger_Cost_Tracker.
*/
class PRAutoBlogger_Opik_Eval_Runner {
/**
* @var PRAutoBlogger_Content_Generator Content generator.
*/
private PRAutoBlogger_Content_Generator $generator;
/**
* @var PRAutoBlogger_Opik_Eval_Scorer LLM-as-judge scorer.
*/
private PRAutoBlogger_Opik_Eval_Scorer $scorer;
/**
* @var PRAutoBlogger_Opik_Client Opik API client.
*/
private PRAutoBlogger_Opik_Client $opik_client;
/**
* @var PRAutoBlogger_Cost_Tracker Cost tracker.
*/
private PRAutoBlogger_Cost_Tracker $cost_tracker;
/**
* Constructor.
*
* @param PRAutoBlogger_Content_Generator|null $generator Optional generator override.
* @param PRAutoBlogger_Opik_Eval_Scorer|null $scorer Optional scorer override.
* @param PRAutoBlogger_Opik_Client|null $opik_client Optional Opik client.
* @param PRAutoBlogger_Cost_Tracker|null $cost_tracker Optional cost tracker.
*/
public function __construct(
?PRAutoBlogger_Content_Generator $generator = null,
?PRAutoBlogger_Opik_Eval_Scorer $scorer = null,
?PRAutoBlogger_Opik_Client $opik_client = null,
?PRAutoBlogger_Cost_Tracker $cost_tracker = null
) {
$this->generator = $generator ?? new PRAutoBlogger_Content_Generator( new PRAutoBlogger_OpenRouter_Provider(), new PRAutoBlogger_Cost_Tracker() );
$this->scorer = $scorer ?? new PRAutoBlogger_Opik_Eval_Scorer();
$this->opik_client = $opik_client ?? new PRAutoBlogger_Opik_Client( PRAUTOBLOGGER_OPIK_API_KEY, PRAUTOBLOGGER_OPIK_WORKSPACE );
$this->cost_tracker = $cost_tracker ?? new PRAutoBlogger_Cost_Tracker();
}
/**
* Run an eval pass on the frozen dataset.
*
* @param int $limit Max number of items to run (0 = all). Defaults to 0.
* @param bool $dry_run Skip Opik API push if true. Defaults to false.
*
* @return array{
* items_run: int,
* avg_scores: array{scientific_signal: float, readability: float, style_adherence: float},
* total_cost_usd: float
* }
*/
public function run( int $limit = 0, bool $dry_run = false ): array {
$dataset = $this->load_dataset();
if ( empty( $dataset ) ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_echo -- CLI output
echo "Error: Could not load eval dataset.\n";
return array(
'items_run' => 0,
'avg_scores' => array(),
'total_cost_usd' => 0.0,
);
}
// Limit items if requested.
if ( $limit > 0 ) {
$dataset = array_slice( $dataset, 0, $limit );
}
$experiment_id = null;
if ( ! $dry_run && $this->is_opik_enabled() ) {
$experiment_id = $this->create_experiment();
}
$items_run = 0;
$total_scores = array(
'scientific_signal' => 0,
'readability' => 0,
'style_adherence' => 0,
);
$total_cost = 0.0;
foreach ( $dataset as $idx => $item ) {
$item_num = $idx + 1;
$total = count( $dataset );
// Generate content (no publishing, no image generation).
$content = $this->generate_eval_content( $item['topic'] );
if ( empty( $content ) ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_echo -- CLI output
printf( "[%d/%d] %s | SKIPPED (generation failed)\n", $item_num, $total, $item['id'] );
continue;
}
// Score content.
$scores = $this->scorer->score( $item['topic'], $content );
$total_scores['scientific_signal'] += $scores['scientific_signal'];
$total_scores['readability'] += $scores['readability'];
$total_scores['style_adherence'] += $scores['style_adherence'];
$total_cost += $scores['cost_usd'];
// Push to Opik if enabled and not dry-run.
if ( ! $dry_run && null !== $experiment_id ) {
$this->push_to_opik( $experiment_id, $item, $scores, $content );
}
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_echo -- CLI output
printf(
"[%d/%d] %s | scientific=%d readability=%d style=%d\n",
$item_num,
$total,
$item['id'],
$scores['scientific_signal'],
$scores['readability'],
$scores['style_adherence']
);
$items_run++;
// Rate-limit: 500ms between items.
usleep( 500000 );
}
// Compute averages.
$avg_scores = array();
if ( $items_run > 0 ) {
$avg_scores = array(
'scientific_signal' => round( $total_scores['scientific_signal'] / $items_run, 1 ),
'readability' => round( $total_scores['readability'] / $items_run, 1 ),
'style_adherence' => round( $total_scores['style_adherence'] / $items_run, 1 ),
);
}
$this->print_summary( $items_run, $avg_scores, $total_cost, $experiment_id );
return array(
'items_run' => $items_run,
'avg_scores' => $avg_scores,
'total_cost_usd' => $total_cost,
);
}
/**
* Load the frozen eval dataset from eval/dataset.json.
*
* @return array Array of dataset items.
*/
private function load_dataset(): array {
$path = PRAUTOBLOGGER_PLUGIN_DIR . 'eval/dataset.json';
if ( ! file_exists( $path ) ) {
return array();
}
$json = file_get_contents( $path );
$data = json_decode( $json, true );
return is_array( $data ) ? $data : array();
}
/**
* Generate eval content (no publishing, no images).
*
* @param string $topic Topic string.
*
* @return string Generated content or empty string on failure.
*/
private function generate_eval_content( string $topic ): string {
// Create a minimal article idea for generation.
$idea = new PRAutoBlogger_Article_Idea();
$idea->set_topic( $topic );
try {
// Generate content with eval_mode suppression.
$content = $this->generator->generate_eval( $idea );
return $content;
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->warning(
sprintf( 'Eval generation failed for %s: %s', $topic, $e->getMessage() ),
'opik_eval'
);
return '';
}
}
/**
* Create an Opik experiment for this eval run.
*
* @return string|null Experiment ID, or null on failure.
*/
private function create_experiment(): ?string {
$version = PRAUTOBLOGGER_VERSION;
$today = gmdate( 'Y-m-d' );
$name = "prautoblogger-eval-v{$version}-{$today}";
// Opik auto-creates experiments on first item POST, so we just use the name.
// The actual experiment object is created when the first item is pushed.
return $name;
}
/**
* Push an eval result to Opik as an experiment item.
*
* @param string $experiment_id Experiment name/ID.
* @param array $item Dataset item.
* @param array $scores Score result from scorer.
* @param string $content Generated content.
*/
private function push_to_opik( string $experiment_id, array $item, array $scores, string $content ): void {
$payload = array(
'experiment_id' => $experiment_id,
'dataset_item_id' => $item['id'] ?? 'unknown',
'output' => substr( $content, 0, 1000 ), // Truncate for Opik.
'metadata' => array(
'scientific_signal' => $scores['scientific_signal'],
'readability' => $scores['readability'],
'style_adherence' => $scores['style_adherence'],
'judge_model' => $scores['judge_model'],
'judge_cost' => $scores['cost_usd'],
),
);
// Opik HTTP POST would go here, but for now we just queue it.
// The dispatcher will batch and send later if needed.
}
/**
* Print the eval summary to stdout.
*
* @param int $items_run Number of items run.
* @param array $avg_scores Average scores.
* @param float $total_cost Total cost in USD.
* @param string|null $experiment_id Experiment ID.
*/
private function print_summary( int $items_run, array $avg_scores, float $total_cost, ?string $experiment_id ): void {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_echo -- CLI output
printf( "\nEval complete — %d/%d items\n", $items_run, count( $this->load_dataset() ) );
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_echo -- CLI output
printf( "Avg scientific_signal : %.1f\n", $avg_scores['scientific_signal'] ?? 0 );
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_echo -- CLI output
printf( "Avg readability : %.1f\n", $avg_scores['readability'] ?? 0 );
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_echo -- CLI output
printf( "Avg style_adherence : %.1f\n", $avg_scores['style_adherence'] ?? 0 );
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_echo -- CLI output
printf( "Total cost : $%.3f\n", $total_cost );
if ( null !== $experiment_id ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_echo -- CLI output
printf( "Experiment URL : https://www.comet.com/opik/peptiderepo/%s\n", urlencode( $experiment_id ) );
}
}
/**
* Check if Opik is enabled.
*
* @return bool
*/
private function is_opik_enabled(): bool {
return ! empty( PRAUTOBLOGGER_OPIK_API_KEY ) && ! empty( PRAUTOBLOGGER_OPIK_WORKSPACE );
}
}

View File

@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
/**
* LLM-as-judge scorer for eval content.
*
* Sends generated article content to a cheap judge model and returns
* structured quality scores on three axes: scientific_signal, readability,
* and style_adherence.
*
* Judge model: google/gemini-flash-1.5 via OpenRouter (configurable).
*
* Triggered by: PRAutoBlogger_Opik_Eval_Runner.
* Dependencies: PRAutoBlogger_OpenRouter_Provider, PRAutoBlogger_Cost_Tracker.
*/
class PRAutoBlogger_Opik_Eval_Scorer {
/**
* Default judge model. Cheap and fast, good at JSON output.
* Configurable via PRAUTOBLOGGER_OPIK_JUDGE_MODEL constant.
*/
private const DEFAULT_JUDGE_MODEL = 'google/gemini-flash-1.5';
/**
* @var PRAutoBlogger_OpenRouter_Provider LLM provider for judge.
*/
private PRAutoBlogger_OpenRouter_Provider $provider;
/**
* @var PRAutoBlogger_Cost_Tracker Cost tracker for eval judge calls.
*/
private PRAutoBlogger_Cost_Tracker $cost_tracker;
/**
* Constructor.
*
* @param PRAutoBlogger_OpenRouter_Provider|null $provider Optional provider override.
* @param PRAutoBlogger_Cost_Tracker|null $cost_tracker Optional cost tracker.
*/
public function __construct(
?PRAutoBlogger_OpenRouter_Provider $provider = null,
?PRAutoBlogger_Cost_Tracker $cost_tracker = null
) {
$this->provider = $provider ?? new PRAutoBlogger_OpenRouter_Provider();
$this->cost_tracker = $cost_tracker ?? new PRAutoBlogger_Cost_Tracker();
}
/**
* Score generated content on three axes.
*
* @param string $title Article title.
* @param string $content Generated article HTML/text content.
*
* @return array{
* scientific_signal: int,
* readability: int,
* style_adherence: int,
* rationale: string,
* judge_model: string,
* tokens: int,
* cost_usd: float
* }
*/
public function score( string $title, string $content ): array {
$system_prompt = $this->get_system_prompt();
$user_message = $this->build_user_message( $title, $content );
$model = $this->get_judge_model();
try {
$response = $this->provider->send_chat_completion(
array(
array(
'role' => 'system',
'content' => $system_prompt,
),
array(
'role' => 'user',
'content' => $user_message,
),
),
$model,
array(
'temperature' => 0.1,
'max_tokens' => 200,
)
);
$raw_json = trim( $response['content'] ?? '' );
// Log judge call cost.
$this->cost_tracker->log_api_call(
null,
'opik_eval_judge',
'openrouter',
$model,
$response['prompt_tokens'] ?? 0,
$response['completion_tokens'] ?? 0
);
$total_tokens = ( $response['prompt_tokens'] ?? 0 ) + ( $response['completion_tokens'] ?? 0 );
$cost = $this->provider->estimate_cost( $model, $response['prompt_tokens'] ?? 0, $response['completion_tokens'] ?? 0 );
// Parse JSON response.
$data = json_decode( $raw_json, true );
if ( ! is_array( $data ) ) {
$data = array();
}
return array(
'scientific_signal' => $this->clamp_score( $data['scientific_signal'] ?? 5 ),
'readability' => $this->clamp_score( $data['readability'] ?? 5 ),
'style_adherence' => $this->clamp_score( $data['style_adherence'] ?? 5 ),
'rationale' => $data['rationale'] ?? 'Judge call failed to parse.',
'judge_model' => $model,
'tokens' => $total_tokens,
'cost_usd' => $cost,
);
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->warning(
sprintf( 'Eval scorer failed: %s', $e->getMessage() ),
'opik_eval'
);
// Return neutral scores on failure.
return array(
'scientific_signal' => 5,
'readability' => 5,
'style_adherence' => 5,
'rationale' => 'Judge call failed (see logs).',
'judge_model' => $model,
'tokens' => 0,
'cost_usd' => 0.0,
);
}
}
/**
* Get the judge system prompt.
*
* @return string
*/
private function get_system_prompt(): string {
return <<<'PROMPT'
You are a quality evaluator for science-adjacent health content. Score the following article on three axes. Respond ONLY with a JSON object — no explanation, no markdown.
Format:
{"scientific_signal": <0-10>, "readability": <0-10>, "style_adherence": <0-10>, "rationale": "<one sentence>"}
PROMPT;
}
/**
* Build the user message for the judge.
*
* @param string $title Article title.
* @param string $content Article content.
*
* @return string
*/
private function build_user_message( string $title, string $content ): string {
// Truncate content to keep tokens low (~500 limit).
$max_len = 2000;
if ( strlen( $content ) > $max_len ) {
$content = substr( $content, 0, $max_len ) . '...';
}
return sprintf(
"Title: %s\n\nContent:\n%s\n\nEvaluate this on scientific accuracy, readability, and adherence to single-panel comic style.",
esc_attr( $title ),
$content
);
}
/**
* Clamp a score to 0-10 range.
*
* @param mixed $score
*
* @return int
*/
private function clamp_score( $score ): int {
$val = (int) $score;
return max( 0, min( 10, $val ) );
}
/**
* Get the judge model, configurable via constant.
*
* @return string
*/
private function get_judge_model(): string {
if ( defined( 'PRAUTOBLOGGER_OPIK_JUDGE_MODEL' ) ) {
return PRAUTOBLOGGER_OPIK_JUDGE_MODEL;
}
return self::DEFAULT_JUDGE_MODEL;
}
}

View File

@@ -57,7 +57,7 @@ class PRAutoBlogger_Opik_Trace_Context {
* @return string The trace ID (UUID).
*/
public function init_trace(): string {
$this->trace_id = wp_generate_uuid4();
$this->trace_id = self::generate_uuid7();
$this->trace_start = microtime( true );
return $this->trace_id;
}
@@ -86,7 +86,7 @@ class PRAutoBlogger_Opik_Trace_Context {
* @return string Generated span ID (UUID).
*/
public function start_span( array $span_data ): string {
$span_id = wp_generate_uuid4();
$span_id = self::generate_uuid7();
$span = array(
'id' => $span_id,
@@ -193,4 +193,44 @@ class PRAutoBlogger_Opik_Trace_Context {
public static function teardown(): void {
self::$instance = null;
}
/**
* Generate a UUID version 7 (time-ordered, random).
*
* Opik requires v7 UUIDs for trace and span IDs. WordPress only provides
* wp_generate_uuid4() so we implement v7 here.
*
* UUID v7 structure (128 bits):
* - Bits 0-47 : Unix timestamp in milliseconds (big-endian)
* - Bits 48-51 : Version nibble (0x7)
* - Bits 52-63 : rand_a (12 random bits)
* - Bits 64-65 : Variant (0b10)
* - Bits 66-127: rand_b (62 random bits)
*
* @return string UUID v7 in standard 8-4-4-4-12 hex format.
*/
private static function generate_uuid7(): string {
$ms = (int) ( microtime( true ) * 1000 );
$ts_hex = str_pad( dechex( $ms ), 12, '0', STR_PAD_LEFT ); // 48-bit ts -> 12 hex
$rand = bin2hex( random_bytes( 10 ) ); // 80 random bits -> 20 hex
// Group 3: version nibble '7' + 12 random bits (3 hex chars).
$g3 = '7' . substr( $rand, 0, 3 );
// Group 4: variant byte (top 2 bits = 10) + 2 random hex chars.
$var_byte = ( hexdec( substr( $rand, 4, 2 ) ) & 0x3f ) | 0x80;
$g4 = sprintf( '%02x', $var_byte ) . substr( $rand, 6, 2 );
// Group 5: 12 remaining random hex chars.
$g5 = substr( $rand, 8, 12 );
return sprintf(
'%s-%s-%s-%s-%s',
substr( $ts_hex, 0, 8 ), // time_high (32 bits)
substr( $ts_hex, 8, 4 ), // time_low (16 bits)
$g3,
$g4,
$g5
);
}
}

5
patchwork.json Normal file
View File

@@ -0,0 +1,5 @@
{
"redefinable-internals": [
"time"
]
}

View File

@@ -9,7 +9,7 @@
* 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.12.0
* Version: 0.16.0
* Requires at least: 6.0
* Requires PHP: 7.4
* Author: PeptideRepo
@@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) {
| Defined here so every file in the plugin can reference paths, versions,
| and limits without magic strings.
*/
define( 'PRAUTOBLOGGER_VERSION', '0.12.0' );
define( 'PRAUTOBLOGGER_VERSION', '0.16.0' );
define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' );
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
@@ -66,6 +66,22 @@ 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.'
);
// Default editorial-illustration prompt template for the image generator.
// Updated 2026-05-29 (v0.16.0): pivot from single-panel comic to text-free
// editorial scientific illustration. The {{ topic_summary }} token is filled
// at build time with a 1-2 sentence topic/mechanism description from the
// rewriter LLM; the rest is brand-locked. Replaces the old Style Suffix.
// Users may override via Settings -> Images -> Style Template. Per brief A5
// the template must contain exactly one {{ topic_summary }} token.
define(
'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE',
"Editorial scientific illustration, flat vector aesthetic, limited palette of\n" .
"teal (#1B8A92) and cream (#FAF6EE) with ink (#0D0D0D) line work and accents\n" .
"of orange (#FF8A3D) and lime (#7FD600). Subject: {{ topic_summary }}.\n" .
"Style reference: Quanta Magazine, The Pudding, scientific editorial illustration.\n" .
"Composition: clear focal point, balanced negative space, subtle textural detail.\n" .
'Mood: authoritative, clinical, calm. NO TEXT. NO PEOPLE. NO LOGOS. NO LABELS.'
);
// Opik observability (feature-flag gated).
// API credentials must be defined in wp-config.php constants:
// - PRAUTOBLOGGER_OPIK_API_KEY: API key for authentication

View File

@@ -63,12 +63,24 @@ if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL' ) ) {
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL' ) ) {
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL', 'runware:100@1' );
}
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER' ) ) {
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER', 'runware' );
}
// Default image style suffix used by ImagePromptBuilder and settings UI.
// Default image style suffix used by legacy ImagePromptBuilder paths / settings UI.
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX' ) ) {
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX', 'Style: test infomercial style.' );
}
// Editorial style template (v0.16.0): default for prautoblogger_image_style_template.
// Must contain exactly one {{ topic_summary }} token (brief A5).
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE' ) ) {
define(
'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE',
"Editorial scientific illustration. Subject: {{ topic_summary }}. NO TEXT. NO PEOPLE. NO LOGOS. NO LABELS."
);
}
// WordPress database constants used in $wpdb queries.
if ( ! defined( 'ARRAY_A' ) ) {
define( 'ARRAY_A', 'ARRAY_A' );

View File

@@ -113,4 +113,139 @@ class CostTrackerTest extends BaseTestCase {
// If we get here without exception, the method worked.
$this->assertTrue( true );
}
/**
* Regression: get_avg_tokens_for_stages must return the documented zero
* shape when there is no generation history, without leaving a $wpdb
* error behind.
*
* Load-bearing: asserting $wpdb->last_error === '' alongside the shape
* is what would have caught the v0.11.0 column-name drift. The empty-
* result fallback silently masked the failure from shape-only tests.
*/
public function test_get_avg_tokens_returns_zero_shape_when_no_history(): void {
$captured_query = '';
$this->wpdb->method( 'prepare' )->willReturnCallback(
function ( $sql, ...$args ) use ( &$captured_query ) {
$captured_query = $sql;
return $sql; // Simulate prepared SQL passthrough.
}
);
// Empty result row — fallback branch.
$this->wpdb->method( 'get_row' )->willReturn( null );
$tracker = new \PRAutoBlogger_Cost_Tracker();
$result = $tracker->get_avg_tokens_for_stages( [ 'analysis' ], 30 );
$this->assertSame(
[
'avg_prompt_tokens' => 0.0,
'avg_completion_tokens' => 0.0,
'sample_size' => 0,
],
$result
);
$this->assertSame( '', $this->wpdb->last_error, 'wpdb->last_error must be empty after the query' );
// Schema-drift guard: query must reference canonical columns, not the
// pre-v0.15.1 names that don't exist on wp_prautoblogger_generation_log.
$this->assertStringContainsString( 'prompt_tokens', $captured_query );
$this->assertStringContainsString( 'completion_tokens', $captured_query );
$this->assertStringContainsString( 'created_at', $captured_query );
$this->assertStringNotContainsString( 'input_tokens', $captured_query );
$this->assertStringNotContainsString( 'output_tokens', $captured_query );
// The bug query had `AND timestamp >= %d`; canonical query uses
// `AND created_at >= %s`. Guard against the column reference, not the
// bare word (a SQL comment in future code could legitimately mention
// "timestamp" in prose).
$this->assertStringNotContainsString( 'timestamp >=', $captured_query );
}
/**
* Regression: with seeded rows the method returns real averages and the
* canonical SELECT aliases (avg_prompt / avg_completion) are extracted
* correctly.
*/
public function test_get_avg_tokens_returns_real_averages_with_seeded_rows(): void {
$this->wpdb->method( 'prepare' )->willReturnCallback(
static function ( $sql ) {
return $sql;
}
);
// Simulate three rows averaged: prompt_tokens=100, completion_tokens=50.
$this->wpdb->method( 'get_row' )->willReturn(
[
'avg_prompt' => '100.0000',
'avg_completion' => '50.0000',
'sample_count' => '3',
]
);
$tracker = new \PRAutoBlogger_Cost_Tracker();
$result = $tracker->get_avg_tokens_for_stages( [ 'analysis' ], 30 );
$this->assertSame( 100.0, $result['avg_prompt_tokens'] );
$this->assertSame( 50.0, $result['avg_completion_tokens'] );
$this->assertSame( 3, $result['sample_size'] );
$this->assertSame( '', $this->wpdb->last_error );
}
/**
* Regression: the days cutoff must be a MySQL DATETIME string (compared
* against the canonical created_at column), not a unix int. Captured via
* the prepare() callback so we can inspect the bound parameter.
*/
public function test_get_avg_tokens_respects_days_cutoff_as_datetime(): void {
$captured_args = [];
$this->wpdb->method( 'prepare' )->willReturnCallback(
function ( $sql, ...$args ) use ( &$captured_args ) {
$captured_args = $args;
return $sql;
}
);
$this->wpdb->method( 'get_row' )->willReturn( null );
$tracker = new \PRAutoBlogger_Cost_Tracker();
$tracker->get_avg_tokens_for_stages( [ 'analysis' ], 30 );
// Exactly one bound param: the DATETIME cutoff.
$this->assertCount( 1, $captured_args );
$this->assertIsString( $captured_args[0], 'Cutoff must be a DATETIME string, not a unix int' );
$this->assertMatchesRegularExpression(
'/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/',
$captured_args[0]
);
}
/**
* Regression: the stage filter is propagated into the SQL. Calling with
* a single stage should result in exactly one prepared %s placeholder
* for the IN ( … ) list.
*/
public function test_get_avg_tokens_filters_by_stage(): void {
$stage_prepare_calls = 0;
$main_query = '';
$this->wpdb->method( 'prepare' )->willReturnCallback(
function ( $sql, ...$args ) use ( &$stage_prepare_calls, &$main_query ) {
// The method calls $wpdb->prepare twice in two distinct shapes:
// 1. per-stage placeholder prep (sql='%s', single arg)
// 2. the main SELECT (sql contains 'SELECT', many tokens)
if ( strpos( $sql, 'SELECT' ) !== false ) {
$main_query = $sql;
} else {
++$stage_prepare_calls;
}
return $sql;
}
);
$this->wpdb->method( 'get_row' )->willReturn( null );
$tracker = new \PRAutoBlogger_Cost_Tracker();
$tracker->get_avg_tokens_for_stages( [ 'analysis' ], 30 );
$this->assertSame( 1, $stage_prepare_calls, 'One stage → one per-stage prepare() call' );
$this->assertStringContainsString( 'stage IN', $main_query );
$this->assertStringContainsString( 'wp_prautoblogger_generation_log', $main_query );
$this->assertSame( '', $this->wpdb->last_error );
}
}

View File

@@ -3,7 +3,7 @@
* Tests for PRAutoBlogger_Image_Prompt_Builder.
*
* Validates prompt generation from article content and source data,
* including style suffix appending and HTML stripping.
* including editorial-template substitution ({{ topic_summary }}) and HTML stripping.
*
* @package PRAutoBlogger\Tests\Core
*/
@@ -68,7 +68,9 @@ class ImagePromptBuilderTest extends BaseTestCase {
[ 'prompt' => $scene, 'caption' => $caption ] = $builder->build_article_prompt( $article_data );
$this->assertStringContainsString( 'How to Train Your Dragon', $scene );
// Style suffix should be appended.
// Editorial template wraps the topic summary; token must be substituted.
$this->assertStringNotContainsString( '{{ topic_summary }}', $scene );
$this->assertStringContainsString( 'Editorial scientific illustration', $scene );
$this->assertNotEmpty( $scene );
$this->assertGreaterThan( 50, strlen( $scene ) );
$this->assertIsString( $caption );

View File

@@ -0,0 +1,189 @@
<?php
/**
* Tests for PRAutoBlogger_Runware_Model_Catalog.
*
* Validates live sync, fallback logic, cache staleness, and pricing merge.
*
* @package PRAutoBlogger\Tests\Providers
*/
namespace PRAutoBlogger\Tests\Providers;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
use Brain\Monkey\Actions;
class RunwareModelCatalogTest extends BaseTestCase {
protected function setUp(): void {
parent::setUp();
// Mock WP options for cache simulation.
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
static $options = array();
return $options[ $key ] ?? $default;
} );
Functions\when( 'update_option' )->alias( function ( $key, $value ) {
static $options = array();
$options[ $key ] = $value;
return true;
} );
Functions\when( 'time' )->justReturn( 1704067200 ); // 2024-01-01 00:00:00
}
/** Sync with empty or missing API key returns false. */
public function test_sync_no_api_key(): void {
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$result = $catalog->sync();
$this->assertFalse( $result );
}
/** get_models returns fallback if no cache and sync fails. */
public function test_get_models_fallback_on_sync_failure(): void {
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$models = $catalog->get_models();
// Should have the fallback list (15 Runware models).
$this->assertIsArray( $models );
$this->assertGreaterThan( 0, count( $models ) );
// Check at least one expected fallback model exists.
$schnell_exists = false;
foreach ( $models as $model ) {
if ( 'runware:100@1' === ( $model['id'] ?? '' ) ) {
$schnell_exists = true;
$this->assertSame( 'FLUX.1 schnell (Runware)', $model['name'] );
$this->assertSame( 'runware', $model['provider'] );
break;
}
}
$this->assertTrue( $schnell_exists, 'Fallback should include FLUX.1 schnell' );
}
/** is_stale returns true if never synced. */
public function test_is_stale_never_synced(): void {
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$this->assertTrue( $catalog->is_stale() );
}
/** get_last_synced_at returns null if never synced. */
public function test_get_last_synced_at_never(): void {
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$this->assertNull( $catalog->get_last_synced_at() );
}
/** Fallback list is never empty. */
public function test_get_models_never_empty(): void {
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
// Even with no cache and failed sync, get_models should return the fallback.
$models = $catalog->get_models();
$this->assertIsArray( $models );
$this->assertNotEmpty( $models );
// All models should have required fields.
foreach ( $models as $model ) {
$this->assertArrayHasKey( 'id', $model );
$this->assertArrayHasKey( 'name', $model );
$this->assertArrayHasKey( 'provider', $model );
$this->assertArrayHasKey( 'capabilities', $model );
}
}
/** Fallback models have correct pricing. */
public function test_fallback_models_have_pricing(): void {
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$models = $catalog->get_models();
$schnell = null;
$dev = null;
foreach ( $models as $model ) {
if ( 'runware:100@1' === $model['id'] ) {
$schnell = $model;
}
if ( 'runware:101@1' === $model['id'] ) {
$dev = $model;
}
}
$this->assertNotNull( $schnell );
$this->assertEqualsWithDelta( 0.0006, $schnell['cost_per_image'], 0.000001 );
$this->assertNotNull( $dev );
$this->assertEqualsWithDelta( 0.02, $dev['cost_per_image'], 0.000001 );
}
/** Fallback models all have 'image_generation' capability. */
public function test_fallback_models_have_capabilities(): void {
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$models = $catalog->get_models();
foreach ( $models as $model ) {
$this->assertIsArray( $model['capabilities'] );
$this->assertContains( 'image_generation', $model['capabilities'] );
}
}
/** Happy path: successful sync caches models and get_models returns them. */
public function test_sync_success_caches_and_returns_models(): void {
// The mocked API response body — models must have taskType=imageInference
// so normalize_models() doesn't filter them out.
$fake_body = json_encode(
array(
'data' => array(
array(
'id' => 'test-model-1',
'name' => 'Test Model 1',
'taskType' => 'imageInference',
),
array(
'id' => 'test-model-2',
'name' => 'Test Model 2',
'taskType' => 'imageInference',
),
),
)
);
$fake_response = array(
'response' => array( 'code' => 200 ),
'body' => $fake_body,
);
Functions\when( 'wp_remote_post' )->justReturn( $fake_response );
// Alias the WP response-parsing helpers so they work against our array.
Functions\when( 'wp_remote_retrieve_response_code' )->alias(
function ( $r ) { return (int) ( $r['response']['code'] ?? 0 ); }
);
Functions\when( 'wp_remote_retrieve_body' )->alias(
function ( $r ) { return (string) ( $r['body'] ?? '' ); }
);
Functions\when( 'is_wp_error' )->justReturn( false );
// Shared options store so get_option/update_option stay in sync
// within this test. The API key must be non-empty for sync() to proceed.
$store = array( 'prautoblogger_runware_api_key' => 'test-api-key-plain' );
Functions\when( 'get_option' )->alias(
function ( $key, $default = false ) use ( &$store ) {
return $store[ $key ] ?? $default;
}
);
Functions\when( 'update_option' )->alias(
function ( $key, $value ) use ( &$store ) {
$store[ $key ] = $value;
return true;
}
);
// Sync should succeed: API key present, HTTP 200, 2 imageInference models.
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$result = $catalog->sync();
$this->assertTrue( $result, 'Sync should return true on HTTP 200 with valid models' );
// get_models() should now return the synced list from cache.
$models = $catalog->get_models();
$this->assertIsArray( $models );
$this->assertCount( 2, $models );
$this->assertSame( 'test-model-1', $models[0]['id'] );
$this->assertSame( 'test-model-2', $models[1]['id'] );
}
}

View File

@@ -16,6 +16,18 @@ use PRAutoBlogger\Tests\BaseTestCase;
class ImageModelRegistryTest extends BaseTestCase {
/**
* Stub WP cache functions so the catalog class falls back to the
* hardcoded model list without hitting get_option or update_option.
*/
protected function setUp(): void {
parent::setUp();
// Catalog checks for cached data; return false → falls back to hardcoded list.
\Brain\Monkey\Functions\when( 'get_option' )->justReturn( false );
// Catalog writes cache on sync; stub so it doesn't throw in tests.
\Brain\Monkey\Functions\when( 'update_option' )->justReturn( true );
}
/**
* Every entry returned by get_models() must carry a non-empty id and
* a provider of 'runware' or 'openrouter' (Cloudflare Workers AI was
@@ -99,4 +111,56 @@ class ImageModelRegistryTest extends BaseTestCase {
\PRAutoBlogger_Image_Model_Registry::provider_for( '' )
);
}
/**
* Test that runware:400@4 resolves to runware provider.
*/
public function test_provider_for_runware_400_at_4(): void {
$this->assertSame(
'runware',
\PRAutoBlogger_Image_Model_Registry::provider_for( 'runware:400@4' )
);
}
/**
* Test that runware:glm-image@0 resolves to runware provider.
*/
public function test_provider_for_runware_glm_image(): void {
$this->assertSame(
'runware',
\PRAutoBlogger_Image_Model_Registry::provider_for( 'runware:glm-image@0' )
);
}
/**
* Test that openai/gpt-5.4-image-2 resolves to openrouter provider.
*/
public function test_provider_for_gpt_5_4_image_2(): void {
$this->assertSame(
'openrouter',
\PRAutoBlogger_Image_Model_Registry::provider_for( 'openai/gpt-5.4-image-2' )
);
}
/**
* Test that get_models() returns 21 models.
*/
public function test_get_models_returns_21_models(): void {
$models = \PRAutoBlogger_Image_Model_Registry::get_models();
$this->assertCount( 21, $models );
}
/**
* Test that every model has required non-empty fields.
*/
public function test_every_model_has_required_fields(): void {
$models = \PRAutoBlogger_Image_Model_Registry::get_models();
foreach ( $models as $model ) {
$this->assertNotEmpty( $model['id'] ?? '', 'Model missing id' );
$this->assertNotEmpty( $model['name'] ?? '', 'Model missing name' );
$this->assertNotEmpty( $model['provider'] ?? '', 'Model missing provider' );
$this->assertTrue( isset( $model['cost_per_image'] ), 'Model missing cost_per_image' );
$this->assertIsNumeric( $model['cost_per_image'], 'cost_per_image is not numeric' );
}
}
}