Commit Graph

48 Commits

Author SHA1 Message Date
peptiderepo
6b1908d628 fix(maintenance): v0.18.2 — four confirmed-live fixes
1. Cost-reporter SQL: phpcs:ignore annotations were embedded inside two SQL
   string literals (get_daily_spend, get_spend_by_stage), causing MariaDB to
   reject both queries and break the Metrics & Costs admin page. Moved annotations
   above the prepare() calls. Regression tests added to CostReporterTest.

2. Collector failure observability: catch block in collect_from_all_sources() now
   logs structured meta (model slug, HTTP status parsed from exception message,
   first 200 chars of error body) so outage incidents are diagnosable post-hoc.

3. run_stages.agent_role back-write: Pipeline_Runner, Article_Worker, and
   Content_Generator were calling Run_Stage_State::start() with agent_role=''.
   All start() call sites now pass Stage_Display_Map::default_agent_role(stage).
   Role written at INSERT only; ON DUPLICATE KEY UPDATE does not include
   agent_role — idempotency-key semantics on resume are unchanged.

4. Runware float deprecation: normalize_seed() applied % to a float
   (microtime(true) * 1000), triggering PHP 8 deprecation on every image
   generation. Fixed with (int) round(...) before the modulo operator.

Refs: convo/prautoblogger/threads/2026-06-pipeline-v2-phase1/25-cto-v0182-maintenance.md
Agent-Session: prab-v0182-maintenance-20260612
2026-06-12 09:16:40 +07:00
peptiderepo
da0250f3ba fix: empty-content guard + finish_reason=length handling + reasoning budget (v0.18.1)
Reasoning models at high effort could spend the entire max_tokens budget
on thinking and return zero visible content with finish_reason=length;
the empty article flowed writer -> editor (reject) -> empty draft post,
with the cost booked as success (2026-06-11 incident: prod run acf24029,
draft post 921; diagnosis: convo pipeline-v2-phase1 seq 12).

- Empty-completion guard at the provider seam (new
  OpenRouter_Completion_Guard): empty/whitespace content is a failure;
  one retry with reasoning disabled (per-call override) when
  finish_reason=length or reasoning was active, then the stage fails.
- finish_reason=length always warning-logged (model, stage, tokens).
- Failed/retried attempts booked to generation_log with
  response_status=error + real token burn; run_decisions verdict rows
  (retry_reasoning_disabled / failed_empty_completion) for v0.18.0 runs.
- Writer-path guard in Content_Generator::execute_stage and
  belt-and-braces empty check in Publisher::create_post.
- Reasoning budget sanity: reasoning.max_tokens cap + completion
  headroom via new prautoblogger_reasoning_max_tokens setting (default
  constant 2048, 0 = legacy uncapped effort mode); cap replaces effort
  when active; governor reservations use the effective ceiling.
- Body assembly moved to Request_Builder::build_body (300-line cap).
- Tests: guard verdict matrix, builder budget shaping, writer/publisher
  guards, full provider retry path.

Agent-Session: prab-empty-draft-guard-20260611
2026-06-11 21:06:46 +07:00
peptiderepo
f5658f1c97 fix(cost): resolve snapshot-suffixed model IDs to base pricing before conservative estimate
OpenRouter sometimes returns a dated snapshot ID as the response model
(request deepseek/deepseek-v4-flash -> response model
deepseek/deepseek-v4-flash-20260423). The pricing chain missed the
registry on those IDs and booked the conservative $10/$30 per-M
estimate: today's prod single-pass run logged $0.582 against a true
flash-tier cost of roughly 1-2 cents. Inflated actuals eat the
Cost_Governor per-run ceiling (default $0.50; today's run would have
halted) and the monthly $5 budget gate (~9 days to self-block).

Fix at the single pricing seam (OpenRouter_Pricing::estimate_cost):
on a lookup miss, strip a trailing 8-digit 20-prefixed -YYYYMMDD
snapshot suffix and retry the hardcoded table + cached models list
once with the base ID. The conservative fallback is unchanged when
the base ID is also unknown. Governor reserve/settle and
generation-log booking all price through this seam, so every path
survives snapshot IDs.

