Compare commits
1 Commits
claude/ado
...
fix/runwar
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
236787ad09 |
13
.github/workflows/ci.yml
vendored
13
.github/workflows/ci.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
php: ['8.1', '8.2', '8.3']
|
||||
php: ['7.4', '8.1', '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 older versions reject.
|
||||
# and may use PHP 8+ syntax (union types etc.) that 7.4 rejects.
|
||||
run: |
|
||||
set -e
|
||||
find . -name '*.php' \
|
||||
@@ -44,12 +44,14 @@ jobs:
|
||||
phpcs:
|
||||
name: PHPCS (WordPress standards)
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: false # strict after PHPCS cleanup in v0.8.3
|
||||
continue-on-error: false # now 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:
|
||||
# Check out the actual branch so phpcbf can push fixes back.
|
||||
# github.head_ref is set on PR events; github.ref_name on push events.
|
||||
ref: ${{ github.head_ref || github.ref_name }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Setup PHP
|
||||
@@ -60,7 +62,10 @@ 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" \
|
||||
@@ -96,7 +101,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
php: ['8.1', '8.2', '8.3']
|
||||
php: ['7.4', '8.1', '8.3']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup PHP ${{ matrix.php }}
|
||||
|
||||
17
.github/workflows/cto-review.yml
vendored
17
.github/workflows/cto-review.yml
vendored
@@ -123,18 +123,17 @@ jobs:
|
||||
git fetch origin ${{ github.event.pull_request.base.ref }} --depth=50
|
||||
BASE_SHA="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
|
||||
# Write the diff to a file, not a step output / env var. Passing a
|
||||
# ~400KB diff through the process env (execve argv+envp) exceeds the
|
||||
# runner ARG_MAX and fails the next step with "Argument list too long"
|
||||
# (issue #110). File transport also sidesteps the ~1MB step-output cap.
|
||||
git diff --no-color "$BASE_SHA" "$HEAD_SHA" | head -c 400000 > /tmp/cto-review-diff.txt
|
||||
DIFF=$(git diff --no-color "$BASE_SHA" "$HEAD_SHA" | head -c 400000)
|
||||
FILES=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")
|
||||
SIZE=$(wc -c < /tmp/cto-review-diff.txt)
|
||||
SIZE=$(echo -n "$DIFF" | wc -c)
|
||||
{
|
||||
echo "size=$SIZE"
|
||||
echo "files<<EOF_FILES"
|
||||
echo "$FILES"
|
||||
echo "EOF_FILES"
|
||||
echo "diff<<EOF_DIFF"
|
||||
echo "$DIFF"
|
||||
echo "EOF_DIFF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Load repo conventions
|
||||
@@ -162,7 +161,7 @@ jobs:
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO_NAME: ${{ github.event.repository.name }}
|
||||
DIFF_PATH: /tmp/cto-review-diff.txt
|
||||
DIFF: ${{ steps.diff.outputs.diff }}
|
||||
DIFF_SIZE: ${{ steps.diff.outputs.size }}
|
||||
CHANGED_FILES: ${{ steps.diff.outputs.files }}
|
||||
CONVENTIONS: ${{ steps.conventions.outputs.content }}
|
||||
@@ -230,9 +229,7 @@ jobs:
|
||||
user_blocks.append(f"**Changed files:**\n```\n{os.environ['CHANGED_FILES']}\n```\n")
|
||||
if os.environ.get("CONVENTIONS", "").strip():
|
||||
user_blocks.append(f"# Repo CONVENTIONS.md\n{os.environ['CONVENTIONS']}\n")
|
||||
with open(os.environ["DIFF_PATH"], encoding="utf-8", errors="replace") as _df:
|
||||
_diff_text = _df.read()
|
||||
user_blocks.append(f"# Diff\n```diff\n{_diff_text}\n```\n")
|
||||
user_blocks.append(f"# Diff\n```diff\n{os.environ['DIFF']}\n```\n")
|
||||
user_message = "\n\n".join(user_blocks)
|
||||
|
||||
tools = [{
|
||||
|
||||
80
.github/workflows/deploy.yml
vendored
80
.github/workflows/deploy.yml
vendored
@@ -28,10 +28,78 @@ jobs:
|
||||
-not -path './.github/*' \
|
||||
-print0 | xargs -0 -n1 -P4 php -l > /dev/null
|
||||
|
||||
deploy:
|
||||
name: Deploy (shared pipeline)
|
||||
deploy-staging:
|
||||
name: Deploy to Staging
|
||||
needs: validate
|
||||
uses: peptiderepo/peptide-e2e/.github/workflows/deploy-app.yml@main
|
||||
with:
|
||||
target_path: wp-content/plugins/prautoblogger
|
||||
secrets: inherit
|
||||
runs-on: ubuntu-latest
|
||||
environment: staging
|
||||
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 → 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' \
|
||||
-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
|
||||
|
||||
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 }} \
|
||||
${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_HOST }} \
|
||||
"cd ~/domains/peptiderepo.com/public_html && \
|
||||
wp litespeed-purge all 2>/dev/null || \
|
||||
(find wp-content/litespeed/ -type f -delete 2>/dev/null; touch .lscache_purge 2>/dev/null) || true"
|
||||
- name: Production health check
|
||||
run: |
|
||||
sleep 5
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://peptiderepo.com)
|
||||
if [ "$STATUS" != "200" ] && [ "$STATUS" != "403" ]; then
|
||||
echo "ERROR: Site returned unexpected HTTP $STATUS after deploy"
|
||||
exit 1
|
||||
fi
|
||||
echo "Production healthy (HTTP $STATUS)"
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/deploy_key
|
||||
|
||||
22
.github/workflows/main-push-audit.yml
vendored
22
.github/workflows/main-push-audit.yml
vendored
@@ -29,22 +29,12 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const sha = context.sha;
|
||||
// Squash-merge -> PR association is eventually-consistent; poll briefly
|
||||
// before concluding this was a direct push (avoids false positives on merges).
|
||||
async function findMergedPR() {
|
||||
for (let attempt = 1; attempt <= 5; attempt++) {
|
||||
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: sha,
|
||||
});
|
||||
const m = prs.find(p => p.merged_at);
|
||||
if (m) return m;
|
||||
if (attempt < 5) await new Promise(r => setTimeout(r, 10000));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const merged = await findMergedPR();
|
||||
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: sha,
|
||||
});
|
||||
const merged = prs.find(p => p.merged_at);
|
||||
if (merged) {
|
||||
core.info(`OK: commit ${sha.substring(0,7)} came from PR #${merged.number}`);
|
||||
return;
|
||||
|
||||
@@ -147,7 +147,6 @@ 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)
|
||||
@@ -438,14 +437,13 @@ 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. 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_prompt_instructions` | System prompt given to the image rewriter LLM (v0.8.0+); falls back to `Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT` when empty |
|
||||
| `prautoblogger_image_nsfw_retry` | Toggle: retry NSFW-blocked image slots with a rule-based fallback prompt (default: '1') |
|
||||
| `prautoblogger_migrated_image_provider_v080` | One-shot migration flag — auto-heals mismatched provider/model pairs on first admin_init after v0.8.0 upgrade |
|
||||
| `prautoblogger_article_font_family` | Font family key: 'default', 'inter', 'georgia', 'merriweather', 'lora', 'open_sans', 'roboto', 'system' |
|
||||
| `prautoblogger_article_font_size` | Body font size in px (0 = theme default). Recommended: 16–18. |
|
||||
| `prautoblogger_table_borders` | Toggle: add borders/padding/striping to tables (default: '1') |
|
||||
| `prautoblogger_image_style_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_image_style_suffix` | Text appended to every image prompt (default: CEO-locked 90s infomercial prompt) |
|
||||
| `prautoblogger_openrouter_model_registry` | Normalized OpenRouter model list (JSON array, daily refresh, serves as durable cache) |
|
||||
| `prautoblogger_openrouter_model_registry_fetched_at` | Unix timestamp of last successful model registry refresh |
|
||||
|
||||
@@ -478,7 +476,7 @@ Stored on every PRAutoBlogger-generated post:
|
||||
| Reddit RSS | Primary Reddit data source — Atom feeds for subreddit hot posts | None (unauthenticated) | No known rate limit; reliable from datacenter IPs | `providers/class-reddit-json-client.php` |
|
||||
| Reddit .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. **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` |
|
||||
| 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` |
|
||||
|
||||
---
|
||||
|
||||
@@ -577,16 +575,6 @@ 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).
|
||||
|
||||
82
CHANGELOG.md
82
CHANGELOG.md
@@ -5,89 +5,7 @@ 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
|
||||
|
||||
@@ -330,11 +330,6 @@ 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 );
|
||||
}
|
||||
|
||||
|
||||
@@ -6,25 +6,22 @@ declare(strict_types=1);
|
||||
*
|
||||
* Static registry of available image generation 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.
|
||||
* What: Returns a hardcoded list of image models for the admin model picker.
|
||||
* No API discovery — updated manually when providers add/remove models.
|
||||
* As of 2026-04-26, registry includes 21 models across Runware (15) and
|
||||
* OpenRouter (6), 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: PRAutoBlogger_Runware_Model_Catalog (live Runware models),
|
||||
* hardcoded fallback lists for resilience.
|
||||
* Dependencies: None.
|
||||
*
|
||||
* @see admin/class-settings-fields-extended.php — Image settings reference this registry.
|
||||
* @see core/class-image-pipeline.php — Derives provider from picked model.
|
||||
* @see providers/class-runware-image-provider.php — Runware FLUX backend.
|
||||
* @see providers/class-runware-model-catalog.php — Live model sync and caching.
|
||||
*/
|
||||
class PRAutoBlogger_Image_Model_Registry {
|
||||
|
||||
/**
|
||||
* Get all available image generation models (Runware live + OpenRouter static).
|
||||
* Get all available image generation models.
|
||||
*
|
||||
* Each entry contains:
|
||||
* - id: Model identifier used in API calls.
|
||||
@@ -37,44 +34,15 @@ class PRAutoBlogger_Image_Model_Registry {
|
||||
* Ordered cheapest-to-most-expensive within each provider, with the
|
||||
* recommended default (Runware schnell) at the top.
|
||||
*
|
||||
* Last verified: 2026-04-26. To update: check https://runware.ai/models (text-to-image section)
|
||||
* and https://openrouter.ai/api/v1/models filtering output_modalities containing 'image'.
|
||||
* Exclude: inpainting (needs mask), img2img (needs seedImage), video, upscalers, background removal.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function get_models(): array {
|
||||
$catalog = new PRAutoBlogger_Runware_Model_Catalog();
|
||||
$runware_models = $catalog->get_models();
|
||||
$openrouter_models = self::get_openrouter_models();
|
||||
return array_merge( $runware_models, $openrouter_models );
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the provider id for a known model, or empty string if the
|
||||
* model id is not in the registry.
|
||||
*
|
||||
* @param string $model_id Model slug from the admin UI.
|
||||
* @return string Provider id ('runware' | 'openrouter' | '').
|
||||
*/
|
||||
public static function provider_for( string $model_id ): string {
|
||||
foreach ( self::get_models() as $model ) {
|
||||
if ( ( $model['id'] ?? '' ) === $model_id ) {
|
||||
return (string) ( $model['provider'] ?? '' );
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
// RUNWARE MODELS
|
||||
array(
|
||||
'id' => 'runware:100@1',
|
||||
'name' => 'FLUX.1 schnell (Runware)',
|
||||
@@ -195,22 +163,7 @@ class PRAutoBlogger_Image_Model_Registry {
|
||||
'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(
|
||||
// OPENROUTER MODELS
|
||||
array(
|
||||
'id' => 'google/gemini-2.5-flash-image',
|
||||
'name' => 'Gemini 2.5 Flash Image (OpenRouter)',
|
||||
@@ -261,4 +214,20 @@ class PRAutoBlogger_Image_Model_Registry {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the provider id for a known model, or empty string if the
|
||||
* model id is not in the registry.
|
||||
*
|
||||
* @param string $model_id Model slug from the admin UI.
|
||||
* @return string Provider id ('runware' | 'openrouter' | '').
|
||||
*/
|
||||
public static function provider_for( string $model_id ): string {
|
||||
foreach ( self::get_models() as $model ) {
|
||||
if ( ( $model['id'] ?? '' ) === $model_id ) {
|
||||
return (string) ( $model['provider'] ?? '' );
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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_TEMPLATE constants,
|
||||
* Dependencies: PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL, PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX constants,
|
||||
* PRAutoBlogger_Article_Typography (font choices), PRAutoBlogger_Image_Model_Registry.
|
||||
*
|
||||
* @see admin/class-settings-fields.php — Core fields and sections; calls get_extended_fields().
|
||||
@@ -235,12 +235,12 @@ class PRAutoBlogger_Settings_Fields_Extended {
|
||||
'icon' => '🔑',
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_image_style_template',
|
||||
'label' => __( 'Style Template', 'prautoblogger' ),
|
||||
'id' => 'prautoblogger_image_style_suffix',
|
||||
'label' => __( 'Style Suffix', 'prautoblogger' ),
|
||||
'type' => 'textarea',
|
||||
'section' => 'prautoblogger_images',
|
||||
'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' ),
|
||||
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX,
|
||||
'description' => __( 'Appended to every image prompt. Controls visual look. 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 (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' ),
|
||||
'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' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_image_nsfw_retry',
|
||||
@@ -258,13 +258,6 @@ 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' ),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,11 +130,6 @@ 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' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -151,53 +151,6 @@ 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
Normal file → Executable file
1
includes/class-deactivator.php
Normal file → Executable file
@@ -39,7 +39,6 @@ class PRAutoBlogger_Deactivator {
|
||||
'prautoblogger_daily_generation',
|
||||
'prautoblogger_collect_metrics',
|
||||
'prautoblogger_reap_orphan_research_rows',
|
||||
'prautoblogger_sync_runware_models',
|
||||
);
|
||||
|
||||
foreach ( $hooks as $hook ) {
|
||||
|
||||
@@ -116,8 +116,6 @@ 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(
|
||||
@@ -181,7 +179,6 @@ 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' ) );
|
||||
@@ -267,24 +264,6 @@ 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();
|
||||
|
||||
@@ -235,19 +235,19 @@ class PRAutoBlogger_Cost_Tracker {
|
||||
);
|
||||
}
|
||||
|
||||
$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';
|
||||
$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';
|
||||
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Prepared stage list via array_map + prepare
|
||||
$query = $wpdb->prepare(
|
||||
"SELECT AVG(prompt_tokens) as avg_prompt,
|
||||
AVG(completion_tokens) as avg_completion,
|
||||
"SELECT AVG(input_tokens) as avg_input,
|
||||
AVG(output_tokens) as avg_output,
|
||||
COUNT(*) as sample_count
|
||||
FROM $table_name
|
||||
WHERE stage IN ( $stage_list )
|
||||
AND created_at >= %s",
|
||||
$cutoff_datetime
|
||||
AND timestamp >= %d",
|
||||
$cutoff_time
|
||||
);
|
||||
|
||||
// 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_prompt'] ?? 0 ),
|
||||
'avg_completion_tokens' => (float) ( $result['avg_completion'] ?? 0 ),
|
||||
'avg_prompt_tokens' => (float) ( $result['avg_input'] ?? 0 ),
|
||||
'avg_completion_tokens' => (float) ( $result['avg_output'] ?? 0 ),
|
||||
'sample_size' => (int) ( $result['sample_count'] ?? 0 ),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,16 +6,13 @@ declare(strict_types=1);
|
||||
*
|
||||
* Builds image prompts from article content or source data.
|
||||
*
|
||||
* 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
|
||||
* Uses a cheap LLM (via OpenRouter) to distill article content into concise
|
||||
* visual scene descriptions optimised for image generation. Falls back to rule-based
|
||||
* synthesis if the LLM call fails, so image generation never blocks on a
|
||||
* prompt-rewriting outage.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Image_Pipeline during content generation run.
|
||||
* Dependencies: PRAutoBlogger_OpenRouter_Provider (LLM call),
|
||||
* PRAutoBlogger_Image_Template_Filler (template substitution),
|
||||
* PRAutoBlogger_Cost_Tracker (logs prompt-rewrite cost),
|
||||
* PRAutoBlogger_Logger (diagnostics).
|
||||
*
|
||||
@@ -30,39 +27,34 @@ 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 write subject descriptions for a text-free editorial science illustration about peptides, supplements, or biohacking.
|
||||
You are a comedy writer and single-panel cartoon creator, like Gary Larson (The Far Side) meets science humor.
|
||||
|
||||
Given an article title and summary, output TWO parts separated by a blank line:
|
||||
Given an article title and summary about peptides, supplements, or biohacking, create a SINGLE-PANEL COMIC concept. Output TWO parts separated by a blank line:
|
||||
|
||||
SCENE: 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.
|
||||
SCENE: One sentence describing the visual gag — a funny, absurd, or ironic situation related to the article's topic. Favor CONCRETE, HUMAN-SCALE scenes: people reacting to things, doctors and patients, scientists in a lab, gym bros with peptide vials, partners at a kitchen table, ordinary objects in ordinary rooms. Keep it simple — one clear visual joke, 1-3 characters max.
|
||||
|
||||
CAPTION: A short, informative caption line (under 15 words) for display as HTML text below the image. Plain and editorial, not a joke.
|
||||
CAPTION: A short, punchy caption or speech bubble line (under 15 words) that delivers the punchline. The humor should be dry, nerdy, and accessible — the reader should chuckle even if they only half-understand the science.
|
||||
|
||||
Rules:
|
||||
- The 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.
|
||||
- The joke MUST relate to the article's actual subject matter, not generic science humor
|
||||
- DO NOT personify molecules, peptides, hormones, proteins, or other chemical structures (no "anthropomorphic molecule", no "cartoon hormone"). Image models render anthropomorphized chemistry as incoherent blobs. If the article is about a substance, show a PERSON reacting to or using the substance; if the article is abstract (a mechanism, a process, a concept), fall back on a human observer reacting to its effect.
|
||||
- Characters can have simple cartoon faces (round heads, dot eyes, expressive eyebrows)
|
||||
- Keep the scene physically simple — few objects, clear staging, easy to read at thumbnail size
|
||||
- The caption should work as a standalone joke even without the image
|
||||
- No logos, watermarks, or branding
|
||||
- Output ONLY the scene and caption. No preamble, no explanation.
|
||||
|
||||
Example output format:
|
||||
A 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.
|
||||
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 concentrated look at how the compound is prepared before use.
|
||||
"They told me the peptide did the work, not the squats."
|
||||
PROMPT;
|
||||
|
||||
/**
|
||||
* Max tokens for the rewriter response. Enough room for the 1-2 sentence
|
||||
* topic/mechanism summary plus the short editorial caption line.
|
||||
* Max tokens for the rewriter response. Comic concepts need more room
|
||||
* for the scene description plus the caption punchline.
|
||||
*/
|
||||
private const REWRITER_MAX_TOKENS = 180;
|
||||
|
||||
@@ -93,9 +85,8 @@ PROMPT;
|
||||
* Build a visual prompt from finished article content.
|
||||
*
|
||||
* Tries LLM rewriting first; falls back to rule-based synthesis on
|
||||
* 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.
|
||||
* failure. Splits scene (for image gen) from caption (HTML below the
|
||||
* image) and appends the style suffix.
|
||||
*
|
||||
* @param array{post_title?: string, post_content?: string, suggested_title?: string} $article_data
|
||||
* @return array{prompt: string, caption: string}
|
||||
@@ -107,8 +98,10 @@ PROMPT;
|
||||
|
||||
$parsed = $this->rewrite_via_llm( $title, $first_para );
|
||||
|
||||
$style_suffix = $this->get_style_suffix();
|
||||
|
||||
return array(
|
||||
'prompt' => PRAutoBlogger_Image_Template_Filler::fill( $parsed['scene'] ),
|
||||
'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ),
|
||||
'caption' => $parsed['caption'],
|
||||
);
|
||||
}
|
||||
@@ -127,8 +120,10 @@ PROMPT;
|
||||
|
||||
$parsed = $this->rewrite_via_llm( $title, $context );
|
||||
|
||||
$style_suffix = $this->get_style_suffix();
|
||||
|
||||
return array(
|
||||
'prompt' => PRAutoBlogger_Image_Template_Filler::fill( $parsed['scene'] ),
|
||||
'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ),
|
||||
'caption' => $parsed['caption'],
|
||||
);
|
||||
}
|
||||
@@ -265,9 +260,10 @@ PROMPT;
|
||||
* @return array{prompt: string, caption: string}
|
||||
*/
|
||||
public function build_fallback_prompt( string $title ): array {
|
||||
$parsed = PRAutoBlogger_Image_Scene_Parser::synthesize_visual_concepts_fallback( $title, '' );
|
||||
$parsed = PRAutoBlogger_Image_Scene_Parser::synthesize_visual_concepts_fallback( $title, '' );
|
||||
$style_suffix = $this->get_style_suffix();
|
||||
return array(
|
||||
'prompt' => PRAutoBlogger_Image_Template_Filler::fill( $parsed['scene'] ),
|
||||
'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ),
|
||||
'caption' => $parsed['caption'],
|
||||
);
|
||||
}
|
||||
@@ -285,4 +281,20 @@ PROMPT;
|
||||
$override = (string) get_option( 'prautoblogger_image_prompt_instructions', '' );
|
||||
return '' !== trim( $override ) ? $override : self::REWRITER_SYSTEM_PROMPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image style suffix with empty-string safeguard.
|
||||
*
|
||||
* Treats both absent and empty string as "use default" to prevent
|
||||
* silent style loss if the admin settings field is saved empty.
|
||||
*
|
||||
* @return string The style suffix with safe fallback.
|
||||
*/
|
||||
private function get_style_suffix(): string {
|
||||
$style_suffix = (string) get_option( 'prautoblogger_image_style_suffix', '' );
|
||||
if ( '' === trim( $style_suffix ) ) {
|
||||
$style_suffix = PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX;
|
||||
}
|
||||
return $style_suffix;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,25 +70,22 @@ class PRAutoBlogger_Image_Scene_Parser {
|
||||
/**
|
||||
* 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).
|
||||
* 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.
|
||||
*/
|
||||
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.
|
||||
// Fallback produces a simple comic concept 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.',
|
||||
'A cartoon scientist in a lab coat looking bewildered while examining something related to: %s.',
|
||||
$title
|
||||
);
|
||||
|
||||
return array(
|
||||
'scene' => trim( $scene ),
|
||||
'caption' => '',
|
||||
'caption' => 'Science is full of surprises.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
<?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 );
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ declare(strict_types=1);
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"redefinable-internals": [
|
||||
"time"
|
||||
]
|
||||
}
|
||||
@@ -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.16.0
|
||||
* Version: 0.14.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.16.0' );
|
||||
define( 'PRAUTOBLOGGER_VERSION', '0.14.0' );
|
||||
define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' );
|
||||
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
|
||||
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||
@@ -66,22 +66,6 @@ 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
|
||||
|
||||
@@ -63,24 +63,12 @@ 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 legacy ImagePromptBuilder paths / settings UI.
|
||||
// Default image style suffix used by ImagePromptBuilder and settings UI.
|
||||
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX' ) ) {
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX', 'Style: test infomercial style.' );
|
||||
}
|
||||
|
||||
// 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' );
|
||||
|
||||
@@ -113,139 +113,4 @@ 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 );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Tests for PRAutoBlogger_Image_Prompt_Builder.
|
||||
*
|
||||
* Validates prompt generation from article content and source data,
|
||||
* including editorial-template substitution ({{ topic_summary }}) and HTML stripping.
|
||||
* including style suffix appending and HTML stripping.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
@@ -68,9 +68,7 @@ class ImagePromptBuilderTest extends BaseTestCase {
|
||||
[ 'prompt' => $scene, 'caption' => $caption ] = $builder->build_article_prompt( $article_data );
|
||||
|
||||
$this->assertStringContainsString( 'How to Train Your Dragon', $scene );
|
||||
// Editorial template wraps the topic summary; token must be substituted.
|
||||
$this->assertStringNotContainsString( '{{ topic_summary }}', $scene );
|
||||
$this->assertStringContainsString( 'Editorial scientific illustration', $scene );
|
||||
// Style suffix should be appended.
|
||||
$this->assertNotEmpty( $scene );
|
||||
$this->assertGreaterThan( 50, strlen( $scene ) );
|
||||
$this->assertIsString( $caption );
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
<?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'] );
|
||||
}
|
||||
}
|
||||
@@ -16,18 +16,6 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user