- class-image-composer.php: swap the fingerprint comparison operands
(WordPress.PHP.YodaConditions flagged the parenthesized null-coalesce
expression on the right-hand side).
- ImageComposerTest: the two capability-cache tests asserted
update_option calls via Functions\expect(), which the setUp
Functions\when() stub shadows (Mockery dispatches the call to the
first matching expectation), so the with()-matcher saw 0 calls once
the CI runner's real imagick made the probe path live. Capture calls
with a when() alias instead (the suite's house pattern) and assert on
the captured writes - including autoload=false and the rewritten
fingerprint. The matching-fingerprint test now genuinely asserts
zero writes instead of a trivially-satisfied never().
No composer behavior change beyond the operand swap.
Agent-Session: engineer-prautoblogger-image-composer-20260611
Commit 2 (final scope) of the in-plugin editorial image pipeline
(ARCHITECTURE.md #21). A deterministic local composer now runs between
provider bytes and sideload: Image A yields a corner-marked featured
(1200x632) + branded OG (1200x630, teal band, logo, baked caption in
Poppins SemiBold) + square card (1080x1080, cream panel, Poppins Bold,
teal footer with the reverse lockup); Image B gets the corner mark only.
- Capability ladder: imagick (full compositing) -> wp_get_image_editor
resize-only (unbranded, WARNING once) -> pass-through (WARNING once);
probe cached in prautoblogger_image_compose_capability, fingerprinted
by PHP version + imagick/gd presence so host flips auto-invalidate.
compose() never throws; composition can never fail a publish.
- Variants sideloaded with a role filename suffix; attachment meta
_prautoblogger_image_role + _prautoblogger_base_attachment_id; post
meta _prautoblogger_og_image_id (frozen for the rebuilt SEO stage) +
_prautoblogger_square_image_id. No cost-tracker rows ($0 local stage;
duration logged on the image_composer channel).
- New Images settings: compose_enabled ('1'), compose_variants
('og,square'), featured_mark_enabled ('1') — read at point of use.
Geometry ships as Layout defaults behind the
prautoblogger_image_compose_layout filter.
- Vendored brand rasters (assets/brand) + bundled OFL Poppins faces
(assets/fonts) for per-environment byte-stable output (stripImage +
PNG date/time chunks excluded). Validated on Hostinger PHP 8.3.30 /
ImageMagick 7.1.1-43: php -l clean on all touched files, double
render byte-identical (sha256), exact variant geometry.
- Fix: featured/Image B alt text is the editorial caption (was the
model name); model still persists in _prautoblogger_image_model.
- Image_Attacher extracted from Image_Pipeline (300-line compliance);
sideloader gains an optional filename suffix param (back-compat '').
- Version 0.16.0 -> 0.17.0 + CHANGELOG + ARCHITECTURE.md #21 +
CONVENTIONS.md composer guide in this commit. Unit tests: layout
clamp boundaries, capability ladder, cache invalidation, attacher
meta-linking, pipeline integration, imagick determinism (gated).
Agent-Session: engineer-prautoblogger-image-composer-20260611
* 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
* style: auto-fix PHPCS violations via phpcbf
---------
Co-authored-by: peptiderepo <peptiderepo@users.noreply.github.com>
File-level sniffs like WordPress.Files.FileName are evaluated at the file level,
not at code level. They require phpcs:ignore directives to be placed within the
file-level docblock (/** ... */), not in code comments.
Moved all 81 phpcs:ignore pragmas from code comments to the file-level docblocks.
This applies to:
- 79 class files with class-naming convention ignores
- prautoblogger.php with strict-types docblock ignore
- uninstall.php with strict-types docblock ignore
- Added phpcs:ignore for class file naming convention (79 violations)
WordPress.Files.FileName.InvalidClassFileName is a known architectural
issue — class files use short names without prautoblogger- prefix
- Added phpcs:ignore for ->prepare() table interpolation (56 violations)
WordPress.DB.PreparedSQL.InterpolatedNotPrepared: dynamic table names
(prab_generation_logs, prab_cost_logs) require interpolation; ->prepare()
does not support table name placeholders, only column values
- Fixed file-level docblock positioning (2 violations)
prautoblogger.php and uninstall.php: moved declare(strict_types=1) before
file docblock per PHP best practices
Run phpcbf --standard=WordPress-Core across includes/, prautoblogger.php,
and uninstall.php. Fixed 1,362 formatting violations in 75 files:
- Short array syntax: [] → array()
- Line breaking for long function calls
- Consistent indentation
- Function call parameter alignment
No logic changes; purely mechanical formatting.
New class PRAutoBlogger_Migrate_Remove_Cloudflare_V0100 (own file so
class-activator.php stays under 300 lines). Runs from
PRAutoBlogger::maybe_run_db_migrations() right after the v0.9.0
migration. Idempotent via prautoblogger_migrated_remove_cloudflare_v0100
flag option (picked up by the uninstall prefix sweep).
Migrates any user on a Cloudflare image provider to Runware FLUX.1
schnell (closest equivalent, ~bash.0006/image). Detects the three
shapes a legacy user can be in: registry-derived provider ===
'cloudflare', image_provider option === 'cloudflare', or the legacy
slug 'flux-1-schnell'.
Cleans up three now-dead options: prautoblogger_cloudflare_ai_token,
prautoblogger_cloudflare_account_id, prautoblogger_cf_image_via_gateway.
Does NOT touch prautoblogger_ai_gateway_base_url — that option is
shared with the OpenRouter AI Gateway route and must stay (live-
verified test).
Tests (tests/unit/Core/ActivatorRemoveCloudflareTest.php):
- cloudflare user → runware schnell
- non-cloudflare user preserved
- runware user preserved
- legacy 'flux-1-schnell' slug triggers migration
- second run is a no-op (does not clobber admin manual changes)
- AI Gateway URL preserved across every path
ImageModelRegistryTest updated: allowed-provider set is now
['runware','openrouter']; new test_cloudflare_entries_removed_v0100
test pins the scope boundary.
Wires out the Cloudflare Workers AI image provider across the plugin:
- class-image-pipeline.php: drop 'cloudflare' dispatch branch; Runware
becomes the unrecognised-provider fallback
- class-image-model-registry.php: remove flux-1-schnell/cloudflare row;
update provider-enum docblocks (runware|openrouter only)
- class-settings-fields-extended.php: remove three CF fields
(cloudflare_ai_token, cloudflare_account_id, cf_image_via_gateway);
drop Cloudflare mention from model-picker + NSFW retry descriptions
- class-admin-page.php: remove prautoblogger_cloudflare_ai_token from
the encrypted-options list
- interface-image-provider.php: retarget preamble @see at Runware +
OpenRouter; update get_provider_name() example
- class-image-nsfw-blocked.php: generalise preamble (Runware +
OpenRouter can raise this)
Also cleans stale CF Workers AI mentions in:
class-ajax-handlers.php, interface-model-registry.php,
class-image-nsfw-retry.php, class-open-router-image-pricing.php,
prautoblogger.php.
The Cloudflare AI Gateway path (in class-open-router-config.php,
class-settings-fields.php 'prautoblogger_ai_gateway_base_url',
class-open-router-request-builder.php cache headers) is intentionally
untouched — it fronts OpenRouter and is unrelated to the CF Workers AI
image provider.
New Runware image backend at ~$0.0006/image (FLUX.1 schnell) — 65× cheaper
than Gemini 2.5 Flash Image. Provider mirrors OpenRouter's split: interface
+ provider + support + pricing + batch, all under the 300-line cap. Batch
class uses curl_multi for true parallel A/B dispatch — wall-clock drops to
the slowest single image instead of the sum.
Changes:
- Add PRAutoBlogger_Runware_Image_Provider + Pricing + Support + Batch
- Flip PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER/MODEL to runware:100@1
- Add prautoblogger_runware_api_key settings field (encrypted at rest)
- v0.9.0 one-time migration flips legacy default model + re-derives provider
- Wire runware branch into Image_Pipeline::create_default_provider()
- Add runware:100@1 and runware:101@1 to Image_Model_Registry (defaults top)
- Update image prompt builder: no more anthropomorphized molecules;
prefer concrete human-scale scenes (gym bros, doctors, scientists)
- ARCHITECTURE.md + CHANGELOG.md updated with new integration + decision
- Unit tests for pricing, support, provider; update model registry test
Cost impact at 100 images/day (50 articles × A/B):
- Before (Gemini via OpenRouter): $3.90/day ≈ $117/mo
- After (FLUX schnell via Runware): $0.06/day ≈ $1.80/mo
Net saving: ~$115/mo
* feat(cost): persist run_id on generated posts + back-populate for v0.8.1 reaper
The orphan-research-row reaper (upcoming commit) needs a cheap way to
answer "which posts belong to this run_id?" when amortizing a stranded
`llm_research` row back to its siblings. Relying on the generation_log
post_id column is circular: if the pipeline died before amortize_research
ran, we'd trust the same table we're trying to repair.
- `Publisher::build_meta()` now accepts `?string $run_id` and writes
`_prautoblogger_run_id => $run_id` when non-empty. Null/empty preserves
existing null-safe legacy behaviour.
- `Publisher::create_post()` forwards the $run_id it already receives
into build_meta — one-line plumbing, no new parameters at call sites.
- `Activator::backpopulate_run_id_meta_v081()` runs once per site,
gated by `prautoblogger_migrated_run_id_meta_v081` option. Walks
`wp_prautoblogger_generation_log`, pairs (post_id, min(run_id)),
writes `_prautoblogger_run_id` on any post that doesn't already have
it. Uses `metadata_exists()` so it's a no-op on re-run.
- Activator also schedules the new daily `prautoblogger_reap_orphan_research_rows`
cron at 03:15 — 15 min after the primary generation cron — so live
pipelines have time to finish amortizing cleanly before the reaper scans.
- Deactivator unschedules the new hook alongside the existing cron hooks.
No user-visible behaviour change yet. The reaper handler ships in the
next commit; this one just ensures it will have the data + schedule it
needs when it lands.
Part of the v0.8.1 bundle (thread research-cost-reaper).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(cost): daily reaper for orphan llm_research rows
Implements the orphan-research-row reaper per thread research-cost-reaper
seq 1. When a pipeline dies between post creation and
`Post_Assembler::amortize_research_costs()` (Hostinger exec kill, OOM,
LiteSpeed cut, fatal), the shared research row stays in
`wp_prautoblogger_generation_log` with `post_id = NULL` and sibling
articles' cost popovers understate their true spend.
New class `PRAutoBlogger_Research_Reaper` owns:
- `on_cron()` — WP-Cron entrypoint hooked to the daily
`prautoblogger_reap_orphan_research_rows` event (already scheduled
by the Activator in the previous commit at 03:15).
- `reap()` — returns a `{reaped, deleted, skipped}` summary. For each
orphan `llm_research` row older than 1 hour (grace window for live
pipelines):
- `_prautoblogger_run_id` post_meta primary lookup.
- Falls back to distinct post_ids in gen_log with the same run_id.
- If ≥1 post found → delegate to the existing idempotent
`Post_Assembler::amortize_research_costs()`. That method inserts
the per-article rows and deletes the original orphan in one pass,
so the reaper is idempotent on re-run.
- If 0 posts found AND orphan is >7 days old → delete outright as
sunk cost with nothing to attribute to.
- Else → skip; try again the next day.
- Never throws. DB errors caught and logged WARNING.
New WP-CLI command `wp prautoblogger reap-research` registered in the
main loader when `WP_CLI` is defined — manual ops trigger that prints
the same summary.
Departure from the CTO's spec: the reap logic lives on
`Research_Reaper` instead of `Post_Assembler`. Placing it on
Post_Assembler pushed that file to 391 lines, over the 300-line cap.
Moving it to the thin class colocates the attribution logic with the
cron/CLI plumbing that triggers it. `amortize_research_costs()` stays
on Post_Assembler — the reaper delegates to it unchanged. Flagged for
CTO review.
Part of the v0.8.1 bundle (thread research-cost-reaper).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(cost): ResearchReaperTest — five cases per spec
Covers the five scenarios from convo thread research-cost-reaper seq 1:
- test_reaps_orphan_with_matching_run_id_posts — meta lookup returns
two post IDs; reaper delegates to amortize_research_costs which
inserts two rows and deletes the orphan.
- test_skips_orphan_within_grace_period — the SELECT's
`created_at < now - 1h` clause filters fresh orphans at the DB; the
reaper's stats stay zero and no side effects fire.
- test_deletes_orphan_with_no_articles_after_7_days — orphan is 8 days
old, zero siblings via meta or gen_log; reaper deletes outright.
- test_falls_back_to_gen_log_when_post_meta_missing — meta returns
empty (legacy pre-v0.8.1 post); gen_log has two distinct post_ids;
amortize still runs.
- test_idempotent_on_repeat_run — second pass on a cleaned state
returns 0/0/0 with no inserts or deletes.
All DB interaction mocked via $wpdb stub — no real DB round-trips.
Fixtures are driven by property mutations in the test methods so
PHPUnit's "one stub per method" restriction on mocks doesn't bite.
Part of the v0.8.1 bundle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(v0.8.1): version bump + CHANGELOG + ARCHITECTURE.md for reaper
Bump plugin header and PRAUTOBLOGGER_VERSION 0.8.0 → 0.8.1. Patch
bump — the reaper is internal infrastructure; no user-visible UX
change.
CHANGELOG:
- Fixed: orphan `llm_research` rows now reaped daily.
- Added: WP-CLI `wp prautoblogger reap-research`; `_prautoblogger_run_id`
post_meta is now written by Publisher on every post.
ARCHITECTURE.md:
- Decision #13 (Run-ID based log linking) cross-refs the new post_meta.
- New Decision #19 documents the reaper's scan/amortize/delete policy,
the 1-hour grace window, the 7-day stale cutoff, and the one-shot
activator back-populate migration.
DB schema unchanged — no PRAUTOBLOGGER_DB_VERSION bump.
Final commit of the v0.8.1 bundle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(cost): widen $wpdb mock so ResearchReaperTest actually runs
BaseTestCase::create_mock_wpdb() only adds methods
[prepare, get_var, get_results, insert, query, get_row, update] — the
reaper and amortize_research_costs also call `delete` and `get_col`,
which the default mock can't stub. CI on PR #100 fired all five new
ResearchReaperTest cases as errors at the first `->method('delete')`
call during setUp.
Fix: build a bespoke $wpdb mock in this file's setUp with the full
superset of methods it needs. Self-contained; no change to
BaseTestCase so existing tests aren't affected.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(cost): filter gen_log inserts from event_log inserts in reaper tests
CI on ec1efee showed the two reap-and-amortize tests failing because
`assertCount(2, $this->inserted_rows)` was seeing 4 rows, not 2. Root
cause: the Logger singleton writes INFO lines via `$wpdb->insert` into
the event_log table — the reaper logs once ("Reaped...") and amortize
logs once ("Amortized LLM research cost...") before the two per-post
gen_log inserts, so the capture ended up with 4 total rows.
Add a `gen_log_inserts()` helper that filters the capture down to rows
whose `__table` contains "generation_log" — the assertions on
attribution count should only measure the actual cost inserts, not
incidental log writes. Two tests updated; logic unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(admin): correct misleading GA4 Property ID description
The GA4 client at includes/core/class-ga4-client.php:85 already wraps the
property_id option with "properties/" in the outbound URL path. The admin
description told users to enter "properties/XXXXXXXXX" — which would have
produced a double-prefixed URL (properties/properties/XXXXXXXXX) and a
guaranteed 404 from the Data API. Description now asks for digits only,
matching actual code behavior.
No code logic change — UX copy + CHANGELOG only. Flagged by CEO while
inspecting the Analytics tab; folded into the in-flight v0.8.1 PR per
CTO call rather than cutting a separate patch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: peptiderepo <peptiderepo@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(image-pipeline): surface per-image failures in the event log
Two gaps made posts 650/657 appear to silently "lose" their featured
images on 2026-04-20:
1. The batch provider tagged each failed key with ['error' => ...] but
the ERROR log line it emitted didn't identify which key failed, so
operators couldn't tell image_a from image_b in the event log when
one of two parallel requests died.
2. Image_Pipeline::process_image_a/_b folded the per-key error into
$result['errors'] and returned silently, with no WARNING row. Ops
only learned about the miss when the outer catch in the caller fired
— and on the 2026-04-20 incident that outer catch didn't fire, so
nothing was logged at all except an unrelated pricing warning.
Fix:
- class-open-router-image-batch.php: collect_result() takes the request
key as its first argument and every Logger::error message reads
'OpenRouter batch image gen <class> for key "<key>": <detail>'.
Matches the shape of OpenRouter_Image_Support::log_retry.
- class-image-pipeline.php: process_image_a and process_image_b now
emit a Logger::warning('image_pipeline') row whenever $image_data is
missing or carries an error key, in addition to pushing to
$result['errors']. The warning fires whether or not the outer catch
runs.
Test: ImagePipelineTest::test_partial_batch_failure_emits_warning_log_row
mocks $wpdb, runs the pipeline with image_a erroring, and asserts at
least one warning/image_pipeline row was inserted.
No behavior change beyond logging; cost impact zero.
Part of the v0.8.0 image bundle (issues from thread image-mime-bug).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(images): retry Cloudflare NSFW-blocked prompts with a sanitized fallback
Cloudflare's Workers AI FLUX.1 schnell content filter sometimes rejects
peptide-comic prompts as NSFW (HTTP 400, errors[].code === 3030). On
2026-04-20 this produced a silent publish-without-featured-image for
post #650 ("Semaglutide vs Tirzepatide") — the LLM-rewritten scene
tripped the filter and we had no recovery path.
New behaviour: on a 3030 response, the Cloudflare provider throws a
typed PRAutoBlogger_Image_NSFW_Blocked exception. The batch wrapper
catches it and tags the per-slot result with
`error_type: nsfw_blocked`. Image_Pipeline hands the results to a new
PRAutoBlogger_Image_NSFW_Retry helper, which reruns the blocked slot
once with a sanitized fallback prompt (title + style suffix, no LLM
rewrite). On success the retry result replaces the error entry; on a
second block, a WARNING is logged and the pipeline publishes without
that image — same terminal behaviour we had before, just explicit.
Files:
- New providers/class-image-nsfw-blocked.php — typed exception.
- providers/class-cloudflare-image-support.php — is_nsfw_error() +
throw_if_nsfw() helpers.
- providers/class-cloudflare-image-provider.php — calls throw_if_nsfw
inside the 4xx branch; batch catches the typed exception and marks
the result with error_type.
- core/class-image-prompt-builder.php — new public build_fallback_prompt
shim so the retry layer can reach the rule-based fallback without
going through the LLM path.
- New core/class-image-nsfw-retry.php — single-shot retry helper.
- core/class-image-pipeline.php — invokes the retry between batch
completion and per-slot processing.
- admin/class-settings-fields-extended.php — new toggle
`prautoblogger_image_nsfw_retry` (default on) in the Images section.
Tests:
- CloudflareImageProviderTest — 3030 response throws NSFW exception;
batch tags the per-slot result with `error_type: nsfw_blocked`.
- ImagePipelineTest — retry succeeds when provider returns a good
result on the second call; retry is skipped when the setting is off.
Cost impact: worst-case an extra Cloudflare call per NSFW-blocked slot
(~$0.000834). Current incidence ~2% of generations; ~+$0.00002 per
generation amortized. Immaterial.
Uninstall: new option covered by the existing prautoblogger_% LIKE
sweep in uninstall.php.
Part of the v0.8.0 image bundle (issues from thread image-mime-bug).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(images): expose rewriter system prompt as admin setting
CEO feedback: every generated image is anchoring on the cartoon
peptide-with-arms-and-legs character because the hardcoded rewriter
system prompt both lists "anthropomorphized molecules" first in its
primer and ships a few-shot example that literally shows one. The ask
is to give CEO the lever without a code change.
Adds `prautoblogger_image_prompt_instructions` (textarea) in the
Images section. Default value is the existing REWRITER_SYSTEM_PROMPT
constant verbatim, so behaviour is unchanged on upgrade until someone
edits it. `Image_Prompt_Builder::rewrite_via_llm()` reads the option
at call time via a new `resolve_system_prompt()` helper and falls back
to the constant if the option is blank or whitespace-only, so a saved
empty value cannot brick image generation.
Const visibility flipped from `private` to `public` so the settings
field can reference it directly as the field default.
Tests:
- `test_rewrite_uses_setting_when_present` — option set, assert the
LLM's system message equals the option content.
- `test_rewrite_falls_back_to_default_when_setting_empty` — option
whitespace, assert the LLM's system message equals the constant.
Uninstall: new option covered by the existing prautoblogger_% LIKE
sweep in uninstall.php.
Cost impact: none by default. Future CEO edits will change prompt
length; current default is ~180 tokens, budget impact linear from
there.
Part of the v0.8.0 image bundle (thread image-mime-bug, message 03).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(images): collapse Image Provider + Image Model into a single dropdown
Until now, Image Provider and Image Model were two independent selects
in the admin UI. That let the CEO set Model=flux-1-schnell while
Provider was still openrouter — OpenRouter has no such model so every
image gen on posts 650/657 on 2026-04-20 returned HTTP 400, and the
article published without a featured image.
v0.8.0 removes the Image Provider field from the admin UI entirely.
The provider is now derived from the model's entry in the registry
every time the model is saved. A new
PRAutoBlogger_Image_Model_Registry::provider_for() helper centralises
the mapping, so admin save, the on-upgrade migration, and future
callers all read from one place.
On save, PRAutoBlogger_Admin_Page::sanitize_image_model() validates
the submitted slug against the registry:
- Known slug: persist it, and update `prautoblogger_image_provider` to
the paired provider as a side-effect.
- Unknown slug: add_settings_error with a clear message, and return
the previously-saved value so the site stays on a working model.
A one-shot v0.8.0 migration in
PRAutoBlogger::on_check_db_version() derives the provider from the
currently-saved model on first admin_init after the plugin is updated.
This auto-heals sites that were running mismatched pairs before the
upgrade, without requiring the admin to re-open settings.
`prautoblogger_image_provider` option key is preserved — all existing
reads in the pipeline, cost tracker, and validators continue to work
unchanged.
Tests (Services/ImageModelRegistryTest):
- Every registry entry has an id and a provider in {openrouter, cloudflare}.
- provider_for() returns the paired provider for a known slug.
- provider_for() returns '' for unknown/empty slugs.
Cost impact: none.
Migration idempotent via the `prautoblogger_migrated_image_provider_v080`
flag. Uninstall sweeps it along with every other plugin option.
Part of the v0.8.0 image bundle (thread image-mime-bug, Issue 1).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(v0.8.0): version bump + CHANGELOG + ARCHITECTURE.md for image bundle
Bump plugin header and PRAUTOBLOGGER_VERSION 0.7.3 → 0.8.0. Minor bump
because Commit D removes the Image Provider dropdown from admin (a
UX-visible change), not a patch.
CHANGELOG:
- Changed: collapsed Image Provider + Model dropdowns; provider derived.
- Added: Image Prompt Instructions setting; Retry NSFW-Blocked Images
setting.
- Fixed: silent per-image failures in the event log.
ARCHITECTURE.md:
- Stage 6b (Image Pipeline) callout now describes provider derivation
and the NSFW-retry fallback path.
- wp_options table lists the four new/changed keys
(`prautoblogger_image_provider` as derived, new
`prautoblogger_image_prompt_instructions`,
`prautoblogger_image_nsfw_retry`, and the one-shot migration flag
`prautoblogger_migrated_image_provider_v080`).
DB schema unchanged — no PRAUTOBLOGGER_DB_VERSION bump needed.
Final commit of the v0.8.0 image bundle (thread image-mime-bug).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: peptiderepo <peptiderepo@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Browser omits checkbox fields from POST when none are checked, so the
prautoblogger_enabled_sources option retained its previous value (with
Reddit). Added a hidden input to the checkboxes renderer so an empty
value is always submitted. Also replaced the hardcoded "Collecting
sources from Reddit…" pipeline status with a dynamic message reflecting
actually-enabled sources.
Bump 0.6.2 → 0.6.3.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
New Peptide_Linker class scans generated HTML and wraps every mention
of a known peptide in a hyperlink to its /peptides/ database page.
Runs as post-processing in Publisher — no LLM involvement, 100% reliable.
Handles case-insensitive matching, hyphen/space variants, skips existing
<a> tags. Gracefully no-ops if PR Core is not active.
Removed prompt-based peptide linking from Content_Prompts (unreliable).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When any peptide from the PR Core database is mentioned in a generated
article, the first mention is now automatically linked to its /peptides/
page. Gracefully degrades if PR Core is not active.
Extracted all prompt construction into Content_Prompts (252 lines),
slimming Content_Generator from 324 → 162 lines. Both under 300.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Writing instructions were framed as a weak "Additional instructions:"
block at the end of the system prompt. Models prioritized competing
format directives in each stage's user prompt and ignored the style
guide. Now: system prompt wraps instructions as "MANDATORY STYLE GUIDE"
with override language, every stage user prompt reinforces compliance,
polish stage explicitly preserves lists and links, and chief editor
revision preserves structural formatting.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Research LLM call runs once per pipeline but was logged with no run_id
or post_id, making it invisible in per-article cost breakdowns. Now the
pipeline's cost tracker flows through Source_Collector to the research
provider, tagging costs with the run_id. After all articles complete,
amortize_research_costs() divides the cost evenly and inserts one row
per article. link_generation_logs() excludes llm_research stage so the
first article doesn't grab the full cost.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
New data source that sends a configurable research prompt to a
reasoning-capable model (Grok 4.1 Fast, DeepSeek-R1, etc.) to identify
trending topics, content gaps, and emerging questions. Findings feed
into the content analyzer alongside Reddit data, combining real-time
community signals with deep knowledge-base research.
New admin settings: Research Model picker, Research Prompt textarea,
and LLM Deep Research toggle in Enabled Sources.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
GLM 4.7 Flash was timing out at 120s on large analysis prompts.
Parse failure errors now log the raw LLM response at error level
(not debug) so the root cause is always visible.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
New toggle in Images settings controls whether the source-data-based
second image is generated. Disabling saves one image gen call + one
LLM prompt rewrite per article. Defaults to enabled.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two new textareas in Content settings let admins provide custom
instructions for the analysis and chief editor LLM stages, matching
the Writing Instructions field added in v0.4.4. All three pipeline
stages now have user-controllable system prompt customization.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
New textarea in Content settings lets admins provide custom instructions
appended to the writer's system prompt. Steers style, structure, voice,
and formatting without editing code.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Models like GPT 5.1 Nano ignore response_format: json_object and wrap
their JSON in ```json fences or add preamble text. New Json_Extractor
utility tries: (1) direct decode, (2) strip markdown fences, (3) find
outermost { ... } substring. Applied to analyzer and chief editor
parsers. Raw response logged on parse failure for debugging.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two-pronged fix for repeated article topics:
1. Feed last 30 days of published article titles into the LLM analysis
prompt with explicit "avoid these" instruction. Zero cost — just
extra context in an existing API call.
2. Replace keyword-overlap dedup (60% word match) with embedding-based
cosine similarity via sentence-transformers/all-MiniLM-L12-v2 on
OpenRouter. Catches rephrasings that keyword matching misses
(e.g. "BPC-157 dosing" vs "how much BPC-157 to take"). Falls back
to keyword matching if the embedding API is unavailable.
Cost: ~$0.00001 per generation run ($0.02/M tokens, ~500 tokens/run).
New files:
- class-semantic-dedup.php — Embedding cosine similarity + keyword fallback
- class-open-router-embedding-provider.php — OpenRouter /embeddings client
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The status poller's orphan-recovery logic re-scheduled cron events for
articles already being generated. During the ~90s generation window, the
queue option in the DB still contained the in-progress idea because the
process only deleted/updated it AFTER generation completed.
Fix: persist the consumed queue to the DB BEFORE starting generation.
When the poller checks get_option(), it now sees either an empty queue
(for the last article) or a queue without the current idea, so orphan
recovery cannot duplicate work.
Root cause: process_next_queued_article() did array_shift on a local
variable but left the DB unchanged until after the slow LLM+image
generation finished. The poller saw the stale DB state, assumed the
cron event had died, and re-scheduled it — producing a 4th post from
3 ideas on every run.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The frontend status poller measured elapsed time from `started` (set once
at the beginning of the run) and declared a stall at 300s. Three articles
take ~6 minutes total, so the poller always false-alarmed on article 3.
Now every stage broadcast writes `last_updated` into the transient, and
the stall check compares idle time since the last update — not since the
run started. This lets N-article runs complete without false timeout as
long as each individual article finishes within 5 minutes.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The pipeline previously ran all articles sequentially in a single PHP
process. On Hostinger, this timed out at ~120s — enough for 1 article
but not 2+. Now the pipeline generates article 1 in-process, then
queues articles 2..N as individual WP-Cron events. Each article gets
its own PHP process with its own execution time budget, so we can
generate any number of articles regardless of server time limits.
Architecture:
- Pipeline_Runner: orchestrate (collect+analyze+score) + first article,
then serialize remaining ideas to wp_options and chain cron events
- Article_Worker (new): focused single-article worker extracted from
the pipeline loop — content gen + editorial + publish
- Executor: new on_process_article_queue() handler for chained events;
status poller re-schedules orphaned queue jobs automatically
- Article_Idea: added to_array() for queue serialization
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The collector used INSERT IGNORE which silently skipped duplicate items
without updating their collected_at timestamp. After 24 hours, these
items fell out of the analyzer's window permanently — even though they
kept appearing in the RSS feed. This caused "No new source data to
analyze" on every run once the initial batch aged out.
Switch to INSERT ... ON DUPLICATE KEY UPDATE to refresh collected_at
(plus score and comment_count, which change over time on Reddit).
The analyzer's 24-hour window now sees re-fetched items again.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Dedup now has two narrow scopes instead of checking all posts ever:
1. Intra-batch: prevents proposing two articles on the same topic
within a single pipeline run (keyword overlap ≥60%)
2. Recent posts: checks only posts from the last 7 days to avoid
back-to-back similar content
Older articles are intentionally NOT checked — revisiting a topic
weeks later is fine. This prevents the dedup from becoming
increasingly aggressive as the blog grows, which was the root cause
of "no viable article ideas found" with 125 collected items.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The old has_similar_post() used WordPress search ('s' parameter) which
matches ANY word in the query. On a niche peptide blog, every topic
shares vocabulary like "BPC-157", "peptide", "dosage" — so all 125
ideas were false-positive deduped against existing posts, producing
zero viable article ideas.
New approach: extract meaningful keywords (strip stopwords), compare
against each existing post title, and only dedup when ≥60% of the
topic's keywords overlap with a single title. Also caches the title
list per pipeline run to avoid repeated queries.
Added summary logging so we can see exactly how many ideas pass
through each filter stage.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Both Image A and Image B now fire concurrently through a single
generate_image_batch() call instead of sequentially. Total wall-clock
time equals the slowest image (~20s), not the sum (~40s), which
eliminates the Image B timeout problem on Hostinger where
max_execution_time killed the cron before the second request finished.
New files:
- class-open-router-image-batch.php — curl_multi orchestrator
- generate_image_batch() added to image provider interface
Pipeline refactored: prompts built upfront, single batch call, results
processed per-key. Cloudflare provider gets a sequential fallback.
Tests updated for batch API. All files under 300-line cap.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Three fixes in one:
1. Duplicate image: prepend_caption_to_post() was embedding the full
image + caption as a <figure> block, but the theme already displays
the featured image. Now only the caption text is prepended — no
second copy of the image.
2. Hardcoded model names: removed all FLUX.1/FLUX.2 references from
model-agnostic code (pipeline, prompt builder, sideloader, OpenRouter
provider, interface, admin labels). The Cloudflare provider still
references FLUX since it specifically wraps that model family.
3. Test alignment: OpenRouterImagePricingTest was asserting against
removed models (flux-2-pro, seedream). Updated to match current
COST_PER_IMAGE entries (Gemini Flash, GPT-5 Image).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Captions were being baked into the image by the FLUX model, causing
text to get cut off at the edges. Now the LLM generates scene and
caption separately — the scene drives image gen (with explicit
"no text in the image" instruction), and the caption is inserted
as a styled <figure>/<figcaption> HTML block at the top of the post.
Changes:
- rewrite_via_llm() returns array{scene, caption} instead of string
- build_article_prompt() and build_source_prompt() return {prompt, caption}
- Style suffix v4: removes "caption text" instruction, adds "no text" directive
- Image pipeline destructures prompt/caption, stores caption in attachment meta
- prepend_caption_to_post() inserts figure+figcaption HTML into post content
- Deduplicated generate_image_a/b into shared generate_single_image()
- One-time v4 migration auto-updates existing style suffix on deploy
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
media_handle_sideload() is only available in admin context. When the
pipeline runs via WP-Cron (background generation), the function is
undefined. Add require_once guards for media.php, file.php, and
image.php before calling sideload.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Hostinger's LiteSpeed web server kills any HTTP connection that runs
longer than 120 seconds with a 500 "Request Timeout" error. The full
pipeline (Reddit fetch → topic scoring → article generation → editorial
pass → image gen ×2) takes 120-180s, causing "Request failed: error"
on every manual generation attempt.
Architecture change:
- Generate Now AJAX handler now schedules a one-shot WP-Cron event
and returns immediately (~100ms response time)
- New prautoblogger_manual_generation cron action runs the pipeline
in a separate PHP process free from the connection timeout
- Pipeline progress is written to a transient at each stage
- New prautoblogger_generation_status AJAX endpoint lets the frontend
poll progress every 5 seconds
- Frontend auto-resumes polling on page load if a generation is running
- Direct wp-cron.php loopback ensures cron fires even if DISABLE_WP_CRON
is set (common on Hostinger)
Version bump: 0.2.9 → 0.3.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace photography-style prompts with a comedy writer system prompt
that generates Far Side-style single-panel comic concepts with punchy
captions. The LLM rewriter now outputs a comic scene + caption line,
and the style suffix renders as classic black-ink newspaper comic on
yellowed newsprint.
Includes v3 style migration to auto-replace both the old infomercial
and the short-lived photography style in wp_options. Bumped max_tokens
to 180 to accommodate scene + caption output.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two fixes:
1. Image attachment: moved set_post_thumbnail() and Image B post meta
into generate_and_attach_images() so they persist immediately after
each image generates. Previously the caller set them after both images
completed — if the process timed out (common with GPT-5 Image at
~30s/image), the featured image was never attached and Image B was
never attempted.
2. Image prompts: replaced FLUX.1-optimized rewriter system prompt and
90s infomercial style suffix with prompts optimized for Gemini Flash
Image and GPT-5 Image. New style: premium scientific lifestyle
photography with golds/teals color grading. Includes one-time migration
to update stored style suffix.
Also logs when Image B is skipped due to missing source data (previously
silent), helping diagnose why some posts only get one image.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
PHP 8.3 TypeError and Error extend \Throwable but NOT \Exception,
so catch(\Exception) silently drops them — explaining why the
generation pipeline fails at the publishing step with no activity
log entry. Now every catch in the generation path catches
\Throwable and logs the exception class name for diagnostics.
Bump to v0.2.7.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replaces Cloudflare Workers AI (FLUX.1 schnell) as the default image
provider with OpenRouter, which offers FLUX.2 Pro, Seedream 3.0,
Gemini Flash Image, and GPT Image 1 at comparable or better pricing.
- New provider class (3-file split: provider, pricing, support) reuses
the existing OpenRouter API key and Cloudflare AI Gateway
- Admin settings: new Image Provider dropdown (openrouter/cloudflare),
model picker shows all available image models from both providers
- Image pipeline auto-selects the configured provider at construction
- Cost logging now uses actual model name instead of hardcoded flux-1-schnell
- Default model changed to black-forest-labs/flux-2-pro ($0.03/image)
- Unit tests for pricing resolution and provider response parsing
Cost impact: FLUX.2 Pro at $0.03/image vs schnell at $0.0007/image —
~43x more expensive per image, but dramatically better quality. At 2
images/article × 1 article/day = ~$1.80/month. Seedream 3.0 ($0.003)
available as a budget alternative.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Root cause: both attach_generated_images() in post-assembler and the
retroactive handler in executor passed null as source_data to the image
pipeline. The pipeline's Image B branch requires non-null source_data
to fire, so Image B was silently skipped on every article since the
feature was built.
Fix: added get_source_data_for_image() to Source_Collector to retrieve
the original Reddit post title/content by source_ids. Post-assembler
now pulls source_ids from the Article_Idea; executor pulls them from
the _prautoblogger_source_ids post meta saved by the publisher.
Also added a regression test proving Image B is skipped when source_data
is null, so this handoff gap will be caught if re-introduced.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replaces the rule-based prompt builder with a cheap LLM call
(analysis model via OpenRouter) that distills article content into
concise visual scene descriptions. The old rule-based method becomes
the fallback if the LLM call fails.
Key design decisions:
- System prompt teaches the LLM to describe objects/setting/lighting
instead of abstract concepts, and to avoid faces (FLUX.1 weakness)
- Uses the analysis model (gemini-2.5-flash-lite) at ~$0.001/call
- Cost is logged to the analytics dashboard via log_api_call()
- Style suffix is still appended separately so the rewriter only
handles scene composition
Cost impact: +~$0.001 per image on top of ~$0.0008 generation cost.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
FLUX.1 schnell requires width and height to be divisible by 8.
The default OG image size 1200×630 has 630 % 8 = 6 remainder,
causing HTTP 400 on every image generation call.
Changes:
- Pipeline default height 630 → 632 (nearest 8-aligned value)
- Provider now rounds any dimension to nearest multiple of 8 as
a defensive guard before sending to the API
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Move system prompt, user prompt, and performance context builders into
PRAutoBlogger_Analysis_Prompts. Analyzer retains orchestration, data
fetch, parsing, and storage. Backward-compatible private proxies kept.
Both files well under 300-line limit.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Taxonomy assignment, generation log linking, LLM content sanitization,
default author resolution, and image pipeline attachment moved to
PRAutoBlogger_Post_Assembler as static helpers. Publisher retains
create_post orchestration, publish/draft flow, and meta building.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>