Tests: snapshot ID resolves to the base price when only the base ID
is registered; an unknown ID without a suffix still gets the
conservative estimate; a non-date trailing digit run (foo-202612345)
is never stripped.

Agent-Session: pipeline-v2-phase1-pricing-fix-20260611
2026-06-11 16:42:56 +07:00
peptiderepo
c6d051c87a feat(cost): per-run cost governor — reserve-before-call enforcement
Pipeline v2 Phase 1, commit 3/4. Cost_Governor reserves each call's
worst-case estimate (prompt chars/4 + max_tokens via the #18 pricing
chain) against the run ledger row with one atomic conditional UPDATE
(the #10 lock discipline) BEFORE dispatch, then settles to actuals after
each response; failed calls release their hold. Integrated inside
OpenRouter_Provider::send_chat_completion so EVERY text-LLM call path is
guarded with zero call-site changes; the image curl_multi batch reserves
its summed estimate in Image_Pipeline before generate_image_batch.

Breach: run halted (sticky), overage recorded, Cost_Ceiling_Exception
aborts the un-dispatched call, process_next_queued_article aborts the
remaining queued articles of halted/failed runs, and the Review Queue
shows a 'Halted runs' notice (runs halted pre-post have no draft to
list). Ceiling = prautoblogger_per_run_cost_ceiling_usd setting (default
constant $0.50, snapshotted at run start, 0 = disabled). Standalone
no-run calls, the monthly cap, and the AI Gateway path are unchanged.

Under-300: provider API-key fetch+validation moved verbatim to
OpenRouter_Request_Builder::resolve_api_key().

Agent-Session: pipeline-v2-phase1-engineer-20260612
2026-06-11 16:42:56 +07:00
peptiderepo
c723af927b feat(prompts): versioned prompt registry, v1 seed, pinning + stamping
Pipeline v2 Phase 1, commit 2/4. Prompt_Registry (read) +
Prompt_Registry_Writer (immutable create/activate/seed) over the new
prompts table; canonical v1 bodies extracted verbatim into
Prompt_Defaults(+Editorial) — single source for BOTH the seed migration
(Migrate_Prompt_Seed_V0180, flag-gated, self-healing) and the in-code
fallback, so a missing table renders byte-identical prompts and never
fatals. Call sites swapped: Content_Prompts, Analysis_Prompts,
Chief_Editor, LLM_Research_Provider. Includes the image-composer seam
keys image.rewriter_system + image.style_template (composer PR consumes,
must not add its own prompt storage).

Pinning: Cost_Tracker::set_run_id() registers the process run context,
ensures the runs ledger row, and pins active versions once per run;
log_api_call/log_image_generation stamp prompt_version (run pins) +
agent_role (stage map) on every generation_log row. Single-pass keeps
stage 'draft' and stamps content.single_pass explicitly.

Under-300 refactors: Content_Generator's four duplicated span/log blocks
consolidated into execute_stage() (301 -> 224 lines, behavior
identical); get_avg_tokens_for_stages body moved to Cost_Reporter with a
delegating wrapper kept for API compatibility.

Tests: byte-identical fallback renders (single_pass/outline/polish/
editor.system), token filling, key coverage incl. stage-map integrity.

Agent-Session: pipeline-v2-phase1-engineer-20260612
2026-06-11 16:42:56 +07:00
peptiderepo
73ac6d12b4 feat: live Runware model sync with 24h cache and Sync Now button (v0.15.0)
Replace hardcoded model registry with live-sync system.

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

Fix: add all 15 model IDs to COST_PER_IMAGE and DEFAULT_STEPS.
2026-04-26 11:14:28 +07:00
peptiderepo
996bd2fd44 style: replace short ternary operator with ?? / explicit ternary to pass WordPress-Core sniff 2026-04-23 03:38:28 +00:00
peptiderepo
0e1551be42 fix(phpcs): move ignore pragmas into file-level docblocks
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
2026-04-23 03:26:50 +00:00
peptiderepo
dc86376e8b style(phpcs): resolve 137 residual WordPress-Core violations
- 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
2026-04-23 03:23:52 +00:00
peptiderepo
f3eacd0d8d style(phpcs): autofix WordPress-Core violations via phpcbf
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.
2026-04-23 02:42:48 +00:00
peptiderepo
21430ce3bf cf-removal: patch registry, pipeline, settings, admin-page, interface
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.
2026-04-21 16:48:04 +00:00
peptiderepo
1c3d9b50c6 cf-removal: delete Cloudflare Workers AI provider files + tests
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/.
2026-04-21 16:45:23 +00:00
peptiderepo
2cacaa3512 v0.9.0: Runware FLUX.1 schnell as default 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
2026-04-21 15:56:35 +00:00
peptiderepo
80a7e11a2b v0.8.2: CF images via AI Gateway + scheduled-gen timezone fix
* 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>
2026-04-21 11:34:37 +07:00
peptiderepo
5c7736bf4e v0.8.0: fix silent image failures + NSFW retry + prompt instructions (#97)
* 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>
2026-04-21 02:02:20 +07:00
peptiderepo
264fcf0f84 Amortize LLM research cost across generated articles (v0.5.2)
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>
2026-04-20 00:44:33 +07:00
peptiderepo
c03bdfb69c feat: add LLM Deep Research source provider (v0.5.0)
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>
2026-04-20 00:05:15 +07:00
peptiderepo
39570e4586 feat: add OpenRouter reasoning mode support (v0.4.9)
Enables reasoning-capable models (Grok 4.1 Fast, DeepSeek-R1, etc.)
via new admin settings: Enable Reasoning toggle and Reasoning Effort
selector. Provider auto-injects reasoning params when enabled; callers
can override per-call via options array. Response parser captures
reasoning_tokens and reasoning_content for visibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-19 23:54:26 +07:00
peptiderepo
281c8cd89c feat: semantic dedup + LLM-aware topic avoidance
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>
2026-04-19 13:35:26 +07:00
peptiderepo
d17c488ef3 feat: parallel image generation via curl_multi
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>
2026-04-18 21:10:54 +07:00
peptiderepo
c8595e05de Fix duplicate image, remove hardcoded model references
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>
2026-04-18 16:08:36 +07:00
peptiderepo
dd406c5ba6 fix: snap aspect ratios to standard values for Gemini compatibility
Gemini image models reject arbitrary aspect ratios like 150:79
(computed via GCD from 1200×632 pixels). Replace GCD-based calculation
with a snap-to-nearest approach using 7 standard ratios (1:1, 3:2,
2:3, 4:3, 3:4, 16:9, 9:16). Also set modalities to ["image", "text"]
as required by some providers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 11:00:35 +07:00
peptiderepo
a4405a5d32 fix: remove hardcoded image model fallback
Don't silently swap to a different model when the user's selection is
unknown. Pass whatever the user configured to OpenRouter and let the
API error clearly. Fallback chain: caller hint → saved option →
PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL constant. Cost estimation for
unknown models logs a warning and uses a conservative $0.05 estimate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 10:50:34 +07:00
peptiderepo
862d7e40e6 fix: update image models, handle upstream errors in HTTP 200
Three issues found in activity logs:

1. Image model IDs stale — FLUX and Seedream removed from OpenRouter.
   Updated to current models: Gemini 2.5/3.1 Flash Image, Gemini 3 Pro
   Image, GPT-5 Image/Mini. Default changed to gemini-2.5-flash-image.

2. openrouter/free router wraps upstream 429 errors in HTTP 200 body
   with error object. Both text and image response parsers now detect
   this pattern and surface a proper RuntimeException.

3. Image request used modalities: ["image"] but Gemini needs
   ["image", "text"]. Updated to include both.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 10:47:05 +07:00
peptiderepo
cf3d97c0c3 fix: handle null content in OpenRouter response parser
Some models (and the openrouter/free router) may return null content
instead of a string. The old check used isset() which returns false
for null, triggering the "unexpected response format" error.

Now: checks for choices[0].message (not content), treats null content
as empty string, and logs the actual response shape on failure so we
can diagnose what the model returned.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 10:23:53 +07:00
peptiderepo
2c2a2acc7d fix: recognize openrouter/free router as zero-cost
The free models router (openrouter/free) doesn't use the :free suffix,
so it needs an explicit check alongside the suffix-based one.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 10:20:32 +07:00
peptiderepo
58cb4ed508 feat: recognize OpenRouter :free models as zero-cost
Models with the :free suffix (e.g. google/gemini-2.5-flash-preview:free)
are served at no charge by OpenRouter. The cost estimator now returns
$0.00 for these models instead of falling through to the conservative
$10/$30 per-million-token fallback.

No changes needed in the model picker or provider — :free models use
the same API endpoint and already appear in the registry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 10:17:59 +07:00
peptiderepo
1968864073 fix: widen all catch blocks from \Exception to \Throwable
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>
2026-04-18 10:12:06 +07:00
peptiderepo
d886aee1d7 fix: rename OpenRouter image files to match autoloader kebab-case convention
The autoloader splits CamelCase on lowercase→uppercase transitions, so
PRAutoBlogger_OpenRouter_Image_Provider maps to class-open-router-image-provider.php.
Files were named class-openrouter-* (missing hyphen), causing a PHP fatal
error when the pipeline tried to instantiate the provider — which killed
the process before any logging could fire.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 09:58:04 +07:00
peptiderepo
0a68741945 feat: add OpenRouter image provider for higher-quality image generation
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>
2026-04-18 09:19:13 +07:00
peptiderepo
5b5286ca5b fix: remove deprecated flux-1-dev model from Cloudflare image config
FLUX.1 [dev] was removed from Cloudflare Workers AI in April 2026,
returning HTTP 400 "No route for that URI". This removes it from the
static model picker list and pricing calculations. Any saved setting
referencing flux-1-dev gracefully falls through to flux-1-schnell
via the null-coalesce in resolve_model().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 07:48:18 +07:00
peptiderepo
49aee0b446 fix: add Cloudflare token to encrypted fields list (fixes 401 on image gen)
prautoblogger_cloudflare_ai_token was missing from the $encrypted array
in sanitize_field(), so the token was stored as plaintext. On read,
decrypt() failed on the non-encrypted value and returned empty string,
causing HTTP 401 on every Cloudflare Workers AI call.

Fix: add the field to the encrypted list so future saves encrypt it.
Also add legacy plaintext detection in get_api_token() and the validator
so the existing DB value works immediately and gets auto-encrypted on
first use.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 07:24:01 +07:00
peptiderepo
2a734ba596 fix: align image dimensions to FLUX.1 divisible-by-8 requirement
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>
2026-04-17 23:39:51 +07:00
peptiderepo
eccb336c94 Merge pull request #20 from peptiderepo/refactor/split-openrouter-provider
refactor: Split OpenRouter provider under 300-line cap
2026-04-16 20:57:01 +07:00
peptiderepo
f81ae1fdaa refactor: extract availability checks and HTTP transport from Reddit client
Move is_rss_available(), is_json_available(), get_rate_limit_status(),
and api_get() to new PRAutoBlogger_Reddit_Availability class.
Brings client from 371 to 267 lines (under 300-line cap).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 20:54:50 +07:00
peptiderepo
fcc61c0cb8 refactor: Split OpenRouter provider to bring files under 300-line cap
Extracted credential validation, request building, response parsing, and
configuration helpers from the 464-line OpenRouter provider into separate,
focused classes. This follows the pattern established by the Cloudflare
image provider and brings all files under the project's 300-line limit.

Files created:
- class-open-router-validator.php (152 lines): Handles credential validation
  with HTTP probes and diagnostic messaging. Delegates from the provider.
- class-open-router-config.php (84 lines): Centralizes admin config resolution
  for API base URL and cache TTL settings. Used by both provider and validator.
- class-open-router-request-builder.php (79 lines): Encapsulates request header
  building and cURL auth filter registration for hosting environments that
  strip Authorization headers.
- class-open-router-response-parser.php (87 lines): Handles response parsing,
  error classification, and retry-decision logic.
- Tests: OpenRouterValidatorTest.php with comprehensive credential checks.

Provider shrinks from 464 to 278 lines. All classes are under 300 lines.
Dependencies remain clean: validator and config are independent, request-builder
and response-parser have no circular imports.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 20:49:54 +07:00
peptiderepo
ebf4f87f98 refactor: split Reddit JSON Client into RSS parser class
Extract parse_atom_feed() and RSS-specific XML parsing logic into a new
PRAutoBlogger_Reddit_RSS_Parser class. This parser is a self-contained data
transformation (Atom XML → normalized post array) with minimal dependencies.

File sizes after split:
- class-reddit-json-client.php: 371 lines (was 468)
- class-reddit-rss-parser.php: 137 lines (new)

Both files now under the 300-line cap. The JSON client retains post/comment
fetching, availability checks, and HTTP infrastructure, while the parser
handles the XML-to-array transformation in isolation.

No callers need updating — PRAutoBlogger_Reddit_Provider uses
fetch_posts() which internally delegates to the parser.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-16 20:46:44 +07:00
peptiderepo
8e6b2d528f Commit 1a: Cloudflare Workers AI image provider + pricing + validator (#9)
Implements image-provider interface + Cloudflare Workers AI adapter with pricing and validator. Independent QA verdict: APPROVE on 52fd11e (qa-reviews/prautoblogger/2026-04-15-52fd11e2ce97dd9e659df5cc87ec1382a668bec1.md). All 4 CTO blockers resolved (PHPUnit retry mocks, XSS hardening on error strings, PHPCS on new files, 300-line cap via support-class extraction). PHPCS backlog (586 errors across 38 pre-existing files) remains on continue-on-error; Workstream C material.
2026-04-15 18:25:46 +07:00
peptiderepo
428a3d5861 feat: optional Cloudflare AI Gateway proxy for OpenRouter calls
Adds two WP options: prautoblogger_ai_gateway_base_url (routes OpenRouter calls through a Cloudflare AI Gateway when set to an https:// URL; empty = direct, the default) and prautoblogger_ai_gateway_cache_ttl (seconds for cf-aig-cache-ttl response caching; 0 = off).

The gateway is a transparent OpenRouter-compatible proxy, so no consuming class, no provider abstraction, and no response-parsing path changes. The http_api_curl belt-and-suspenders Authorization filter is rescoped to the configured upstream host so it still runs under gateway.ai.cloudflare.com.

Rationale: leverages existing Cloudflare infrastructure to satisfy the CTO cost-control rules (caching, cost tracking, rate limiting, fallback) without building equivalent middleware in-plugin. Fully opt-in and reversible from the admin UI.

Docs: ARCHITECTURE.md options + integrations tables updated; key decision #15 added. Uninstall is unaffected (wildcard prautoblogger_% delete).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 01:41:30 +07:00
Terence Ong
4f704a5dcd fix: add key format validation to validate_credentials_detailed()
Root cause of the 401 was a corrupted API key: the WordPress auth
salt changed after the key was encrypted, so decryption produced
garbage instead of sk-or-... OpenRouter interprets the garbled
Bearer token as "Missing Authentication header."

Adds the same sk-or- prefix check to the credential validation
method so users see a clear "re-enter your key" message instead
of a cryptic 401 error.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 08:48:32 +07:00
Terence Ong
98ef81f49a fix: resolve OpenRouter 401 by injecting auth header at cURL level
Hostinger's hosting environment appears to strip the Authorization
header from outgoing wp_remote_post requests before they reach the
cURL transport. This caused OpenRouter to respond with HTTP 401
"Missing Authentication header" even though the header was set in
the wp_remote_post args array.

Fixes:
- Add http_api_curl filter (priority 99) that re-sets the
  Authorization header directly on the cURL handle, bypassing any
  intermediate header stripping. Scoped to openrouter.ai URLs only.
- Applied to all three OpenRouter call sites: send_chat_completion,
  validate_credentials_detailed, and get_available_models.
- Filter is cleaned up in a finally block to avoid leaking.

Also:
- Add pre-flight key format validation (must start with "sk-or-")
  to catch corrupted decryption early with a clear error message.
- Fix retry condition: was `>= 429` (incorrectly retried 430-499),
  now `=== 429` (only retry rate-limit).
- Add detailed diagnostic logging on 401/403 auth failures.
- Add .env.credentials to .gitignore.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 08:30:37 +07:00
peptiderepo
b21d102afb feat: v0.2.2 — CI hardening, remove dead stubs, strengthen tests
Address CTO audit findings for PRAutoBlogger:
- CI pipeline now runs PHPCS (WordPress standards) and PHPUnit before deploy
- Removed TikTok/Instagram/YouTube stub providers (dead code, not registered)
- Rewrote PublisherTest with real behavioral assertions (post_status, metadata,
  taxonomy, log linking, hooks) instead of method-existence checks
- Added cross-system LLM budget coordination docs to ARCHITECTURE.md
- Bumped version to 0.2.2

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 22:41:10 +07:00
peptiderepo
d9985f6dc2 refactor: remove PullPush.io, use Reddit RSS as primary data source
PullPush.io index was frequently stale/unavailable. Reddit RSS/Atom feeds
work reliably from datacenter IPs (Hostinger) where .json gets 403.

- RSS is now the primary source for post fetching
- .json endpoints kept as fallback for posts and sole source for comments
- Deleted class-pull-push-client.php
- Updated admin UI, test connection handler, docs, and ARCHITECTURE.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 14:00:55 +07:00
peptiderepo
b5cbbef804 feat: v0.2.1 — fix encryption, diverse multi-topic generation, UX fixes
- Rewrite encryption with "enc:" prefix to permanently eliminate
  the double-encryption bug on settings save
- Add one-time migration to clear legacy non-prefixed encrypted values
- Rewrite content analyzer prompts to request N distinct diverse topics
  across 5 categories (question, complaint, comparison, news, guide)
- Pass target_count*2 from pipeline to analyzer for headroom
- Add skip_dedup flag so manual Generate Now always produces content
- Generate Now button now sends force=1 to clear stale locks
- Rename OpenRouter provider files to PSR-4 convention (hyphenated)
- Switch default models to google/gemini-2.5-flash-lite
- Clean up reddit_debug log channel to standard 'reddit'
- Bump version to 0.2.1

Cost: ~$0.003/article with Gemini 2.0 Flash, ~34s pipeline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 13:36:36 +07:00
peptiderepo
0db5096b31 feat: replace Reddit OAuth with PullPush.io + .json fallback 2026-04-12 09:16:46 +07:00
Terence Ong
88d674d679 Initial commit: PRAutoBlogger v0.1.0 with frontend posts widget 2026-04-10 23:33:42 +07:00
Terence Ong
68c6f67bcf Initial commit: AutoBlogger v0.1.0
WordPress plugin that auto-researches topics from Reddit, generates
SEO-optimized articles via OpenRouter LLMs, runs them through a chief
editor agent, and publishes on a daily schedule.

Includes:
- Full generation pipeline (collect → analyze → score → generate → review → publish)
- Cost tracking and budget enforcement
- Admin UI: settings, review queue, metrics, activity log
- CI/CD pipeline (GitHub Actions → WordPress REST deploy)
- Unit test suite (PHPUnit + Brain Monkey)
- Structured logging with configurable verbosity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 16:10:27 +07:00