Bug 1: Image style suffix empty-string bypass
- get_option() for prautoblogger_image_style_suffix returns empty string from DB instead of default
- Root cause of post #675 rendering photorealistically instead of newspaper comic
- Fixed by extracting private helper get_style_suffix() that treats both absent AND empty as default
- Applied to all three call sites in class-image-prompt-builder.php (lines 85, 107, 272)
Bug 2: Image cost logged under wrong run_id
- class-image-pipeline.php created fresh cost tracker when invoked without argument
- Image costs logged under different run_id than article's other stages
- Cost breakdowns aggregating by run_id silently excluded image line items
- Fixed by threading article worker's cost tracker through Publisher to image pipeline
- Modified publisher.php and post-assembler.php to accept and pass cost_tracker
- Updated article-worker.php to pass cost_tracker to publisher methods
- Image costs now share same run_id as analysis, draft, review, and llm_research stages
Version: 0.12.1 -> 0.12.2
CHANGELOG: Added comprehensive entry describing both fixes
Opik requires version 7 UUIDs. wp_generate_uuid4() was producing v4
which the API rejected with 422. Replaced both call sites in
class-opik-trace-context.php with generate_uuid7(), a private static
method implementing RFC 9562 §5.7.
Fixes: smoke test 422 {"errors":["Trace id must be a version 7 UUID"]}
Dynamic table names ({}, {}) are used in
database queries and cannot be parameterized with $wpdb->prepare() which only
supports value placeholders, not table-name placeholders.
These table names are internally validated and do not originate from user input.
Excluding WordPress.DB.PreparedSQL.InterpolatedNotPrepared from the ruleset.
Created phpcs.xml ruleset that inherits from WordPress-Core but excludes
WordPress.Files.FileName.InvalidClassFileName sniff. This is an architectural
choice: PRAutoBlogger uses short class names (class-logger.php) instead of
fully-qualified names (class-prautoblogger-logger.php) for better readability.
Updated CI workflow to use phpcs.xml instead of --standard=WordPress-Core.
Remaining violations (56 wpdb + 2 docblock) are still addressed with inline
phpcs:ignore pragmas with documented reasons.
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
- PRAUTOBLOGGER_VERSION constant now matches header (0.10.1)
- CHANGELOG.md updated to accurately reflect Round 3 work:
* OpenRouter test encryption fix (real class round-trip)
* 137 PHPCS violations resolved with targeted ignores + reasons
* Clear accounting of which categories of violations and why ignored
- 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
Adds missing wp_salt() and wp_generate_uuid4() stubs to BaseTestCase::setUp()
so tests that call PRAutoBlogger_Encryption (for API key decryption) don't fail.
Fixes PublisherTest::test_publish_links_generation_logs_by_run_id() which was
capturing the wrong database query (SELECT from SourceCollector) due to test
fixture including source_ids. Now creates a fixture override without source_ids
to isolate the run_id linking assertion.
Related: Failures #1 and #2 from PR #109.
Bump version: 0.10.0 → 0.10.1
Updated CHANGELOG.md with Fixed/Changed entries:
- Missing post_type_exists() stub in BaseTestCase
- PHPCS autofix of 1,362 violations across 75 files
- CI gate flipped to strict mode
Updated ARCHITECTURE.md with ADR #20 documenting the test infra
and PHPCS compliance improvements.
Change continue-on-error from true to false for the PHPCS check.
Now that all 1,362 violations are fixed via phpcbf, PHPCS failures
will block CI. This prevents regression of style standards.
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.
When PR Core is not active, PeptideLinker guards calls with post_type_exists().
This stub was missing from base setup, causing 8 PublisherTest errors.
Adds Functions\when('post_type_exists')->justReturn(false) to setUp().
When a PR modifies the admin settings surface, file/class structure, or data flow, the QA reviewer now flags the PR as a MAJOR concern unless the PR description references an accompanying update to the CTO app-map at `Peptide Repo CTO/app-maps/<repo-slug>.md`.
The CTO workspace is a separate repo, so CI cannot verify the doc update mechanically — the reviewer inspects the PR description for an explicit mention.
Why: the CTO-level app-maps are the single-pane-of-glass reference that lets the CTO answer questions like 'which settings does this plugin expose?' without grepping the repo each time. Without a merge-gate prompt, these maps drift out of sync with the code.
Co-authored-by: peptiderepo <peptiderepo@users.noreply.github.com>
On v0.9.0 this constant was implicitly defined by CloudflareImageProviderTest::setUp()
— deleted in this PR. OpenRouterImagePricingTest relied on PHPUnit's alphabetical
file order (C before O) to have the constant defined before its assertion.
Deleting the CF test broke that chain; OR pricing test errored with 'Undefined
constant "PRAutoBlogger\\Tests\\Providers\\PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL"'.
Fix: hoist the define into tests/bootstrap.php alongside the other
PRAUTOBLOGGER_DEFAULT_* constants. No test ordering dependency remains.
The set_default_options() block was still seeding the dead CF Workers AI gateway
toggle on fresh activations. Removed alongside its settings field + migration
delete-sweep so uninstall → install cycles stop re-adding the option.
Residual from commit 21430ce. Caught in local verification before PR open.
- prautoblogger.php: plugin header Version + PRAUTOBLOGGER_VERSION
- CHANGELOG.md: Removed/Changed/Note/Cost impact/Migration sections
- ARCHITECTURE.md:
- drop 4 CF provider files from the file tree
- drop 2 CF credential options from the wp_options table
- drop CF Workers AI row from the External API Integrations table
- flip Phase 3 placeholder from CF Workers AI registry to Runware
- supersede ADR #16 with a one-line history note pointing at v0.10.0
- update ADR #17 tail to mention the removal
readme.txt stable tag is stale (0.2.2) and clearly not maintained
inline with real releases — leaving alone in this PR; fix belongs in
a dedicated release-process cleanup.
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.
Drops the four CF provider classes and their two test files. The
OpenRouter AI Gateway path is unaffected — it reads
prautoblogger_ai_gateway_base_url via class-open-router-config.php
directly, with no dependency on the CF support class.
Leaves class-cost-tracker.php line 165 'cloudflare-workers-ai' label
intact for historical log integrity.
Part of v0.10.0 (removing CF Workers AI as image provider).
See convo/prautoblogger/threads/2026-04-cloudflare-workers-ai-removal/.
The composer classmap autoloader loads the real PRAutoBlogger_Encryption
before our eval() stub runs, so class_exists() is always true and the stub
is never installed. Two consequences:
1. Provider tests set 'enc:fake-key' and the real decrypt() path ran,
calling wp_salt() which isn't stubbed. Switched to plaintext keys
so is_encrypted() short-circuits and no crypto runs.
2. Support test for the decrypt path now uses a real encrypt/decrypt
round-trip with a stubbed wp_salt() returning a deterministic
32-char salt. This exercises the actual code path users hit in
production, not a lying stub.
Removes the dead eval() stubs that were being skipped anyway.
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(images): route Cloudflare Workers AI via AI Gateway by default
Direct probe on 2026-04-21 confirmed the Cloudflare AI Gateway
route to Workers AI (`gateway.ai.cloudflare.com/.../workers-ai/@cf/...`)
is no longer 403ing — the upstream regression noted in v0.8.0's
ARCHITECTURE.md #16 and in thread image-pipeline seq 6 has been
resolved. Routing via the gateway gives us the same benefits we
already get for OpenRouter LLM calls: response caching, cost/latency
dashboard, rate limiting.
Changes:
- `Cloudflare_Image_Support::build_endpoint_url()` now prefers the
gateway URL derived from the existing `prautoblogger_ai_gateway_base_url`
option. Direct API remains the fallback.
- New private helper `build_gateway_workers_ai_url()` strips the
OpenRouter path suffix from the configured gateway URL and appends
`/workers-ai/{model}`. Returns empty string (fallback to direct) when:
no gateway URL configured, the new toggle is off, or the URL isn't a
valid `https://gateway.ai.cloudflare.com/...` shape.
- New admin toggle `prautoblogger_cf_image_via_gateway` (default on) in
the Images section. Operators can disable to force direct API without
clearing the LLM-path gateway URL.
- ARCHITECTURE.md #16 rewritten to reflect the v0.8.2+ gateway routing.
- CHANGELOG.md v0.8.2 entry under Changed.
Tests (new `CloudflareImageSupportTest`):
- Direct URL when gateway not configured.
- Gateway URL derived correctly when configured + toggle on.
- Toggle off forces direct URL even with gateway configured.
- Malformed gateway URL falls back to direct (defensive).
- `is_nsfw_error()` only matches error code 3030 (pinned separately so
a tolerance regression fails early).
Cost impact: near-zero. Gateway adds a few ms of latency and enables
response caching — could *reduce* cost on identical prompts within the
configurable cache TTL.
Uninstall: new option covered by existing prautoblogger_% LIKE sweep.
Implements Commit 1b (narrow AI-Gateway-migration scope) per thread
image-pipeline seq 7.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(cost): seed new toggles in activator defaults; tighten URL stripper
QA review on cd45ae2 surfaced two things; one real, one hallucinated:
- **Real (fixed):** `prautoblogger_cf_image_via_gateway` and v0.8.1's
`prautoblogger_image_nsfw_retry` weren't seeded in
`Activator::set_default_options()`. Belt-and-braces — the `get_option()`
fallback at the read site already defaults to '1', so no user-visible
regression exists, but seeding the options follows the established
pattern for every other plugin setting and makes intent legible to
operators inspecting `wp_options` directly.
- **Hallucinated (rejected):** the reviewer claimed
`CloudflareImageSupportTest` methods 2–5 were empty stubs. Verified
— every test has real `$this->assert*` calls (10 total across the 5
cases). Ignoring. Flagging the false-positive to the CTO per the
qa-review quality-tracking protocol.
Also clarified the docblock + tightened the ternary inside
`build_gateway_workers_ai_url()` — explicit `strrpos` false-check
instead of the `?:` short-circuit, so a no-slash base (defensive edge)
falls through cleanly instead of returning a weirdly-sliced URL.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(schedule): interpret `prautoblogger_schedule_time` in site timezone
Per thread scheduled-gen-timezone seq 1: WordPress core calls
`date_default_timezone_set('UTC')` early in bootstrap, so the activator's
`strtotime("tomorrow HH:MM")` was resolving in UTC regardless of the
site's configured timezone. On peptiderepo.com (Asia/Singapore, UTC+8)
a `schedule_time` of `06:00` fired at 14:00 local.
Fold this into the existing v0.8.2 branch (PR #102) since both this fix
and the earlier CF-image-gateway change target the same version and
touch overlapping files (activator, settings-fields-extended).
Changes:
- `Activator::next_daily_generation_timestamp()` — new public helper
that reads `prautoblogger_schedule_time`, constructs a
`DateTimeImmutable('tomorrow', wp_timezone())`, applies the
configured hour/minute, returns a Unix timestamp. Defensive fallback
to the old UTC-interpretation behaviour on any DateTime parse error
so a bad tz string can't brick the schedule.
- `Activator::schedule_cron()` delegates to the helper instead of
calling `strtotime` directly.
- New `Activator::reschedule_daily_in_site_timezone_v082()` — one-shot
migration that clears the stale UTC-scheduled event and re-queues
using the helper. Gated by `prautoblogger_migrated_schedule_tz_v082`
option so it runs exactly once. Logs an INFO line comparing
before/after next_run for auditability.
- `class-prautoblogger.php::on_check_db_version()` invokes the
migration on every admin_init; the one-shot flag handles re-run
guarding (no DB_VERSION bump needed — the migration is independent
of the schema version).
- Settings description for "Generation Time" now says
"...in your site's configured timezone (Settings → General →
Timezone). Current site timezone: <wp_timezone_string()>" instead
of the ambiguous "server timezone" phrasing.
File-size compliance:
- Adding the timezone helpers pushed `class-activator.php` to 352
lines. Extracted the 104-line `create_tables()` SQL to a new
`class-schema-installer.php` (136 lines). Activator now 245 lines,
schema file fully dedicated to dbDelta.
Tests (new):
- `ActivatorScheduleTest` — SGT 06:00 → 22:00 UTC prev day; UTC 06:00
→ 06:00 UTC (no-op case); Asia/Kathmandu UTC+5:45 → 00:15 UTC
(fractional offset); malformed time input doesn't throw.
- `ActivatorMigrationTest` — pre-seeded UTC cron gets cleared and
rescheduled in SGT; second pass is idempotent.
CHANGELOG v0.8.2 entry now also covers the timezone fix under Fixed +
the description wording change under Changed.
Implements thread `scheduled-gen-timezone` seq 1 spec.
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>
* 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>
* test(images): update ImagePromptBuilderTest for array-returning builder
`build_article_prompt()` and `build_source_prompt()` have returned
`array{prompt: string, caption: string}` since v0.4.x; five existing
tests still asserted against the envelope as if it were a string. On
PHP 8+ `assertStringContainsString` and `strlen()` raise a TypeError
when handed an array, so every one of those tests failed on the CI
matrix even though the production code is fine.
Fix is mechanical per CTO spec in convo seq 6: destructure the return
on receipt and assert against `$scene` / `$caption`. Cases updated:
- test_build_article_prompt_from_content
- test_build_article_prompt_with_missing_content
- test_build_source_prompt_from_reddit_data
- test_build_source_prompt_without_comments
- test_prompts_are_reasonably_sized (500-char cap now bound to the
scene+style-suffix, which is what the cap was always meant to measure)
No production code changes. Unrelated pre-existing failures
(`Call to undefined function post_type_exists()` in Publisher fixtures
and the repo-wide PHPCS backlog) remain out of scope per CTO.
Follow-up to PR #97 (v0.8.0 image bundle), which shipped before this
test fix landed. Context in convo thread image-mime-bug seq 07.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(images): drop unreachable content-based assertion in article-prompt test
Per CTO review on PR #99 (convo image-mime-bug seq 8): the earlier diff
destructured the return value correctly, but left an assertion for
'Dragons are fascinating' — a content substring. With no OpenRouter key
in the unit-test env, `rewrite_via_llm()` catches and falls back to
`synthesize_visual_concepts_fallback()`, which uses title only. The
content substring is structurally unreachable on that path, so the
assertion can never pass without mocking the LLM.
Picking CTO option 1 ("honest and minimal"): drop the content
assertion. The title + non-empty + length + caption-is-string checks
remain — those describe what the method actually guarantees regardless
of LLM reachability. Content-path testing belongs in a mocked-LLM test
(see `test_rewrite_uses_setting_when_present`, which already covers it).
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>
Hardcoded light-mode hex colors in table styles broke dark mode —
header backgrounds and text were unreadable. Now uses theme CSS custom
properties (--color-border-default, --color-bg-secondary,
--color-text-primary) with light-mode fallbacks.
Bump 0.7.2 → 0.7.3.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Theme's global reset strips list-style from all ul/ol — restored
disc/decimal bullets with padding inside .entry-content.
Tables now wrapped in a scrollable .prab-table-wrap div via the_content
filter (priority 99). Added responsive media query for tighter cell
padding on viewports under 600px.
Bump 0.7.1 → 0.7.2.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The file-based cache delete wasn't clearing LiteSpeed's server-level
page cache, causing stale pages after deploy. Now uses wp litespeed-purge
all via WP-CLI (with file delete as fallback if CLI unavailable).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
CSS targeted .post-content but the theme template uses .entry-content.
Also removed the _prautoblogger_generated meta check — display settings
should apply site-wide to all posts, not just PRAutoBlogger-generated
ones.
Bump 0.7.0 → 0.7.1.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
New "Display" admin section with three settings:
- Article Font (7 choices + theme default, Google Fonts loaded on demand)
- Article Font Size (px, 0 = theme default)
- Table Borders (toggle, on by default — adds borders, padding, striping)
Inline CSS injected via wp_head only on singular posts with
_prautoblogger_generated meta — zero impact on other pages.
Extracted Image_Model_Registry to keep settings-fields-extended under
300 lines.
Bump 0.6.3 → 0.7.0.
Co-Authored-By: Claude Opus 4.6 <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>
View URL was using adminUrl (wp-admin/?p=123) instead of siteUrl.
Now uses home_url() for the frontend permalink.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Each idea row now has a Generate button that triggers single-article
generation via a background WP-Cron job. Skips the pipeline's collect/
analyze/score steps and goes straight to Article_Worker with the
selected idea. Button shows real-time stage updates via per-idea status
transients, then swaps to Edit/View links with cost on completion.
Respects the generation lock to prevent concurrent runs.
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 submenu page showing all article ideas from the content analyzer.
Displays title, type, relevance score, frequency, key points, and
keywords with type filtering and pagination. Gives live visibility
into the idea pipeline.
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>