Compare commits

...

33 Commits

Author SHA1 Message Date
peptiderepo
0689989318 fix(tests): remove Post_Assembler eval stub from SyncModeTest (breaks Publisher tests)
SyncModeTest runs alphabetically before GenerationCheckpointRunnerTest and
PublisherSideEffectsTest. Defining PRAutoBlogger_Post_Assembler via eval with
only amortize_research_costs() caused Publisher* and ResearchReaper tests to
fail with 'Call to undefined method sanitize_llm_content()' because the stub
registered before the autoloader could load the real class.

Fix: remove the Post_Assembler eval block entirely from both generate-tick
tests. The 'more ideas remain' path (2-idea queue, pop one) never calls
Post_Assembler::amortize_research_costs() -- that only runs in the last-idea
branch. No stub is needed.

Article_Worker stub is retained (it IS called in this path and needs a no-op
generate() to avoid real LLM calls).

Agent-Session: prab-reliability-test-fix-20260622
2026-06-22 17:44:12 +00:00
peptiderepo
d5fb1299ac tests(checkpoint): fix vacuous sync-mode test + add generate-tick sync test (NF-1)
- GenerationCheckpointRunnerSyncModeTest.php (new, 246 lines): three tests that
  genuinely exercise the sync-mode guards --
    test_orchestrate_tick_in_sync_mode_does_not_schedule_background_event:
      orchestrate_only() now returns 1 idea so execution reaches the guard at
      line ~181 (was vacuous -- empty-ideas stub triggered early return at ~148).
      Asserts GENERATE_ACTION NOT scheduled, spawn_cron NOT called.
    test_generate_tick_sync_mode_suppresses_reschedule (new):
      2-idea queue, set_sync_mode(true), on_generate_tick() -- asserts
      GENERATE_ACTION NOT in scheduled events, spawn_cron NOT called.
    test_generate_tick_async_mode_schedules_generate_action:
      async regression guard moved from original file.
- GenerationCheckpointRunnerTest.php (233 lines): removed the two sync-mode
  tests to stay under 300-line rule; added docblock pointer to SyncModeTest.
- CHANGELOG.md: merged duplicate [0.22.1] headings into one entry (NF-2).

Production code unchanged. Version stays 0.22.1.

Agent-Session: prab-reliability-test-fix-20260622
2026-06-22 17:38:17 +00:00
peptiderepo
404b3b4ce2 style: auto-fix PHPCS violations via phpcbf 2026-06-22 17:16:57 +00:00
peptiderepo
6229601df4 fix: QA remediation P1-A/P1-B/P1-C/P2 for v0.22.1 sync-mode
P1-A: wrap wp_schedule_single_event(GENERATE_ACTION) + fire_cron_now() in
on_orchestrate_tick() with 'if ( ! self::$sync_mode )' guard.
Mirrors the existing guard in on_generate_tick(). Prevents a background
wp-cron process spawning in parallel with the VPS --sync loop and
prematurely finalizing the run lock before Article_Worker completes.

P1-B: add two unit tests to GenerationCheckpointRunnerTest.php:
- test_orchestrate_tick_in_sync_mode_does_not_schedule_background_event:
  asserts sync_mode=true suppresses GENERATE_ACTION schedule + spawn_cron.
- test_generate_tick_async_mode_schedules_generate_action:
  asserts sync_mode=false (async path) still reschedules GENERATE_ACTION.

P1-C: create root CONTEXT.md with domain glossary (run, run_stages,
checkpoint, idea queue, Generation_Lock) and execution mode reference
(async wp-cron vs --sync VPS-driven).

P2: update ARCHITECTURE.md §25 Entry Points table with the
'wp prautoblogger generate --sync' VPS path row.

Agent-Session: prab-reliability-remediation-20260622
2026-06-22 17:14:31 +00:00
peptiderepo
9190361865 test: mock wp_date() in RunReaperTest (v0.22.1 TZ fix)
The reaper's stuck-sweep cutoff now uses wp_date() instead of gmdate()
(v0.22.1 TZ fix — makes cutoff consistent with local-TZ updated_at).
RunReaperTest did not mock wp_date(); alias it to gmdate() so tests
remain timezone-agnostic and the existing SQL assertions still pass.

Agent-Session: prab-reliability-20260622
2026-06-22 16:38:09 +00:00
peptiderepo
7b540a7ce1 style: auto-fix PHPCS violations via phpcbf 2026-06-22 16:35:40 +00:00
peptiderepo
4ca66bcdc1 v0.22.1: sync CLI, reaper TZ fix, maintenance-cron self-heal
- wp prautoblogger generate --sync: synchronous VPS orchestrator mode.
  Acquires lock in-process, calls on_orchestrate_tick() then loops
  on_generate_tick() up to 20 ticks. Emits JSON summary; exits 0/1.
- Generation_Checkpoint_Runner::set_sync_mode(): static flag suppresses
  wp-cron reschedule inside on_generate_tick() when the VPS external
  loop is the driver. No effect on existing async (cron/AJAX) paths.
- Run_Reaper TZ fix: sweep_stuck_stages() + sweep_stuck_runs() now use
  wp_date() for $cutoff so it matches updated_at's local-TZ storage.
  gmdate() was 8h behind on Hostinger (UTC+8) — latent for old stale
  runs but would suppress reaping of same-day-stalled runs.
- DB_Migrations v0221 self-heal: restores missing maintenance crons
  (reap_orphan_research_rows, collect_metrics, sync_runware_models).
  prautoblogger_daily_generation deliberately NOT re-registered — VPS
  systemd timer owns generation scheduling. Note: wp-cron reliability on
  this Hostinger host is suspect; maintenance crons are low-stakes.

Agent-Session: prab-reliability-20260622
2026-06-22 16:32:13 +00:00
peptiderepo
a2af04f2c4 docs: add app-local ADR log + ADR-0001 editorial illustration image style (#169)
Adds docs/adr/ scaffold (README index) and ADR-0001 documenting the v0.16.0 pivot to text-free editorial illustration with caption-as-HTML.

Agent-Session: adr-scaffold-20260617
2026-06-19 16:25:53 +07:00
peptiderepo
2871761665 ci: fix peptide-e2e org ref in deploy.yml and ci.yml (#168)
Fix stale peptiderepo/peptide-e2e refs in both deploy.yml and ci.yml.

Agent-Session: fix-e2e-ref-20260617
2026-06-17 11:38:28 +07:00
peptiderepo
110c681c57 Adopt reusable CI standard + 300-line splits (#167)
ci: adopt estate reusable CI + split 300-line violations (v0.22.0)

Squash merge of PR #167 (claude/adopt-ci-20260616)
Head: dc5145cd92

Agent-Session: deploy-operator-2026-06-16
2026-06-16 22:01:45 +07:00
peptiderepo
d770e8a86a feat: M4 in-admin manual generation on chained-cron checkpoints + IA/perf pass (v0.21.0) (#166)
Final Phase 2 admin milestone. Board "New Article" runs through the M3 chained-cron checkpoint state machine (one stage/idea per tick, queue persisted before generate, lock released on every exit path) so generation survives Hostinger background-kill; retires the SSH-only do_action workaround (WP-CLI routes through the same entry). IA/perf: settings-backed board/ideas pagination + db 1.4.0 indexes. Folds guard P2s (--self-test + window doc).

QA: APPROVE-WITH-P2-NOTES @ f2a00d4 + delta APPROVE @ 0c8071f. Deferred (tracked): class-admin-page.php 353-line split + pagination Settings-UI fields.

Agent-Session: phase2-admin-m4-20260614
2026-06-15 00:48:43 +07:00
peptiderepo
326a1af900 ci: guard against undefined PRAUTOBLOGGER_* constants (#165)
Adds scripts/check-constants.php + a required ci.yml step that fails the build when any referenced PRAUTOBLOGGER_* constant is neither defined in prautoblogger.php nor defined()-guarded. Closes the v0.19.0 admin-500 incident class (seq 38). CI-only; no plugin runtime change.

QA: APPROVE-WITH-P2-NOTES (2026-06-14-923f712). P2 follow-ups folded into M4.

Agent-Session: ci-constants-guard-20260614
2026-06-14 20:03:18 +07:00
peptiderepo
6ce2cbb94c fix: restore 3 bootstrap constants dropped in v0.19.0 — admin 500 hotfix (v0.20.1) (#163)
Incident hotfix, prod already hot-patched + verified. QA: APPROVE @ 9366a01 (qa-reviews/prautoblogger/2026-06-12-9366a01.md).

Agent-Session: cto-constants-hotfix-merge-20260612
2026-06-12 22:27:34 +07:00
peptiderepo
2d641153c7 ci(review): drop multi-model panel to Haiku-only (#162)
QA A/B decision 2026-06-12 (CEO-ratified): Haiku-only. Evidence: convo/cto/threads/2026-06-qa-ab-review/. QA verdict per repo in qa-reviews/.

Agent-Session: cto-panel-merge-20260612
2026-06-12 21:53:35 +07:00
peptiderepo
ac2f20de7e feat: M3 edit + single-step re-run — input versioning, human-modified audit, staged re-run (v0.20.0) (#161)
QA: REJECT@6ed639d (CTO rebase header P1) -> APPROVE@6727e07 (qa-reviews/prautoblogger/2026-06-12-6727e07.md)
CPO guardrails 1-5 verified hard-AC. CEO conditional greenlight 2026-06-12 in chat (QA-APPROVE condition met).

Agent-Session: cto-m3-merge-20260612
2026-06-12 21:46:49 +07:00
peptiderepo
3b6a302f95 fix(maintenance): v0.19.4 — composer GD-rung tests, layout-key cleanup, carried P3s (#160)
QA: APPROVE-WITH-P2-NOTES @ c0929be (qa-reviews/prautoblogger/2026-06-12-c0929be.md)
CEO greenlight: 2026-06-12 in chat.

Agent-Session: cto-maint-v0194-merge-20260612
2026-06-12 21:21:52 +07:00
peptiderepo
5c3328dee2 v0.19.3 - hotfix: dossier 403 (hidden-page pattern -> options.php parent) (#159)
Dossier registers under options.php parent (stable admin_page_* hookname at
registration AND request time); hide-by-unset removed; enqueue gate fixed;
request-time-faithful regression tests (fail on v0.19.2 by construction);
hidden-admin-page convention codified in CONVENTIONS.md.

QA: REJECT-P1 (ae32876, test file 551 lines) -> split -> APPROVE (6689126).
Verdicts: qa-reviews/prautoblogger/2026-06-12-{ae32876,6689126}.md
Thread: pipeline-v2-phase2-admin seq 21-28

Agent-Session: cto-dossier-403-hotfix-merge-20260612
2026-06-12 18:41:46 +07:00
peptiderepo
9c8e500244 v0.19.2 - Phase 2 admin M2: Article Dossier + class-prautoblogger split (#158)
Article dossier (Proposal C): per-stage input/output sections w/ raw-trace
toggle, metadata sidebar + per-stage cost receipt, run_decisions rationale,
image section, metabox dossier link, board deep-links. DB-migration methods
extracted to class-db-migrations.php (byte-parity verified by QA).

QA: APPROVE-W-P2 qa-reviews/prautoblogger/2026-06-12-579c66e.md
P2s carried to M3: populate request_json (input trace), sidebar pv
consolidation, image stages in run_stages. Thread: pipeline-v2-phase2-admin seq 16-20

Agent-Session: cto-phase2-admin-m2-merge-20260612
2026-06-12 18:01:39 +07:00
peptiderepo
2911c94f57 v0.19.1 - hotfix: Board admin page 404 (submenu registered before parent menu) (#157)
Board submenu now registers at admin_menu priority 11 (parent toplevel at 10)
so the page hook lands under prautoblogger_page_prautoblogger-board; $submenu
reordered defensively to keep Board the first item / top-level click target.
Enqueue gate now matches; board.css/board.js load for the first time on prod.
Regression test: BoardMenuRegistrationTest (fails on pre-fix main).

QA: APPROVE-W-P2 qa-reviews/prautoblogger/2026-06-12-6168520.md
Thread: convo/prautoblogger/threads/2026-06-pipeline-v2-phase2-admin/ seq 11-14

Agent-Session: cto-board-404-hotfix-merge-20260612
2026-06-12 15:46:09 +07:00
peptiderepo
49f17c3d6f v0.19.0 - Phase 2 admin M1: kanban board + live progress; cost-reporter SQL fix (#156)
M1 of the Editorial Record admin (Direction C, CEO-ratified 2026-06-12).
Kanban dashboard (Generating/In Review/Published/Failed) as primary landing,
AJAX live progress via status poller, setting-backed poll interval + published
window, cost-reporter phpcs:ignore SQL fix, board column mapping tests.

QA: REJECT-P1 (29a334b) -> remediated -> APPROVE-WITH-P2-NOTES (60aa449).
Verdicts: qa-reviews/prautoblogger/2026-06-12-29a334b.md, 2026-06-12-60aa449.md
Thread: convo/prautoblogger/threads/2026-06-pipeline-v2-phase2-admin/ seq 1-9

Agent-Session: cto-phase2-admin-m1-merge-20260612
2026-06-12 14:55:48 +07:00
peptiderepo
87020736ef ci: harden cto-review.yml severity KeyError (v0.18.3)
QA (seq 43) identified a content-triggered KeyError in the CTO-Review
postprocessor: when a model returns a concerns[] item without a 'severity'
key, line 454 crashed with KeyError instead of degrading.

  sev = c[severity].upper()        # before — hard KeyError
  sev = c.get("severity", "UNKNOWN").upper()  # after — degrades gracefully

The JSON schema marks severity as required but the postprocessor does not
enforce it — a model deviating on a large diff (91K bytes for f10e177 vs
41K for 02e9aab) triggered the crash. The fix surfaces UNKNOWN-severity
concerns rather than killing the check.

No other workflow changes.

Agent-Session: prab-v0183-20260612
2026-06-12 11:55:02 +07:00
peptiderepo
dd1c38b6cf fix: document STATUS_TTL/API_TIMEOUT/MAX_RETRIES safety invariant (v0.18.3)
Add comment at the PRAUTOBLOGGER_MAX_RETRIES + PRAUTOBLOGGER_API_TIMEOUT_SECONDS
constant definitions documenting the dependency:

  API_TIMEOUT_SECONDS × MAX_RETRIES (540s) must stay < STATUS_TTL (600s)

Current product: 180s × 3 = 540s — 60s margin. Prevents constant-drift
regression: raising either constant without checking the inequality could cause
a fully-retrying LLM call to exhaust the status-transient TTL within a single
stage, triggering a false orphan-run abort (R2b path in poller).

Agent-Session: prab-v0183-20260612
2026-06-12 11:55:02 +07:00
peptiderepo
952344ff5f docs: ARCHITECTURE.md — add poller + fetcher to file tree; rewrite fallback detection
P1-1: class-generation-status-poller.php added under includes/; executor
description updated to reflect AJAX delegation (no longer 'start + status').

P1-2: class-runware-catalog-fetcher.php added under includes/providers/;
class-runware-model-catalog.php also added (was missing from tree).

P1-3: 'Fallback detection' blurb rewritten. Old: 180s/post-meta check.
Actual: transient renewed per-stage via update_generation_stage(); lock within
STATUS_TTL → R3 (running); lock > STATUS_TTL → R2b (abort_orphaned_run,
status:error). Daily Run_Reaper remains backstop.

Agent-Session: prab-v0183-20260612
2026-06-12 11:55:02 +07:00
peptiderepo
f0dc1c4621 fix: migrate Runware catalog to modelSearch; failure cooldown (v0.18.3 R4)
Runware retired taskType:models; all 42 daily catalog 401s come from this

deprecated endpoint. Migrated to auth+modelSearch pair per Runware's

current API. Paginator fetches up to 5 pages x 100 = 500 models, filters

client-side to io:text-to-image in capabilities[]. Uses air identifier as

model id. Cache key bumped to v2 (prautoblogger_runware_model_cache_v2)

to invalidate v1 rows that used old taskType/requiresImage shape.

Failure cooldown: sync() records prautoblogger_runware_catalog_last_failure_at

on every error path. get_models() gates re-trigger via is_in_failure_cooldown()

which reads prautoblogger_runware_catalog_failure_cooldown_seconds (default

3600s). Success clears the timestamp. Caps cascade to ~24 errors/day.

300-line rule: fetching/normalization extracted to new companion class

PRAutoBlogger_Runware_Catalog_Fetcher (class-runware-catalog-fetcher.php,

205 lines). Catalog class: 261 lines. Autoloader picks up the new file.

Tests: normalize_models against real modelSearch fixture (redacted key);

cooldown behavior (failure->active->expired); sync clears timestamp; fallback

list always served when cache empty. 328/328 PHPUnit green.

Agent-Session: prab-v0183-20260612
2026-06-12 11:55:02 +07:00
peptiderepo
eae0239b0c fix(tests): add wpdb->posts and wpdb->options to mock setup
AnalysisPromptsTest: analysis-prompts.php uses $wpdb->posts in the
JOIN clause ({->posts}); mock stdClass must declare posts property.

GenerationStatusPollerTest: Generation_Lock queries {->options}
in acquire/release/get_acquired_at; mock stdClass must declare options.

Agent-Session: prab-v0183-20260612
2026-06-12 11:55:02 +07:00
peptiderepo
1afac09d0a fix(v0183): GA4 SQL fix + manual-run resilience + honest status
R1: move phpcs:ignore out of SQL string in class-analysis-prompts.php:81
    (same pattern as v0.18.2 cost-reporter fix). GA4 self-improvement loop
    has returned empty context on every pipeline run since v0.16.0 — this
    restores get_performance_context() to the LLM. Sibling occurrence also
    fixed in class-post-assembler.php:112.

R2(a): update_generation_stage() now renews STATUS_TTL on every stage call
    so the transient outlasts long LLM calls during background runs.

R2(b): on_ajax_generation_status() detects lock-held-but-transient-gone
    when lock age > STATUS_TTL (= Hostinger background-process kill).
    Marks stuck run failed, releases lock, returns status:error with
    'infrastructure timeout' message. Daily reaper remains the backstop.

R3: when transient absent but lock held within TTL, return status:running
    (with started_at from lock) instead of status:idle.

Executor split: class-executor.php was 351 lines (>300). AJAX generation
    handlers extracted to new class-generation-status-poller.php (260 lines);
    executor now 226 lines. Public hook wiring unchanged via delegation.
    Generation_Lock gains get_acquired_at() for R2b/R3 lock-age checks.

Tests: AnalysisPromptsTest (regression SQL + behavioral context shaping),
       GenerationStatusPollerTest (R2a TTL renewal, R2b abort, R3 running,
       idle path).

Closes: R1 (analysis-prompts SQL), R2 (manual-run resilience), R3 (honest status)
Version: 0.18.3
Agent-Session: prab-v0183-20260612
2026-06-12 11:55:02 +07:00
peptiderepo
5ea058d504 style: auto-fix PHPCS violations via phpcbf 2026-06-12 09:16:40 +07:00
peptiderepo
3af65bbe39 fix(stage-state): v0.18.2b — symmetric agent_role via in-class resolve_role seam
QA REJECT-P1 remediation (seq 29). start()/done()/is_done()/get_output()
were asymmetric: start() passed the mapped role but done()/is_done()/
get_output() passed '' via the default, addressing a different unique key
(run_id, stage, agent_role, item_key). On every normal run this orphaned
the start() row in 'running'; on resume is_done() always returned false
→ duplicate-post vector.

Fix: add resolve_role(stage, agent_role) private static seam in
Run_Stage_State. When the caller passes '' the role is derived from
Stage_Display_Map::default_agent_role(stage) before any DB operation.
Non-empty roles (Phase-2 fan-out) pass through unchanged. All four
write/lookup methods (start, done, fail, get) call resolve_role.
is_done() and get_output() go through get() so they benefit too.

Mid-run-upgrade compat (v0.18.1 → v0.18.2): get() falls back to a
legacy role='' row on miss so a run started under v0.18.1 (all rows
have agent_role='') can still resume under v0.18.2. One extra indexed
query, only on miss, only for known stages.

Tests: two new RunStageStateTest cases —
  test_role_tagged_start_and_done_are_symmetric: start+done with
  'editor', assert is_done('editor')=true and is_done('')=false.
  test_legacy_empty_role_row_is_found_on_fallback: resolve to 'editor',
  primary miss, legacy role='' hit → is_done=true.

ARCHITECTURE.md §22: agent_role column and #22 Phase 1 description
updated — no longer 'Phase-2 only / '' default'; now documents Phase 1
population from Stage_Display_Map.

Agent-Session: prab-v0182-maintenance-20260612
2026-06-12 09:16:40 +07:00
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
b902216497 fix(settings): sanitize reasoning token cap as numeric on save
QA F4 (P3): prautoblogger_reasoning_max_tokens was missing from the
sanitize_field() numeric allowlist, so it saved as raw text (absint at
read kept runtime safe). Saves as numeric-or-0 now, matching every other
number field. CHANGELOG 0.18.1 entry notes the server-side sanitize (no
new version — 0.18.1 is still unreleased).

Agent-Session: prab-empty-draft-guard-20260611
2026-06-11 21:06:46 +07:00
peptiderepo
27ef37fe15 test: split PublisherTest and OpenRouterProviderTest under the 300-line rule
QA F2 (P2): this PR pushed PublisherTest to 333 lines and
OpenRouterProviderTest to 318. Both splits are mechanical (test groups
moved verbatim to a sibling file; setUp duplicated; no test logic or
assertion changes):

- OpenRouterProviderTest (318 -> 169): wire_full_provider() + the two
  v0.18.1 retry-path integration tests move to
  OpenRouterProviderRetryTest.php (223). The remaining file is
  byte-identical to its pre-PR state on main.
- PublisherTest (333 -> 202): the five post-creation side-effect tests
  (taxonomy, tags, run_id log linking, action/filter hooks — incl. the
  two pre-existing skipped ones, moved as-is) move to
  PublisherSideEffectsTest.php (183). Guard tests stay with the
  create_post() funnel tests.

Suite count unchanged (302 tests); all four files pass locally.

Agent-Session: prab-empty-draft-guard-20260611
2026-06-11 21:06:46 +07:00
peptiderepo
b7ffce8d88 docs(architecture): document v0.18.1 completion guard, reasoning cap option, request-builder scope
QA F1 (REJECT-P1 on 2bdc763, qa-reviews/prautoblogger/2026-06-11-2bdc763.md):
ARCHITECTURE.md was not updated in the commit that introduced the new
class and option (Standard §7 same-commit rule).

- File tree: add class-open-router-completion-guard.php (empty-completion
  guard, v0.18.1) under includes/providers/.
- Options table: add prautoblogger_reasoning_max_tokens (v0.18.1 reasoning
  token cap; default PRAUTOBLOGGER_DEFAULT_REASONING_MAX_TOKENS = 2048;
  0 = legacy uncapped effort mode).
- File tree: refresh class-open-router-request-builder.php description —
  it now owns request body assembly + the reasoning token cap/headroom,
  not just headers.

No version bump: 0.18.1 is still unreleased on this branch.

Agent-Session: prab-empty-draft-guard-20260611
2026-06-11 21:06:46 +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
146 changed files with 17294 additions and 2453 deletions

View File

@@ -35,25 +35,33 @@ add_action( 'rest_api_init', 'prautoblogger_deploy_register_route' );
* Register the deploy REST route.
*/
function prautoblogger_deploy_register_route(): void {
register_rest_route( 'prautoblogger-deploy/v1', '/deploy', [
'methods' => 'POST',
'callback' => 'prautoblogger_deploy_handle',
'permission_callback' => 'prautoblogger_deploy_check_auth',
] );
register_rest_route(
'prautoblogger-deploy/v1',
'/deploy',
array(
'methods' => 'POST',
'callback' => 'prautoblogger_deploy_handle',
'permission_callback' => 'prautoblogger_deploy_check_auth',
)
);
// Health check endpoint (GET) — useful for monitoring.
register_rest_route( 'prautoblogger-deploy/v1', '/status', [
'methods' => 'GET',
'callback' => function () {
return [
'status' => 'ready',
'plugin' => is_dir( WP_PLUGIN_DIR . '/prautoblogger' ) ? 'installed' : 'not_installed',
'active' => is_plugin_active( 'prautoblogger/prautoblogger.php' ),
'version' => prautoblogger_deploy_get_installed_version(),
];
},
'permission_callback' => 'prautoblogger_deploy_check_auth',
] );
register_rest_route(
'prautoblogger-deploy/v1',
'/status',
array(
'methods' => 'GET',
'callback' => function () {
return array(
'status' => 'ready',
'plugin' => is_dir( WP_PLUGIN_DIR . '/prautoblogger' ) ? 'installed' : 'not_installed',
'active' => is_plugin_active( 'prautoblogger/prautoblogger.php' ),
'version' => prautoblogger_deploy_get_installed_version(),
);
},
'permission_callback' => 'prautoblogger_deploy_check_auth',
)
);
}
/**
@@ -78,7 +86,7 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon
return new WP_Error(
'rate_limited',
'Deploy in progress or recently completed. Wait 60 seconds.',
[ 'status' => 429 ]
array( 'status' => 429 )
);
}
set_transient( 'prautoblogger_deploy_lock', time(), 60 );
@@ -87,7 +95,7 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon
$files = $request->get_file_params();
if ( empty( $files['plugin']['tmp_name'] ) || ! is_uploaded_file( $files['plugin']['tmp_name'] ) ) {
delete_transient( 'prautoblogger_deploy_lock' );
return new WP_Error( 'no_file', 'No plugin zip uploaded.', [ 'status' => 400 ] );
return new WP_Error( 'no_file', 'No plugin zip uploaded.', array( 'status' => 400 ) );
}
$tmp_file = $files['plugin']['tmp_name'];
@@ -96,11 +104,11 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon
$zip = new ZipArchive();
if ( true !== $zip->open( $tmp_file ) ) {
delete_transient( 'prautoblogger_deploy_lock' );
return new WP_Error( 'invalid_zip', 'Uploaded file is not a valid zip.', [ 'status' => 400 ] );
return new WP_Error( 'invalid_zip', 'Uploaded file is not a valid zip.', array( 'status' => 400 ) );
}
$has_main_file = false;
for ( $i = 0; $i < $zip->numFiles; $i++ ) {
for ( $i = 0; $i < $zip->numFiles; $i++ ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
if ( 'prautoblogger/prautoblogger.php' === $zip->getNameIndex( $i ) ) {
$has_main_file = true;
break;
@@ -113,7 +121,7 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon
return new WP_Error(
'invalid_plugin',
'Zip must contain prautoblogger/prautoblogger.php at root.',
[ 'status' => 400 ]
array( 'status' => 400 )
);
}
@@ -143,7 +151,7 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon
activate_plugin( 'prautoblogger/prautoblogger.php' );
}
delete_transient( 'prautoblogger_deploy_lock' );
return new WP_Error( 'backup_failed', 'Could not backup current plugin.', [ 'status' => 500 ] );
return new WP_Error( 'backup_failed', 'Could not backup current plugin.', array( 'status' => 500 ) );
}
}
@@ -161,7 +169,7 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon
}
delete_transient( 'prautoblogger_deploy_lock' );
error_log( '[PRAutoBlogger Deploy] Extraction failed. Rolled back to previous version.' );
return new WP_Error( 'extract_failed', 'Zip extraction failed. Rolled back.', [ 'status' => 500 ] );
return new WP_Error( 'extract_failed', 'Zip extraction failed. Rolled back.', array( 'status' => 500 ) );
}
// 7. Activate plugin.
@@ -178,7 +186,7 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon
return new WP_Error(
'activation_failed',
'Plugin activation failed: ' . $activation_result->get_error_message() . '. Rolled back.',
[ 'status' => 500 ]
array( 'status' => 500 )
);
}
@@ -188,18 +196,23 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon
}
$old_version = prautoblogger_deploy_get_installed_version();
error_log( sprintf(
'[PRAutoBlogger Deploy] Successfully deployed v%s (was: %s) at %s',
$incoming_version,
$old_version ?: 'fresh install',
gmdate( 'Y-m-d H:i:s' )
) );
error_log(
sprintf(
'[PRAutoBlogger Deploy] Successfully deployed v%s (was: %s) at %s',
$incoming_version,
$old_version ? $old_version : 'fresh install',
gmdate( 'Y-m-d H:i:s' )
)
);
return new WP_REST_Response( [
'success' => true,
'version' => $incoming_version,
'message' => "PRAutoBlogger v{$incoming_version} deployed and activated.",
], 200 );
return new WP_REST_Response(
array(
'success' => true,
'version' => $incoming_version,
'message' => "PRAutoBlogger v{$incoming_version} deployed and activated.",
),
200
);
}
/**
@@ -210,7 +223,7 @@ function prautoblogger_deploy_get_installed_version(): string {
if ( ! file_exists( $main_file ) ) {
return '';
}
$data = get_file_data( $main_file, [ 'Version' => 'Version' ] );
$data = get_file_data( $main_file, array( 'Version' => 'Version' ) );
return $data['Version'] ?? '';
}

View File

@@ -1,111 +1,53 @@
name: CI — Lint & Test
# Runs on every PR and every push to main. Validates PHPUnit tests + lint +
# coding standards before deploy can happen. deploy.yml on main continues to
# run its own validate job as a belt-and-suspenders check, but this CI workflow
# is what gives PRs an immediate signal so fixes happen before merge.
##
# CI caller — PRAutoBlogger
#
# Thin caller that delegates to the reusable estate CI workflow in peptide-e2e.
# workflow_call is required so deploy.yml can gate on this workflow via
# `uses: ./.github/workflows/ci.yml` (the workflow_call trigger enables that).
#
# Permissions: contents: write is required at the caller level so the reusable
# workflow's phpcbf auto-commit job can push fixes back to the PR branch.
# (GitHub enforces that callers cannot be escalated beyond their declared scope.)
#
# tests: brain-monkey — no WP install or DB required; Brain Monkey mocking only.
# line_limit_paths: includes — estate hard 300-line rule applied to includes/.
##
name: CI
on:
pull_request: {}
push:
branches: [main]
pull_request:
branches: [main]
workflow_call: {}
permissions:
contents: read
contents: write
jobs:
php-lint:
name: PHP Lint (${{ matrix.php }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ['8.1', '8.2', '8.3']
steps:
- uses: actions/checkout@v4
- name: Setup PHP ${{ matrix.php }}
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
coverage: none
- name: Syntax check all PHP files
# Exclude .github/mu-plugins — those run on the production server (PHP 8+)
# and may use PHP 8+ syntax (union types etc.) that older versions reject.
run: |
set -e
find . -name '*.php' \
-not -path './vendor/*' \
-not -path './node_modules/*' \
-not -path './.git/*' \
-not -path './.github/*' \
-print0 | xargs -0 -n1 -P4 php -l > /dev/null
ci:
uses: peptide-repo/peptide-e2e/.github/workflows/ci.yml@main
with:
tests: brain-monkey
secrets: inherit
phpcs:
name: PHPCS (WordPress standards)
check-constants:
name: Check PRAUTOBLOGGER_* constants
runs-on: ubuntu-latest
continue-on-error: false # strict after PHPCS cleanup in v0.8.3
permissions:
contents: write # allows phpcbf auto-fix commits back to the PR branch
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref || github.ref_name }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup PHP
- name: Setup PHP 8.1
uses: shivammathur/setup-php@v2
with:
php-version: '8.1'
coverage: none
tools: composer:v2, phpcs
- name: Install WordPress Coding Standards
run: |
composer global config --no-plugins allow-plugins.dealerdirect/phpcodesniffer-composer-installer true
composer global require --no-interaction \
"dealerdirect/phpcodesniffer-composer-installer:^1.0" \
"wp-coding-standards/wpcs:^3.0" \
"phpcsstandards/phpcsutils:^1.0" \
"phpcsstandards/phpcsextra:^1.0"
- name: Auto-fix with phpcbf (commit back to branch if changed)
# phpcbf exits 1 when it makes fixes, 0 when nothing to fix — both are success here.
# Commits made via GITHUB_TOKEN do not trigger a new CI run (GitHub prevents loops).
run: |
phpcbf --standard=phpcs.xml \
--extensions=php \
--ignore=vendor/*,tests/*,node_modules/* \
includes/ prautoblogger.php uninstall.php || true
if ! git diff --quiet; then
git config user.name "peptiderepo"
git config user.email "peptiderepo@users.noreply.github.com"
git add -A
git commit -m "style: auto-fix PHPCS violations via phpcbf"
git push
fi
- name: Run PHPCS (only non-auto-fixable violations can fail here)
run: |
phpcs --standard=phpcs.xml \
--extensions=php \
--ignore=vendor/*,tests/*,node_modules/* \
--warning-severity=0 \
--report=full \
includes/ prautoblogger.php uninstall.php
phpunit:
name: PHPUnit (PHP ${{ matrix.php }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ['8.1', '8.2', '8.3']
steps:
- uses: actions/checkout@v4
- name: Setup PHP ${{ matrix.php }}
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
coverage: none
tools: composer:v2
- name: Install composer dependencies
run: composer install --no-progress --no-interaction --prefer-dist
- name: Run PHPUnit (Brain Monkey — no WP install required)
run: vendor/bin/phpunit --testdox
- name: Check PRAUTOBLOGGER_* constants
# Fails if any PRAUTOBLOGGER_* constant referenced in the shipped
# plugin PHP is neither defined in prautoblogger.php nor guarded
# by defined() at the reference site. Prevents the v0.19.0 incident
# class (constant deleted from bootstrap, test fallback masks the gap).
run: php scripts/check-constants.php
- name: Self-test check-constants scanner
# Verifies the scanner itself can detect the failure cases it was
# designed to catch. If the scanner regresses, this step fails CI.
# Added v0.21.0; restored in v0.22.0 (was accidentally dropped in rewrite).
run: php scripts/check-constants.php --self-test

View File

@@ -1,20 +1,17 @@
name: CTO Review
# Automated PR review powered by Claude + DeepSeek + Kimi (via OpenRouter)
# plus Neurometric PR-Summarizer (a Qwen3-4B task-specialist SLM).
# Automated PR review powered by Claude Haiku (via OpenRouter).
#
# During the A/B window (until further notice), every PR gets reviewed by FOUR models
# in parallel. The PR comment shows each model's verdict side-by-side so we can compare
# quality/cost on real PRs before picking a permanent default.
# A/B panel decision 2026-06-12: panel dropped to Haiku-only.
# Evidence: convo/cto/threads/2026-06-qa-ab-review/02-analyst-findings.md
# DeepSeek: high false-positive rate (absint()="code exec", PLUGIN_DIR="dir traversal").
# Kimi: ~60% model failure rate across 13 PRs sampled.
# Neurometric: summarizer only, zero unique-real findings, unreliable heuristic verdict.
# Haiku: 0 failures, lowest noise, equivalent real-signal catch rate vs 4-voice panel.
#
# Neurometric is a "prose voice": it does not support OpenAI tool-calling, so its
# contribution is raw markdown + a heuristic verdict. It is never the primary voice.
# Does NOT merge. Posts formal APPROVE/REQUEST_CHANGES/COMMENT reviews.
#
# Does NOT merge. Does NOT post formal APPROVE/REQUEST_CHANGES reviews — always COMMENT
# (GITHUB_TOKEN can't formally approve PRs without repo-setting toggle).
#
# Requires repo secrets: OPENROUTER_API_KEY, NEUROMETRIC_API_KEY (optional; the
# Neurometric voice self-skips with an inline error note if the key is missing).
# Requires repo secret: OPENROUTER_API_KEY
#
# Cost cap: tracked in Repository Variables (CTO_REVIEW_SPEND_CENTS, CTO_REVIEW_CAP_CENTS).
# Default cap is 1000 cents ($10/month). Auto-resets on the 1st of each month.
@@ -125,8 +122,8 @@ jobs:
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
# Write the diff to a file, not a step output / env var. Passing a
# ~400KB diff through the process env (execve argv+envp) exceeds the
# runner ARG_MAX and fails the next step with "Argument list too long"
# (issue #110). File transport also sidesteps the ~1MB step-output cap.
# runner ARG_MAX and fails the next step with "Argument list too long".
# File transport also sidesteps the ~1MB step-output cap.
git diff --no-color "$BASE_SHA" "$HEAD_SHA" | head -c 400000 > /tmp/cto-review-diff.txt
FILES=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")
SIZE=$(wc -c < /tmp/cto-review-diff.txt)
@@ -151,12 +148,11 @@ jobs:
echo "EOF_CONV"
} >> "$GITHUB_OUTPUT"
- name: Call 4 models in parallel (OpenRouter + Neurometric)
- name: Call Haiku via OpenRouter
if: steps.budget.outputs.skip != 'true'
id: review
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
NEUROMETRIC_API_KEY: ${{ secrets.NEUROMETRIC_API_KEY }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
@@ -184,24 +180,17 @@ jobs:
fi
python3 - <<'PY'
import json, os, sys, urllib.request, concurrent.futures
import json, os, sys, urllib.request
api_key = os.environ["OPENROUTER_API_KEY"]
# A/B harness: four models. Each gets the same prompt.
# Primary model's verdict drives the heading. Others are labeled.
# Edit this list to change the roster (or drop to 1 after A/B window closes).
#
# provider_type semantics:
# - "openrouter_tools": OpenAI tool-calling via OpenRouter; structured verdict.
# - "neurometric_prose": Neurometric marketplace SLM; prose output + heuristic verdict.
MODELS = [
# (slug, display name, input_per_M_USD, output_per_M_USD, is_primary, provider_type)
("anthropic/claude-haiku-4.5", "Claude Haiku 4.5", 1.00, 5.00, True, "openrouter_tools"),
("deepseek/deepseek-chat-v3.1", "DeepSeek V3.1", 0.15, 0.75, False, "openrouter_tools"),
("moonshotai/kimi-k2-thinking", "Kimi K2 Thinking", 0.60, 2.50, False, "openrouter_tools"),
("neurometric/pr-summarizer", "Neurometric PR-Summarizer (Qwen3-4B)", 0.00, 0.00, False, "neurometric_prose"),
]
# Haiku-only reviewer. A/B panel dropped 2026-06-12 per QA data:
# DeepSeek high false-positive rate, Kimi ~60% failure rate,
# Neurometric zero unique-real findings. Haiku retained as sole voice.
MODEL_SLUG = "anthropic/claude-haiku-4.5"
MODEL_DISPLAY = "Claude Haiku 4.5"
INPUT_PER_M_USD = 1.00
OUTPUT_PER_M_USD = 5.00
system = """You are the CTO and lead reviewer for the peptiderepo project. You are reviewing a pull request opened by an autonomous agent or human contributor.
@@ -264,218 +253,100 @@ jobs:
}
}]
PER_MODEL_TIMEOUT = 60 # seconds; one hung model should not block siblings
body = {
"model": MODEL_SLUG,
"max_tokens": 2048,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user_message}
],
"tools": tools,
"tool_choice": {"type": "function", "function": {"name": "submit_review"}}
}
req = urllib.request.Request(
"https://openrouter.ai/api/v1/chat/completions",
data=json.dumps(body).encode(),
headers={
"Authorization": f"Bearer {api_key}",
"content-type": "application/json",
"HTTP-Referer": "https://github.com/peptiderepo",
"X-Title": f"peptiderepo CTO reviewer ({MODEL_DISPLAY})"
},
method="POST"
)
# Heuristic verdict inference for prose-voice models that can't do tool-calling.
# Keyword order matters: block markers beat approve markers.
PROSE_BLOCK_MARKERS = [
"do not merge", "do not approve", "should not be merged", "reject this",
"critical security", "critical issue", "critical vulnerability",
"blocker", "blocking issue", "request_changes", "request changes",
"**critical**", "sql injection", "must be addressed before",
]
PROSE_APPROVE_MARKERS = [
"safe to merge", "ready to merge", "no blocking issues", "lgtm",
"approve this", "looks good to merge", "no concerns",
]
error = None
review = None
cost_cents = 0.0
ptok = 0
ctok = 0
def infer_verdict_from_prose(text):
t = (text or "").lower()
if any(m in t for m in PROSE_BLOCK_MARKERS):
return "REQUEST_CHANGES"
if any(m in t for m in PROSE_APPROVE_MARKERS):
return "APPROVE"
return "COMMENT"
def call_openrouter_tools(slug, display, in_rate, out_rate, is_primary):
body = {
"model": slug,
"max_tokens": 2048,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user_message}
],
"tools": tools,
"tool_choice": {"type": "function", "function": {"name": "submit_review"}}
}
req = urllib.request.Request(
"https://openrouter.ai/api/v1/chat/completions",
data=json.dumps(body).encode(),
headers={
"Authorization": f"Bearer {api_key}",
"content-type": "application/json",
"HTTP-Referer": "https://github.com/peptiderepo",
"X-Title": f"peptiderepo CTO reviewer ({display})"
},
method="POST"
)
try:
with urllib.request.urlopen(req, timeout=PER_MODEL_TIMEOUT) as resp:
data = json.loads(resp.read())
except Exception as e:
return {
"slug": slug, "display": display, "is_primary": is_primary,
"provider_type": "openrouter_tools",
"error": str(e)[:300],
"cost_cents": 0,
}
try:
with urllib.request.urlopen(req, timeout=60) as resp:
data = json.loads(resp.read())
except Exception as e:
error = str(e)[:300]
data = {}
if not error:
choice = data.get("choices", [{}])[0].get("message", {})
tool_calls = choice.get("tool_calls", [])
if not tool_calls:
return {
"slug": slug, "display": display, "is_primary": is_primary,
"provider_type": "openrouter_tools",
"error": f"No tool_call in response. Raw content: {str(choice)[:300]}",
"cost_cents": 0,
}
error = f"No tool_call in response. Raw content: {str(choice)[:300]}"
else:
try:
review = json.loads(tool_calls[0]["function"]["arguments"])
except Exception as e:
error = f"Malformed tool args: {e}"
try:
review = json.loads(tool_calls[0]["function"]["arguments"])
except Exception as e:
return {
"slug": slug, "display": display, "is_primary": is_primary,
"provider_type": "openrouter_tools",
"error": f"Malformed tool args: {e}",
"cost_cents": 0,
}
if not error:
usage = data.get("usage", {})
ptok = usage.get("prompt_tokens", 0)
ctok = usage.get("completion_tokens", 0)
cost_usd = (ptok * INPUT_PER_M_USD / 1_000_000) + (ctok * OUTPUT_PER_M_USD / 1_000_000)
cost_cents = cost_usd * 100
usage = data.get("usage", {})
ptok = usage.get("prompt_tokens", 0)
ctok = usage.get("completion_tokens", 0)
cost_usd = (ptok * in_rate / 1_000_000) + (ctok * out_rate / 1_000_000)
cost_cents = cost_usd * 100
return {
"slug": slug, "display": display, "is_primary": is_primary,
"provider_type": "openrouter_tools",
"review": review, "prompt_tokens": ptok, "completion_tokens": ctok,
"cost_cents": cost_cents,
}
def call_neurometric_prose(slug, display, is_primary):
nm_key = os.environ.get("NEUROMETRIC_API_KEY", "")
if not nm_key:
return {
"slug": slug, "display": display, "is_primary": is_primary,
"provider_type": "neurometric_prose",
"error": "NEUROMETRIC_API_KEY secret not set on this repo",
"cost_cents": 0,
}
body = {
"model": slug,
"max_tokens": 2048,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user_message},
],
}
req = urllib.request.Request(
"https://api.neurometric.ai/v1/chat/completions",
data=json.dumps(body).encode(),
headers={
"Authorization": f"Bearer {nm_key}",
"content-type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=PER_MODEL_TIMEOUT) as resp:
data = json.loads(resp.read())
except Exception as e:
return {
"slug": slug, "display": display, "is_primary": is_primary,
"provider_type": "neurometric_prose",
"error": str(e)[:300],
"cost_cents": 0,
}
prose = data.get("choices", [{}])[0].get("message", {}).get("content") or ""
verdict = infer_verdict_from_prose(prose)
return {
"slug": slug, "display": display, "is_primary": is_primary,
"provider_type": "neurometric_prose",
"review": {"verdict": verdict, "prose": prose},
"prompt_tokens": 0, "completion_tokens": 0,
"cost_cents": 0,
}
def call_model(slug, display, in_rate, out_rate, is_primary, provider_type="openrouter_tools"):
if provider_type == "neurometric_prose":
return call_neurometric_prose(slug, display, is_primary)
return call_openrouter_tools(slug, display, in_rate, out_rate, is_primary)
results = []
with concurrent.futures.ThreadPoolExecutor(max_workers=len(MODELS)) as pool:
futures = [pool.submit(call_model, *m) for m in MODELS]
for f in concurrent.futures.as_completed(futures):
results.append(f.result())
# Order: primary first, then alphabetical for stable comparison
results.sort(key=lambda r: (not r.get("is_primary"), r["display"]))
# Build combined comment
primary = next((r for r in results if r.get("is_primary") and "review" in r), None)
heading_verdict = primary["review"]["verdict"] if primary else "MIXED"
lines = [f"## CTO Review — {heading_verdict}", ""]
lines.append(f"_A/B harness active: {len(results)} models reviewing in parallel. Primary verdict drives heading; others are advisory._")
lines.append("")
total_cents = 0.0
for r in results:
lines.append(f"---")
primary_tag = " (primary)" if r.get("is_primary") else ""
lines.append(f"### {r['display']}{primary_tag}")
if "error" in r:
lines.append("")
lines.append(f"⚠️ Model failed: `{r['error']}`")
lines.append("")
continue
rev = r["review"]
# Build comment
if error:
heading_verdict = "ERROR"
lines = [f"## CTO Review — {heading_verdict}", ""]
lines.append(f"Model failed: `{error}`")
lines.append("")
if r.get("provider_type") == "neurometric_prose":
lines.append(f"**Verdict:** {rev['verdict']} _(heuristic, inferred from prose)_")
lines.append("")
# Cap at 6KB to keep PR comments readable; 4B model output is rarely over this
prose = rev.get("prose", "")
if len(prose) > 6000:
prose = prose[:6000] + "\n\n_…output truncated for comment length…_"
lines.append(prose)
lines.append("")
lines.append("_free tier; per-call token usage not tracked_")
lines.append("")
continue
lines.append(f"**Verdict:** {rev.get('verdict', 'COMMENT')}")
lines.append(f"_reviewer: {MODEL_DISPLAY}_")
else:
heading_verdict = review.get("verdict", "COMMENT")
lines = [f"## CTO Review — {heading_verdict}", ""]
lines.append(f"**Verdict:** {heading_verdict}")
lines.append("")
lines.append(rev.get("summary") or "_(model returned no summary)_")
lines.append(review.get("summary") or "_(model returned no summary)_")
lines.append("")
if rev.get("concerns"):
if review.get("concerns"):
lines.append("**Concerns:**")
for c in rev["concerns"]:
sev = c["severity"].upper()
for c in review["concerns"]:
sev = c.get("severity", "UNKNOWN").upper()
f = f"`{c['file']}`" if c.get("file") else ""
lines.append(f"- **{sev}** {f} — {c['issue']}")
lines.append("")
if rev.get("positives"):
lines.append("**Positives:** " + "; ".join(rev["positives"]))
if review.get("positives"):
lines.append("**Positives:** " + "; ".join(review["positives"]))
lines.append("")
lines.append(f"_tokens in/out: {r['prompt_tokens']}/{r['completion_tokens']} · cost: ${r['cost_cents']/100:.4f}_")
total_cents += r["cost_cents"]
lines.append(f"_tokens in/out: {ptok}/{ctok} · cost: ${cost_cents/100:.4f}_")
lines.append("")
lines.append("---")
lines.append(f"_Total review cost: **${total_cents/100:.4f}**. A/B roster configured in `.github/workflows/cto-review.yml`._")
lines.append(f"_Total review cost: **${cost_cents/100:.4f}**. Reviewer: {MODEL_DISPLAY}. Roster configured in `.github/workflows/cto-review.yml`._")
with open("/tmp/review-body.md", "w") as f:
f.write("\n".join(lines))
with open(os.environ["GITHUB_OUTPUT"], "a") as f:
f.write(f"added_cents={int(round(total_cents))}\n")
f.write(f"added_cents={int(round(cost_cents))}\n")
f.write(f"skipped=0\n")
f.write(f"primary_verdict={heading_verdict}\n")
PY
- name: Post PR review (formal APPROVE / REQUEST_CHANGES / COMMENT)
# Uses primary model's verdict as the formal event. Falls back to COMMENT
# if the primary model failed or returned something unexpected.
# Uses Haiku's verdict as the formal event. Falls back to COMMENT
# if the model failed or returned something unexpected.
if: steps.budget.outputs.skip != 'true' && steps.review.outputs.skipped != '1'
uses: actions/github-script@v7
env:

View File

@@ -9,29 +9,17 @@ concurrency:
cancel-in-progress: true
jobs:
validate:
name: PHP Lint Gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.1'
coverage: none
- name: PHP Lint Check
run: |
set -e
find . -name '*.php' \
-not -path './vendor/*' \
-not -path './node_modules/*' \
-not -path './.git/*' \
-not -path './.github/*' \
-print0 | xargs -0 -n1 -P4 php -l > /dev/null
ci:
name: CI Gate (reusable)
uses: ./.github/workflows/ci.yml
permissions:
contents: write
secrets: inherit
deploy:
name: Deploy (shared pipeline)
needs: validate
uses: peptiderepo/peptide-e2e/.github/workflows/deploy-app.yml@main
needs: ci
uses: peptide-repo/peptide-e2e/.github/workflows/deploy-app.yml@main
with:
target_path: wp-content/plugins/prautoblogger
secrets: inherit

View File

@@ -2,6 +2,8 @@
> **Cross-app context:** decisions that affect multiple plugins (Cloudflare AI Gateway routing, OpenRouter account sharing, the interface pattern, image-generation stack, social distributor choice) are recorded in `Peptide Repo CTO/docs/engineering/decisions/`. The incident runbook for cross-app failure modes is at `Peptide Repo CTO/docs/engineering/INCIDENT-RUNBOOK.md`. Engineer PRAutoBlogger should read both before making decisions that cross plugin boundaries.
> **App-local decisions:** choices that affect only this plugin are recorded in [`docs/adr/`](docs/adr/README.md) — a per-repo numbered log (0001, 0002, …). Read `docs/adr/README.md` for the index. The routing rule and required template live centrally in `Peptide Repo CTO/docs/engineering/ADR-PROCESS.md`.
PRAutoBlogger is a WordPress plugin that monitors social media (starting with Reddit) for recurring questions, complaints, and comparisons in a configured niche, uses LLM agents (via OpenRouter) to generate high-quality blog articles from those insights, runs them through a chief editor agent for quality review, and auto-publishes them on a configurable daily schedule. All collected data and generation metrics are stored for a self-improvement feedback loop.
---
@@ -119,16 +121,24 @@ prautoblogger/
│ │ └── OFL.txt # SIL Open Font License
│ ├── css/
│ │ ├── admin.css # Admin page styles (wp-admin conventions)
│ │ ├── board.css # Kanban board styles -- warm editorial palette (v0.19.0)
│ │ ├── dossier.css # Article dossier page styles: verdict pills, cost receipt, trace toggle (v0.19.2)
│ │ ├── dossier-edit.css # M3 edit/re-run layer: chips, edit panel, image grid, spend warning (v0.20.0)
│ │ └── posts-widget.css # Frontend posts widget styles (uses theme CSS vars)
│ └── js/
│ ├── admin.js # Admin page interactivity (vanilla JS / Alpine.js)
│ ├── board.js # Board poller: AJAX poll + DOM updates + backoff (v0.19.0)
│ ├── dossier.js # Per-stage raw-trace toggle: aria-expanded / aria-controls (v0.19.2)
│ ├── dossier-edit.js # M3 edit/re-run actions + stage-status polling (queued -> pickup -> result) (v0.20.0)
│ └── posts-widget.js # React component for frontend post cards (wp.element)
│
├── includes/
│ ├── class-prautoblogger.php # Main orchestrator — registers all hooks, delegates execution
│ ├── class-pipeline-schema-installer.php # v0.18.0 substrate tables (prompts, run_sources, run_decisions, runs, run_stages)
│ ├── class-prautoblogger.php # Main orchestrator — registers all hooks, delegates execution (split v0.19.2)
│ ├── class-db-migrations.php # DB migration methods extracted from class-prautoblogger.php (v0.19.2 split, binding 1)
│ ├── class-pipeline-schema-installer.php # Pipeline substrate tables (prompts, run_sources, run_decisions, runs, run_stages, stage_inputs)
│ ├── class-migrate-prompt-seed-v0180.php # One-shot v0.18.0 prompt-registry seed migration
│ ├── class-executor.php # Cron handlers, generation AJAX (start + status), model registry
│ ├── class-executor.php # Cron handlers, generation AJAX (delegates start + status to poller), model registry
│ ├── class-generation-status-poller.php # Generation AJAX handlers: status transient renewal, lock-age orphan detection (R2b/R3), abort_orphaned_run
│ ├── class-ajax-handlers.php # Non-generation AJAX: images, models, test connections
│ ├── class-generation-lock.php # DB-level atomic mutex for single-writer generation
│ ├── class-activator.php # Activation: create DB tables, set defaults, schedule cron
@@ -136,6 +146,15 @@ prautoblogger/
│ ├── class-autoloader.php # PSR-4-style autoloader for plugin classes
│ │
│ ├── admin/
│ │ ├── class-board-page.php # Kanban board -- primary landing screen (v0.19.0)
│ │ ├── class-board-data-provider.php # Board orchestrator: 4-column snapshot + dossier deep-links (v0.19.0, v0.19.2)
│ │ ├── class-board-gen-log-query.php # Board gen_log queries: Generating + Failed column raw DB reads (v0.19.0)
│ │ ├── class-dossier-page.php # Article dossier admin page: options.php-parent hidden-page, priority 12, enqueue, nonce (v0.19.3)
│ │ ├── class-dossier-data-assembler.php # 5-query view-model builder: runs+stages+gen_log+decisions+meta (v0.19.2)
│ │ ├── class-dossier-gen-log-query.php # Dossier gen_log queries: per-stage cost receipt + raw trace (v0.19.2)
│ │ ├── class-dossier-actions.php # M3 AJAX: save fork / queue replay / queue rebuild / stage-status poll (v0.20.0)
│ │ ├── class-dossier-rerun-panel-data.php # M3 per-stage affordance + spend view model (v0.20.0)
│ │ ├── class-dossier-image-data.php # F3: post's pipeline attachments via _prautoblogger_image_role (v0.20.0)
│ │ ├── class-admin-page.php # Main settings page (tabbed SaaS-style UI)
│ │ ├── class-settings-fields.php # Declarative settings: sections + core fields (API/Models/Content/Sources)
│ │ ├── class-settings-fields-extended.php # Operational fields: schedule, publishing, display, analytics, images
@@ -182,7 +201,15 @@ prautoblogger/
│ │ ├── class-run-state.php # runs-table ledger + lifecycle row (ceiling/reserved/settled, pins, status)
│ │ ├── class-cost-governor.php # Per-run reserve-before-call enforcement (atomic conditional UPDATE)
│ │ ├── class-cost-ceiling-exception.php # Thrown on ceiling breach (run already halted)
│ │ ├── class-run-stage-state.php # Per-run per-stage state machine (idempotent resume, output snapshots)
│ │ ├── class-run-stage-state.php # Per-run per-stage state machine: read API + thin write proxies (split v0.20.0)
│ │ ├── class-run-stage-writes.php # Pipeline-path writes (start/done/fail; done clears stale) — extracted v0.20.0
│ │ ├── class-run-stage-rerun-state.php # M3 operator-action mutations: restart/mark_stale/demote (v0.20.0)
│ │ ├── class-stage-input-store.php # INSERT-only stage_inputs store: edit forks + idea seeds (v0.20.0)
│ │ ├── class-rerun-eligibility.php # M3 policy gates: frozen posts, editable stages, chain order (v0.20.0)
│ │ ├── class-stage-replay.php # M3 governed single-stage replay from a fork body (v0.20.0)
│ │ ├── class-rerun-executor.php # M3 cron handlers: replay + rebuild jobs (v0.20.0)
│ │ ├── class-rerun-job-support.php # M3 queue/lock/budget/status-transient plumbing (v0.20.0)
│ │ ├── class-request-recorder.php # B1: process-scoped outbound request-body stash -> generation_log.request_json (v0.20.0)
│ │ ├── class-run-reaper.php # Stuck-run sweep + audit-payload retention (rides the #19 cron)
│ │ ├── class-audit-writer.php # run_sources / run_decisions insert layer
│ │ ├── class-pipeline-status.php # Status-transient + summary helpers (extracted from runner/worker)
@@ -195,6 +222,7 @@ prautoblogger/
│ ├── providers/
│ │ ├── interface-llm-provider.php # Contract for any LLM provider
│ │ ├── class-open-router-provider.php # OpenRouter API implementation (chat completions)
│ │ ├── class-open-router-completion-guard.php # Empty-completion guard: warn on finish_reason=length, one reasoning-off retry, audited failure (v0.18.1)
│ │ ├── class-open-router-embedding-provider.php # Text embeddings for semantic dedup
│ │ ├── class-open-router-pricing.php # Model pricing lookup and cost estimation
│ │ ├── interface-source-provider.php # Contract for any social media source
@@ -207,11 +235,13 @@ prautoblogger/
│ │ ├── class-open-router-image-support.php # API key, response parsing, retry/backoff helpers
│ │ ├── class-open-router-image-pricing.php # Model resolution + per-image cost estimation
│ │ ├── class-open-router-config.php # API base URL (direct vs AI Gateway)
│ │ ├── class-open-router-request-builder.php # Header building + Hostinger cURL auth workaround
│ │ ├── class-open-router-request-builder.php # Request body assembly + reasoning token cap/headroom (v0.18.1) + headers + Hostinger cURL auth workaround
│ │ ├── class-runware-image-provider.php # Runware FLUX.1 (default v0.9.0+)
│ │ ├── class-runware-image-pricing.php # FLUX schnell/dev cost table + resolver
│ │ ├── class-runware-image-support.php # Key, response parsing, retry, dimension snap
│ │ ├── class-runware-image-batch.php # True parallel curl_multi batch dispatcher
│ │ ├── class-runware-model-catalog.php # Live model catalog sync: modelSearch pagination, cache v2, failure cooldown
│ │ ├── class-runware-catalog-fetcher.php # HTTP fetch + normalize_models for Runware modelSearch (extracted for 300-line rule)
│ │ └── (new providers go here — see CONVENTIONS.md)
│ │
│ ├── services/
@@ -235,11 +265,19 @@ prautoblogger/
│
├── templates/
│ └── admin/
│ ├── board-page.php # Kanban board template (4-column editorial layout) (v0.19.0)
│ ├── dossier-page.php # Article dossier: two-column layout, sidebar, stage sections (v0.19.2)
│ ├── dossier-stage-section.php # Stage section partial: output, raw trace, M3 chips + rerun footer
│ ├── dossier-edit-panel.php # M3 per-stage edit panel: message textareas, fork info, spend strip (v0.20.0)
│ ├── dossier-log-stage-section.php # F3 log-only stage sections incl. image attachment grid (v0.20.0)
│ ├── dossier-sidebar-cards.php # M3 sidebar: run spend (guardrail 4) + models/pv consolidation (F2) (v0.20.0)
│ ├── dossier-stage-section.php # Per-stage block: rendered output + raw-trace toggle + cost receipt (v0.19.2)
│ ├── metabox-dossier-link.php # Slim post metabox: "View generation dossier →" link (v0.19.2)
│ ├── settings-page.php # Settings page template (tabbed sidebar layout)
│ ├── metrics-page.php # Metrics/cost dashboard template
│ ├── review-queue.php # Review queue table template
│ ├── log-viewer.php # Activity log viewer template
│ └── metabox-generation-info.php# Post metabox template
│ └── metabox-generation-info.php# Post metabox template (superseded by metabox-dossier-link.php in v0.19.2)
│
├── languages/
│ └── prautoblogger.pot # i18n translation template
@@ -336,11 +374,19 @@ Hostinger's 120-second LiteSpeed connection timeout. The frontend polls for stat
└──────────────────────────────┘
```
**Fallback detection:** If Hostinger kills the PHP process before it updates the
transient (despite `ignore_user_abort`), the status endpoint detects the orphan after
180 seconds by checking for recently-created posts with `_prautoblogger_run_id` meta.
It also releases any stale generation lock. After 600 seconds (STATUS_TTL), it gives
up unconditionally and reports a timeout.
**Fallback detection (v0.18.3 R2b/R3):** The status transient is renewed on every stage
transition via `update_generation_stage()` (`Pipeline_Status::broadcast()`), so its TTL
always counts from the last activity rather than run start. When a status poll finds the
transient absent:
- **Lock within TTL (`lock_age ≤ STATUS_TTL = 600s`):** the background process is still alive
but has not broadcast yet (or the transient was evicted). `handle_missing_transient()` returns
`status: running` (R3) — button stays in-progress, no state change.
- **Lock exceeded TTL (`lock_age > STATUS_TTL`):** background process died without releasing the
lock. `abort_orphaned_run()` marks the `runs` row `failed`, releases the generation lock, deletes
the status transient and queue option, and returns `status: error` with "infrastructure timeout"
(R2b). A daily `Run_Reaper::sweep_stuck_runs()` at 2× `EXPECTED_RUN_SECONDS` (~3600s) remains
the backstop for runs the poll path never sees.
---
@@ -505,6 +551,7 @@ dropped on uninstall.
| verdict | VARCHAR(50) | e.g. 'approved', 'revised', 'rejected', 'halted' |
| rationale | TEXT | Decision rationale (nullable) |
| citation_score | FLOAT | Nullable until the Phase-2 editorial loop computes it |
| human_modified | TINYINT(1) | v0.20.0 — decision derives from a human-edited input (set on replay of the stage, or on decisions recorded while rebuilding a human-modified item) |
| created_at | DATETIME | Row creation time |
- INDEX: `run_id`
@@ -535,18 +582,44 @@ dropped on uninstall.
| id | BIGINT UNSIGNED | Auto-increment PK |
| run_id | VARCHAR(36) | Pipeline run UUID |
| stage | VARCHAR(50) | Stage name (see stage vocabulary) |
| agent_role | VARCHAR(50) | Fan-out dimension for Phase-2 quorum ('' default) |
| agent_role | VARCHAR(50) | Fan-out dimension. Populated in Phase 1 from `Stage_Display_Map::default_agent_role()` (e.g. 'writer', 'editor', 'publisher'). Phase-2 quorum passes multiple roles for the same stage. |
| item_key | VARCHAR(64) | Scopes article-level stages within multi-article runs ('' for run-level) |
| status | VARCHAR(20) | pending / running / done / failed / halted |
| attempt | SMALLINT UNSIGNED | Re-entry counter |
| cost_usd | DECIMAL(10,6) | Cost attributed to the stage |
| meta_json | LONGTEXT | Stage output snapshot — lets resume reuse a done stage instead of re-charging it (payload pruned on the retention cron) |
| human_modified | TINYINT(1) | v0.20.0 — set (sticky, never cleared) when an edited input fork enters execution for this row (CPO guardrail 2) |
| stale | TINYINT(1) | v0.20.0 — upstream changed after this row completed; row STAYS 'done' so resume never silently re-runs it; cleared only by a fresh done() |
| started_at | DATETIME | First entry |
| updated_at | DATETIME | Last transition |
| finished_at | DATETIME | Set on done/failed (nullable) |
- UNIQUE: `run_id, stage, agent_role, item_key` (the idempotency key) · INDEX: `status, updated_at`
##### `prautoblogger_stage_inputs` — immutable input-version store (v0.20.0, db 1.3.0)
| Column | Type | Description |
|--------------|-----------------|--------------------------------------------------|
| id | BIGINT UNSIGNED | Auto-increment PK |
| run_id | VARCHAR(36) | Pipeline run UUID |
| stage | VARCHAR(50) | Stage name ('' for idea seeds) |
| agent_role | VARCHAR(50) | Stage row agent role |
| item_key | VARCHAR(64) | Article scope |
| version | INT UNSIGNED | Monotonic per scope; **rows are INSERT-only** — edits create the next version, originals are never overwritten |
| source | VARCHAR(20) | 'human' (edit fork) or 'seed' (Article_Idea payload persisted at worker start) |
| request_json | LONGTEXT | Fork body / idea JSON. Human fork bodies pruned by the retention cron after R days; seed rows (~1KB) kept |
| author | VARCHAR(100) | WP user login, or 'pipeline' for seeds |
| created_at | DATETIME | Row creation time |
- UNIQUE: `run_id, stage, agent_role, item_key, version` · INDEX: `run_id`
- The ORIGINAL executed input of a stage is `generation_log.request_json` (B1) — it is
never copied or modified here; forks are the edit history (CPO guardrail 1).
**db version note:** `PRAUTOBLOGGER_DB_VERSION` moved 1.1.0 → 1.3.0 in v0.20.0. The
constant never actually carried the "db 1.2.0" this document used as shorthand for the
v0.18.0 substrate; 1.3.0 leapfrogs the phantom value so the version-compare self-healing
migration fires exactly once everywhere.
### WordPress Options (`wp_options`)
All prefixed with `prautoblogger_`:
@@ -561,6 +634,7 @@ All prefixed with `prautoblogger_`:
| `prautoblogger_analysis_model` | OpenRouter model for analysis (default: cheap) |
| `prautoblogger_writing_model` | OpenRouter model for writing (default: quality) |
| `prautoblogger_editor_model` | OpenRouter model for chief editor (default: quality) |
| `prautoblogger_reasoning_max_tokens` | v0.18.1 — hard cap on thinking tokens per call when reasoning is enabled; sent as OpenRouter `reasoning.max_tokens` (replaces `effort` when active) and the request `max_tokens` is raised by the cap so reasoning can never consume the visible-content budget; 0 = legacy uncapped effort mode. Default `PRAUTOBLOGGER_DEFAULT_REASONING_MAX_TOKENS` (2048) |
| `prautoblogger_daily_article_target` | Number of articles per day (1-10, default: 1) |
| `prautoblogger_writing_pipeline` | 'single_pass' or 'multi_step' (default: multi_step) |
| `prautoblogger_niche_description` | Text description of the site's niche |
@@ -595,6 +669,8 @@ All prefixed with `prautoblogger_`:
| `prautoblogger_image_compose_variants` | Comma list of composed variants for Image A (default 'og,square'; whitelisted at point of use) |
| `prautoblogger_image_featured_mark_enabled` | Toggle: subtle corner mark on featured images (default '1') |
| `prautoblogger_image_compose_capability` | Cached capability probe `{fingerprint, capability}`; fingerprint = PHP version + imagick/gd presence, so it auto-invalidates on host changes |
| `prautoblogger_board_poll_interval` | Kanban board poll interval in seconds (default 5, min 3). Localized into board.js. |
| `prautoblogger_board_published_window_days` | Days back to show in the Published column (default 7). |
### Post Meta
@@ -829,14 +905,175 @@ multi-step publish paths. Four subsystems:
keyed by `_prautoblogger_run_id` + `_prautoblogger_idea_hash` (check-before-insert) so a
retried run cannot duplicate a post. `PRAutoBlogger_Run_Reaper` (same daily cron as #19)
marks runs stuck `running` > 2× expected stage wall-clock as `failed`; such runs render
as **"incomplete"** in audit queries. Quorum logic itself is Phase 2 — the schema just
reserves its dimensions.
as **"incomplete"** in audit queries. Quorum logic (multiple roles per stage) itself is Phase 2. In Phase 1,
`agent_role` is populated on every row from `Stage_Display_Map::default_agent_role()`
so the run timeline is self-describing and the idempotency key is fully populated.
### #20: PHPUnit test infrastructure + WordPress-Core PHPCS compliance (v0.10.1)
Tests use Brain\Monkey for WordPress function mocking (no database required). The BaseTestCase singleton handles setup/teardown of Monkey stubs and provides fixture helpers for common data shapes (SourceData, ArticleIdea, GenerationLog, etc.). All WordPress functions used by the plugin are stubbed in BaseTestCase::setUp() so individual test classes only override what they need. In v0.10.1, missing stubs for `post_type_exists()` were added to support PeptideLinker's PR-Core guards. All 1,362 WordPress-Core coding-standards violations (short array syntax, line length, spacing) were auto-fixed via phpcbf and the CI gate was flipped to strict mode — PHPCS failures now block CI. This prevents future regressions on the code style front.
### #22b: Kanban board as primary admin landing screen (v0.19.0 / v0.19.1)
**Hidden admin page convention (v0.19.3):** See CONVENTIONS.md §Hidden Admin Pages for the full rule. Summary: hidden admin pages (link-accessed only, not visible in nav) MUST use `options.php` as the `add_submenu_page()` parent. NEVER unset/remove registered pages from `$submenu` after registration -- doing so causes `get_admin_page_parent()` to fail at request time, which makes WP recompute the hookname in the `admin_page_*` orphan namespace, find no registered handler, and call `wp_die(403)`. This is the dossier 403 incident (v0.19.2→v0.19.3). Same hookname-mismatch class as the board 404 (v0.19.0→v0.19.1); the only difference is that the board bug was a priority-ordering issue at registration time while the dossier bug was a post-registration $submenu mutation.
The primary admin screen is now a kanban board (Direction C "Editorial Record") with four
columns: Generating | In Review | Published | Failed. A separate `class-board-page.php`
registers the `Board` submenu and a `$submenu` reorder makes it the first (primary) entry.
`class-board-data-provider.php` orchestrates the four columns and handles WP_Query columns
(In Review, Published). Raw `prab_generation_log` queries (Generating, Failed) are in the
extracted `class-board-gen-log-query.php` (split for 300-line compliance) -- no new schema
required for M1. The data provider uses the `prautoblogger_generation_status` transient
(written by the Executor for manual/daily runs) as the primary active-run signal, with a
secondary query on recent `prab_generation_log` run_ids that have no linked post as a
fallback. M2 will rename Runs & Audit -> Articles and wire board cards to the Article Dossier.
Poll interval and published window are settings-backed, localized into board.js, never
hardcoded.
**Menu-ordering constraint (v0.19.1 hotfix):** `PRAutoBlogger_Board_Page::on_register_menu`
MUST be hooked at `admin_menu` priority 11 (or higher), AFTER
`PRAutoBlogger_Admin_Page::on_register_menu` which runs at priority 10. Reason: WordPress
computes the page hookname via `get_plugin_page_hookname()` at `add_submenu_page()` call
time. If `add_menu_page()` has not yet run, `$admin_page_hooks['prautoblogger-settings']`
is unset and WordPress falls back to the `admin_page_*` hookname. At HTTP request time WP
recomputes the hookname as `prautoblogger_page_prautoblogger-board` and finds no registered
callback -> `wp_die("Invalid plugin page")` 404. The enqueue-gate in `on_enqueue_assets()`
checks `prautoblogger_page_prautoblogger-board` and would similarly never fire with the
wrong hookname (board.css/board.js would silently not load). Regression test:
`tests/unit/Admin/BoardMenuRegistrationTest.php`.
### #23: Article Dossier page — per-article inspection (v0.19.2)
The dossier is a read-only per-article admin page that exposes the full substrate view for
a single post: generation run, stage I/O, cost receipts, raw-trace toggles, editorial
decisions, and image A/B pairs. It is link-accessed only (hidden submenu) — no navigation
entry appears in the WP sidebar.
**Page registration** (`class-dossier-page.php`): uses the canonical hidden-admin-page
pattern -- registered under parent **`options.php`** at `admin_menu` priority **12**. The
hookname is `admin_page_prautoblogger-dossier` at BOTH registration time and request time,
so there is no hookname mismatch and no need to manipulate `$submenu`. The page does not
appear in the WP admin sidebar (options.php-parent pages are hidden by construction).
URL shape: `admin.php?page=prautoblogger-dossier&post_id=<int>` (unchanged from v0.19.2 --
deep-link URLs are parent-agnostic). Secured by `manage_options` capability. Static
`url_for_post(int $post_id): string` provides the canonical deep-link.
v0.19.2 used `prautoblogger-settings` as parent with post-registration `unset($submenu[...])` to
hide the page. This caused `get_admin_page_parent()` to fail at request time -> orphan
`admin_page_*` hookname -> no handler registered -> `wp_die(403)`. Fixed in v0.19.3 by switching
to `options.php` parent and removing all `$submenu` mutation. See CONVENTIONS.md §Hidden Admin
Pages for the full rule and ARCHITECTURE.md §22b for both incident records.
Regression test: `tests/unit/Admin/DossierMenuRegistrationTest.php` -- the
`test_request_time_hookname_matches_registration_hookname` test replicates the request-time
resolution algorithm (simulate `get_admin_page_parent()`) so it FAILS on hide-by-unset and
PASSES on options.php-parent.
**5-query data assembly** (`class-dossier-data-assembler.php`):
```
1. wp_prautoblogger_runs WHERE run_id = %s -- 1 row (run metadata)
2. wp_prautoblogger_run_stages WHERE run_id = %s -- N rows (per-stage status/cost)
3. wp_prautoblogger_generation_log WHERE run_id = %s -- N rows (model/pv/tokens/request)
4. wp_prautoblogger_run_decisions WHERE run_id = %s -- N rows (editorial rationale)
5. get_post_meta($post_id) -- WP object cache hit
```
All queries are keyed on `run_id` (indexed). No N+1. Stage output is reconstructed from
`run_stages.meta_json → json_decode → meta['output']` (same path as `Run_Stage_State::
get_output()`). The dossier assembler does NOT call `Run_Stage_State` directly; it performs
its own raw queries so the view is not coupled to the cache layer.
**Legacy graceful state (binding 5):** Posts published before v0.18.0 (no runs/run_stages
rows) and posts where `_prautoblogger_run_id` is absent or blank return `['has_run' => false]`.
The template renders a clean "No generation record for this article" notice. No PHP notices
or fatals.
**Amortized research rows:** `generation_log` rows with `prompt_version = NULL` and
`agent_role = ''` (from the `llm_research` stage orphan-reaper path) are rendered with
model shown as "—" and no raw-trace toggle (request_json may be NULL). No fatal/notice.
**Raw-trace security (binding 4):** `request_json` stores the OpenRouter request body —
model identifier, messages array, temperature. Authorization headers are never logged (the
outbound HTTP client strips them before logging, per the original architecture decision on
`wp_prautoblogger_generation_log`). The dossier template renders `request_json` via
`esc_html()` inside a `<pre>` block. All model-generated text rendered via `wp_kses_post()`
(rendered view) or `esc_html()` (raw trace). Both paths treat content as untrusted HTML.
**File naming note:** `PRAutoBlogger_DB_Migrations` is loaded via explicit `require_once`
in `prautoblogger.php` (not the autoloader). The autoloader converts `DB_Migrations` to the
kebab segment `d-b-migrations`, producing `class-d-b-migrations.php` — which does not match
the file `class-db-migrations.php`. Explicit loading is the canonical pattern for classes
whose names do not round-trip cleanly through the kebab converter. `class-prautoblogger.php`
uses the same pattern (also explicitly required in `prautoblogger.php`).
### #24: Edit + single-step re-run under CPO guardrails (v0.20.0, db 1.3.0, June 2026)
Phase 2 admin M3 (design contract: cpo seq 8 Proposal C + the five guardrails of the
edit+rerun delta ruling; decision `2026-06-12-admin-redesign-direction-c.md`).
**B1 — request_json persistence.** The OpenRouter provider stashes every outgoing chat
request BODY in the process-scoped `PRAutoBlogger_Request_Recorder` (the Run_Context
pattern) right after `build_body()`; `Cost_Tracker::log_api_call()` consumes it into
`generation_log.request_json`. Consume-once + overwrite-on-record means a stale body can
never attach to an unrelated row; recording happens pre-dispatch so error rows carry the
request too. Authorization can never be recorded: headers are built separately
(`build_headers()`) and `build_body()` copies only whitelisted option keys. Retention
rides the existing `prautoblogger_request_json_retention_days` prune unchanged.
**Two re-run primitives, both chained-cron (never synchronous):** the dossier AJAX layer
(`Dossier_Actions`) only validates + queues; the cron handlers (`Rerun_Executor`)
re-validate eligibility UNDER the generation lock before mutating anything.
1. **Replay** — one governed chat call from the latest edited input fork
(`stage_inputs`, INSERT-only versions). `Stage_Replay::options_from_body()` is the
exact inverse of `build_body()`+`apply_reasoning_budget()` (reasoning headroom
subtracted back out; reasoning pinned explicitly so global-setting drift cannot
reshape a replay). The call goes through `send_chat_completion`, so the cost
governor reserves against the SAME run ledger row (guardrail 4), with the normal
retry/empty-completion machinery. Output lands in the stage's run_stages snapshot —
the same place downstream rebuilds read from.
2. **Rebuild ("re-run from here")** — demotes the target + downstream chain stages to
`pending` and re-enters `Article_Worker` with the idea reconstructed from the stored
seed (`stage_inputs`, source='seed', persisted at worker start because re-deriving
the idea from post fields could break the item_key hash and duplicate a post).
Upstream done stages are reused (never re-charged); demoted stages rebuild their
prompts from CURRENT upstream snapshots — this is how an edit propagates downstream.
**Stale is a column, not a status.** A stale stage stays `done`, so
resume-without-recharge logic can never silently re-run it — guardrail 3 (no silent
auto-rerun; each downstream re-run is a deliberate operator action) holds by
construction. Only `Run_Stage_Writes::done()` clears `stale`; only
`Run_Stage_Rerun_State` (operator-action paths) may demote a done row. `human_modified`
is sticky for the life of the run (set when a fork enters execution, surfaced as header/
stage chips in the dossier, a board-card chip, and a Review Queue link-chip — the
run-list-level visibility the product AC requires).
**Run reopening.** `Run_State::reopen()` is the atomic terminal→running gate for re-run
spend; it RE-SNAPSHOTS `ceiling_usd` from the current setting (a deliberate re-run
adopts the operator's current per-run policy, exactly like a new run) and never resets
reserved/settled — re-runs are new spend on the same run. Handlers always restore a
terminal status (or record failed/halted) so the run-reaper's stuck-running sweep stays
meaningful; jobs hold the global generation lock and ride the M1 status transient, so
board/dossier polling shows queued → pickup → result and the existing R2/R3 orphan
recovery covers dead re-run processes unchanged.
**Eligibility (guardrail 5).** Posts with status publish/future/private freeze their
run server-side (and `Publisher` refuses to touch them: re-run output refreshes only
UNPUBLISHED posts, preserving their status — re-runs never publish). Editable stages
are the item-scoped writer chat stages (outline/draft/polish); review/publish re-run
via rebuild (their inputs are derived), run-level and image stages are excluded with
visible in-UI reasons. Phase-2b stages inherit the mechanism when they exist as
item-scoped chat calls.
**F2/F3 (QA M2):** the sidebar consolidates per-stage model/pv/role; image (and other
log-only) stages render from generation_log + the `_prautoblogger_image_role`
attachment set — wiring the dossier to real data was deliberately chosen over teaching
the live image pipeline to write run_stages (that belongs to the Phase-2b restructure).
## Cross-System LLM Budget Coordination
PRAutoBlogger and Peptide News both call OpenRouter and may share a single API key / billing account. Their combined spend should be considered when setting per-plugin budgets.
@@ -849,3 +1086,66 @@ PRAutoBlogger and Peptide News both call OpenRouter and may share a single API k
**Important:** If your OpenRouter account has a global spending limit, set each plugin's budget to less than half the total. PRAutoBlogger will hard-stop when its budget is exhausted, but Peptide News currently has no budget enforcement — a spike in news fetches could consume shared quota.
**Future improvement:** A shared `wp_options` key (e.g., `ecosystem_monthly_llm_budget`) that both plugins read, with each plugin reserving its allocation on startup. This requires coordination at the ecosystem level and is tracked as a medium-term goal.
## §25 Chained-Cron Checkpoint Generation Path (v0.21.0, M4)
### Problem
Hostinger shared hosting kills PHP background processes after ~120 s. The former
`on_manual_generation()` path in `Executor` ran the complete orchestrate +
generate pipeline in a single PHP process — meaning a host kill mid-run left the
generation lock held, the run row stuck in `running`, and partially-generated
articles orphaned with no status broadcast.
### Solution: Two-Tick Checkpoint Machine
`PRAutoBlogger_Generation_Checkpoint_Runner` (v0.21.0) splits the pipeline into
independent cron ticks, each of which completes well within Hostinger's limit:
| Tick | WP-Cron action | What happens |
|------|---------------|-------------|
| 1 | `prautoblogger_gen_orchestrate` | Collect sources → analyze → score ideas. Serialize ideas to `prautoblogger_article_queue` option. Schedule Tick 2. |
| 2..N | `prautoblogger_gen_tick` | Pop ONE idea from queue. Call `Article_Worker::generate()`. Persist updated queue. Reschedule next Tick, or finalize. |
If the host kills any tick:
- The idea queue is persisted (option survived the crash).
- The next CRON run picks up naturally (no orphan).
- `Run_Reaper` reclaims truly stuck runs via its daily sweep.
### Entry Points
| Caller | Path |
|--------|------|
| Board "New Article" button | AJAX `prautoblogger_generate_now` → `Generation_Status_Poller::on_ajax_generate_now()` → schedules `prautoblogger_manual_generation` → `Executor::on_manual_generation()` → `kick_off()` |
| WP-CLI (async) | `wp prautoblogger generate` → `WP_CLI_Commands::generate_command()` → `kick_off()` |
| WP-CLI (sync / VPS) | `wp prautoblogger generate --sync` → `WP_CLI_Commands::run_sync()` → `set_sync_mode(true)` → `on_orchestrate_tick()` → tick loop `on_generate_tick()` (×N) → `set_sync_mode(false)` |
| Old cron hook (retained) | `prautoblogger_manual_generation` → `Executor::on_manual_generation()` → `kick_off()` |
### SSH Workaround Retirement
The former `wp eval 'do_action("prautoblogger_manual_generation")'` workaround
fired `on_manual_generation()` synchronously in the CLI PHP process, bypassing:
- Nonce/capability checks (running as CLI user, not WP user)
- Cron-based loopback spawning
- Per-process time limits
This path is retired. `Executor::on_manual_generation()` now delegates immediately
to `Generation_Checkpoint_Runner::on_orchestrate_tick()` (the Tick 1 handler),
which acquires the lock and runs orchestration within its own process. The
`wp prautoblogger generate` WP-CLI command provides the supported CLI path.
### Key State
| Store | Key | Contents |
|-------|-----|---------|
| DB option | `prautoblogger_article_queue` | `{run_id, ideas[], results{}}` — persisted across ticks |
| DB option | `prautoblogger_checkpoint_run_id` | Current run UUID (for finalize/cleanup paths) |
| Transient | `prautoblogger_generation_status` | Status broadcast (polling by board/dossier JS) |
| DB | `prautoblogger_runs` | Per-run ledger with status, cost ceiling |
### Budget / Halt Contract
`Cost_Tracker::is_budget_exceeded()` is checked at the START of Tick 1 and each
generate tick. A halted run (`Run_State::get_status() = 'halted'`) causes the
generate tick to abort without calling `Article_Worker`, clean up the queue, and
release the lock — same guarantee as `Pipeline_Runner`'s queued-article path.

View File

@@ -5,106 +5,390 @@ All notable changes to PRAutoBlogger will be documented in this file.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project uses [Semantic Versioning](https://semver.org/).
## [0.18.0] - 2026-06-12
## [Unreleased - Internal]
Pipeline v2 — Phase 1 substrate (plan of record: CPO thread
`2026-06-prautoblogger-pipeline-redesign`, seq 3). Built on the current pipeline with **no
behavior change** to the Economy (single-pass) and multi-step publish paths.
### Internal
- ADR log scaffolded: `docs/adr/` created as the app-local decision log for this plugin; `docs/adr/README.md` is the index; ADR-0001 documents the v0.16.0 editorial illustration image-style pivot. `ARCHITECTURE.md` updated to point to the folder. No runtime changes; docs-only.
- CI constants guard (`scripts/check-constants.php`): new required CI step (`Check PRAUTOBLOGGER_* constants`) fails the build when any `PRAUTOBLOGGER_*` constant referenced in shipped plugin PHP is neither defined in `prautoblogger.php` nor `defined()`-guarded at the reference site. Prevents a recurrence of the v0.19.0 admin-500 regression class.
## [0.22.1] - 2026-06-22
### Added
- **Schema v1.2.0** (`prautoblogger_db_version` 1.1.0 → 1.2.0, idempotent dbDelta
migrations, self-healing on version mismatch): new tables
`wp_prautoblogger_prompts` (versioned prompt registry),
`wp_prautoblogger_run_sources` + `wp_prautoblogger_run_decisions` (audit child tables),
`wp_prautoblogger_runs` (per-run cost ledger + lifecycle) and
`wp_prautoblogger_run_stages` (per-run per-stage state machine, idempotency key
`run_id + stage + agent_role + item_key`); additive nullable columns `agent_role` and
`prompt_version` on `wp_prautoblogger_generation_log` (historical rows stay valid).
- `PRAutoBlogger_Stage_Display_Map`: single PHP vocabulary for historical stages
(`analysis`, `outline`, `draft`, `polish`, `review`, `llm_research`, `image_a`, `image_b`,
`image_prompt_rewrite`, `opik_eval_judge`), Pipeline v2 stages (`research`, `curate`,
`draft`, `editorial`, `seo`, `publish`) and a humanizing fallback for unknown values, plus
per-stage default agent role and primary prompt-registry key.
- Uninstall now drops the five substrate tables and clears every plugin cron hook
(previously only two of eight were cleared).
- **Versioned prompt registry** (`PRAutoBlogger_Prompt_Registry` + `_Writer`): all pipeline
prompt copy — content system/single-pass/outline/draft/polish, analysis system/user,
editor system/review, research system, plus the **illustration rewriter prompt and the
image Style Template** (the image-composer PR seam) — is seeded as immutable version 1
from the v0.16.0 hardcoded texts (`prautoblogger_migrated_prompt_seed_v0180`). One active
version per key; changes create new versions, never mutate. Call sites render through the
registry with a **byte-identical in-code fallback** when the table is missing/empty
(self-healing, no fatals on half-migrated state). No admin UI in Phase 1.
- **Prompt-version pinning**: `Cost_Tracker::set_run_id()` pins the active version of every
key on the run's row; every generation_log row (text and image stages) is stamped with the
pinned `prompt_version` plus a stage-derived `agent_role`. Single-pass rows keep stage
`draft` and stamp `content.single_pass` explicitly.
- `PRAutoBlogger_Run_State` (runs-table ledger row: ceiling/reserved/settled/overage, pins,
sticky lifecycle states) and `PRAutoBlogger_Run_Context` (per-process run id, mirrors the
Opik trace-context pattern).
- **Per-run cost governor** (`PRAutoBlogger_Cost_Governor`, net-new enforcement — previously
only the monthly cap was enforced): every `send_chat_completion()` reserves its worst-case
estimate (prompt chars/4 + max_tokens, priced via the #18 chain) against the run ledger
with a single atomic conditional UPDATE (the #10 lock discipline) BEFORE dispatch; the
image `curl_multi` batch reserves its summed estimate before dispatch; reservations settle
to actuals after each response (a failed call releases its hold). Breach → run `halted`
(sticky), overage recorded, `Cost_Ceiling_Exception` aborts the call, remaining queued
articles abort, and the run is surfaced in a "Halted runs" notice on the Review Queue.
New setting **Per-Run Cost Ceiling (USD)** (`prautoblogger_per_run_cost_ceiling_usd`,
default `PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD` = $0.50, snapshotted at run start,
0 = disabled). Calls outside a run, the monthly budget, and the Cloudflare AI Gateway
path behave exactly as before.
- **Run state machine + idempotent resume** (`PRAutoBlogger_Run_Stage_State`): per-run
per-stage rows keyed `run_id + stage + agent_role + item_key` (item_key scopes
article-level stages — one run_id spans all N articles of a batch; agent_role is the
Phase-2 quorum dimension, schema-only for now). Done stages snapshot their output and are
REUSED on re-entry — never re-run, never re-charged (writer stages via
`Content_Generator::set_run_item()`, the editorial review via its stored verdict, a fully
published item is skipped outright). **Post creation is keyed by `_prautoblogger_run_id`
+ new `_prautoblogger_idea_hash` meta (check-before-insert)** so a retried run cannot
create a duplicate post. Run-level research/analysis checkpoints ride Pipeline_Runner;
completion/failure marks land on the runs row (Executor catch paths, orphan aborts,
monthly-budget aborts).
- **Reaper extension** (`PRAutoBlogger_Run_Reaper`, same daily cron as the #19 research
reaper — no new schedule): stages stuck `running` > 2× expected stage wall-clock and runs
silent > 2× expected run wall-clock are marked `failed` (filterable expectations); their
open stages are reaped; such runs are queryable as **"incomplete"** via
`Run_Reaper::incomplete_runs()`. Also enforces **retention**: `request_json` on the
generation log and stage output snapshots are NULLed after R days — R from the new
**Audit Payload Retention** setting (`prautoblogger_request_json_retention_days`, default
`PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS` = 14, 0 = keep forever).
- **Audit writes** (`PRAutoBlogger_Audit_Writer`): every reviewed article records its
editorial verdict in `run_decisions`; every published article records the sources that
fed it in `run_sources` (the source_ids_json / `_prautoblogger_research_sources`
consolidation for new runs; no backfill).
### Changed
- `Content_Generator`'s four stage methods now share one `execute_stage()` helper (the Opik
span + dispatch + cost-log boilerplate was identical); file drops from 301 to ~220 lines.
No behavior change: same prompts, models, params, span names, stage values.
- `Cost_Tracker::get_avg_tokens_for_stages()` body moved to `Cost_Reporter` (read-only
reporting query); the Cost_Tracker method remains as a delegating wrapper.
- `OpenRouter_Provider`'s API-key fetch + format validation moved to
`OpenRouter_Request_Builder::resolve_api_key()` (300-line cap); identical messages/behavior.
- Pipeline_Runner's and Article_Worker's duplicated status-transient/summary helpers moved
verbatim to the new `PRAutoBlogger_Pipeline_Status` (Pipeline_Runner back under the
300-line cap at 271).
- **`wp prautoblogger generate --sync`** (VPS orchestrator mode): runs the full
checkpoint pipeline synchronously in the calling process — acquires lock, calls
`on_orchestrate_tick()` in-process, then loops `on_generate_tick()` until the
queue is empty or a terminal state is reached (max 20 ticks). Exits 0 on
success with a JSON summary line; exits 1 on error. Designed to be called from
the Coolify VPS over a held-open SSH session, where Hostinger's ~10-min
background-process kill does not apply.
### Fixed
- ARCHITECTURE.md documented the multi-step edit stage as `edit`; the code has always
written `polish`. Canonicalized to **`polish`** everywhere.
- Half-migrated-schema hardening: generation_log inserts retry without the new audit
columns when the columns are missing (a cron run right after deploy no longer loses cost
rows), and the db-version self-heal now also runs on cron requests (`init` +
`wp_doing_cron()`), not only on `admin_init`.
- OpenRouter pricing now resolves dated snapshot model IDs returned by the API
(e.g. response model `deepseek/deepseek-v4-flash-20260423` for a
`deepseek/deepseek-v4-flash` request) to the base model's price before the
conservative-estimate fallback applies. Such responses were previously booked
at the conservative $10/$30 per-M rate (~30x real cost on flash-tier models) —
phantom spend that ate the per-run cost ceiling and the monthly budget gate.
- **`$sync_mode` guard in `Generation_Checkpoint_Runner::on_generate_tick()`**:
when running under `--sync`, the internal `wp_schedule_single_event()` +
`fire_cron_now()` reschedule is suppressed so the VPS's external loop cannot
race with a competing background wp-cron tick. Existing async (cron/AJAX) paths
are unaffected.
- **Reaper timezone fix** in `Run_Reaper::sweep_stuck_stages()` and
`sweep_stuck_runs()`: `$cutoff` was computed with `gmdate()` (UTC) while
`updated_at` is stored via `current_time('mysql')` (server local time, UTC+8).
Changed to `wp_date()` so the cutoff respects the site timezone. On this host
the 8-hour delta could suppress reaping of a same-day-stalled run; the 44h-old
run `ee6e7d91` was already far outside the threshold and unaffected.
- **Scoped self-heal migration** in `DB_Migrations`: restores the low-stakes
maintenance cron events (`prautoblogger_reap_orphan_research_rows`,
`prautoblogger_collect_metrics`, `prautoblogger_sync_runware_models`) that
vanished after the 06-15 deploy cycle. **`prautoblogger_daily_generation` is
deliberately NOT re-registered** — the VPS systemd timer owns generation
scheduling as of v0.22.1. Note: wp-cron reliability on this Hostinger host is
suspect (background PHP killed ~10 min); maintenance crons are low-stakes and
acceptable on this transport; generation is not.
- **Sync-mode guard tests** (QA NF-1 remediation 2026-06-23): moved sync-mode
tests to `GenerationCheckpointRunnerSyncModeTest.php`; fixed
`test_orchestrate_tick_in_sync_mode_does_not_schedule_background_event` to
return >= 1 idea from `orchestrate_only()` so execution reaches the sync guard
at line ~181 (was vacuous -- previous stub returned `[]` causing early return);
added `test_generate_tick_sync_mode_suppresses_reschedule` for the
`on_generate_tick()` guard; async regression test preserved in same file.
- **Root `CONTEXT.md`**: created domain glossary (run/run_stages/checkpoint/
idea-queue/Generation_Lock) and execution mode comparison (async wp-cron vs.
`--sync` VPS).
- **`ARCHITECTURE.md` SS25**: added `WP-CLI (sync / VPS)` row to Entry Points table.
## [0.22.0] - 2026-06-16
### Changed
- **CI adopted estate standard (reusable workflow)**: `ci.yml` is now a thin
caller of `peptiderepo/peptide-e2e/.github/workflows/ci.yml@main` with
`tests: brain-monkey`. Removes duplicate per-repo lint, PHPCS, and PHPUnit
jobs (now provided by the reusable workflow). The phpcbf auto-fix commit-back
pattern (prautoblogger gold standard) is now the reusable implementation.
- **Deploy gate upgraded**: `deploy.yml` no longer runs a standalone PHP-lint
validate job; it calls `./.github/workflows/ci.yml` (which includes the full
reusable suite) via `workflow_call`. This closes the gap where deploy's lint
gate was weaker than CI's PHPCS gate.
- **Constants guard retained**: `check-constants` job kept as a second parallel
job in `ci.yml` alongside the reusable `ci` job. No regression from the
v0.19.0 protection.
- **`composer.json` test script added**: `composer test` now runs
`vendor/bin/phpunit --testdox` (required by the reusable workflow's phpunit job).
### Refactored (300-line rule compliance)
- `PRAutoBlogger_Keyword_Extractor` (new, `core/class-keyword-extractor.php`):
shared keyword tokeniser extracted from `Idea_Scorer` and `Semantic_Dedup`;
both now delegate to `PRAutoBlogger_Keyword_Extractor::extract()`.
- `PRAutoBlogger_Image_Aspect_Ratio` (new, `providers/class-image-aspect-ratio.php`):
aspect-ratio snap utility extracted from `OpenRouter_Image_Provider` and
`OpenRouter_Image_Batch`; both now call `PRAutoBlogger_Image_Aspect_Ratio::snap()`.
- `PRAutoBlogger_Settings_Sanitizer` (new, `admin/class-settings-sanitizer.php`):
all option sanitization logic extracted from `Admin_Page::sanitize_field()`;
`Admin_Page` delegates via `PRAutoBlogger_Settings_Sanitizer::sanitize_field()`.
- `PRAutoBlogger_Settings_Fields_Images` (new, `admin/class-settings-fields-images.php`):
image-section field definitions extracted from `Settings_Fields_Extended`;
`Settings_Fields_Extended::get_fields()` merges via array union.
- `PRAutoBlogger_Runware_Validator` (new, `providers/class-runware-image-validator.php`):
`validate_credentials_detailed()` extracted from `Runware_Image_Provider`;
provider delegates via `PRAutoBlogger_Runware_Validator::validate()`.
- `class-runware-image-batch.php`: 15 individual `phpcs:disable` lines collapsed
to a single grouped suppression (equivalent effect, saves 14 lines).
- `class-open-router-image-provider.php`: `snap_aspect_ratio` private method and
`STANDARD_ASPECTS` constant removed (now handled by `Image_Aspect_Ratio`).
## [0.21.0] - 2026-06-14
### Added
- **Board “New Article” button (Phase 2 admin M4)**. Manual generation is now
accessible from the kanban board (not just the Settings page). The button fires
the existing `prautoblogger_generate_now` AJAX action; the board status area
shows generation progress via the existing transient poller. Requires
`manage_options` capability.
- **Chained-cron checkpoint machine** (`PRAutoBlogger_Generation_Checkpoint_Runner`).
Replaces the monolithic `on_manual_generation()` synchronous path with a
two-tick checkpoint machine so Hostinger’s PHP-process kill cannot abort a
mid-pipeline run:
- Tick 1 (`prautoblogger_gen_orchestrate`): collect → analyze → score ideas,
persist to `prautoblogger_article_queue` option, schedule Tick 2.
- Tick 2…N (`prautoblogger_gen_tick`): pop ONE idea, generate one article,
reschedule or finalize. Persists consumed queue BEFORE generating to prevent
orphan-recovery duplication on restart.
- Budget / halt: `Cost_Tracker::is_budget_exceeded()` checked per tick;
a halted run aborts all remaining ticks without calling `Article_Worker`.
- **WP-CLI `wp prautoblogger generate` command**. Routes through the same
chained-cron checkpoints as the board button. Replaces the retired SSH-only
`wp eval 'do_action("prautoblogger_manual_generation")'` workaround.
- **Settings-backed board pagination** (perf pass). All hardcoded LIMIT / `posts_per_page`
values in board queries replaced with two new settings:
- `prautoblogger_board_column_limit` (default 20, constant `PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT`)
- `prautoblogger_ideas_per_page` (default 30, constant `PRAUTOBLOGGER_DEFAULT_IDEAS_PER_PAGE`)
- **DB indexes** (via `PRAUTOBLOGGER_DB_VERSION` → `1.4.0`):
- `generation_log`: `KEY run_id_post_id`, `KEY created_status` (board query + failed-cards).
- `analysis_results`: `KEY analyzed_type` (ideas-browser pagination + type filter).
- `runs`: `KEY started_at` (articles-list pagination `ORDER BY started_at DESC LIMIT n OFFSET m`).
- **Query helper split**: `PRAutoBlogger_Ideas_Browser_Query` (new `class-ideas-browser-query.php`)
carries static query helpers extracted from `class-ideas-browser.php` to enforce the
300-line rule (CONVENTIONS §1).
- **Guard P2s** (`scripts/check-constants.php`):
- Added `--self-test` mode: synthesizes a missing constant and asserts the scanner catches it.
- Added inline comment documenting `CONSTANTS_GUARD_WINDOW = 4`.
- Wired `--self-test` as a CI step after the main constants check.
- **PHPUnit test** `tests/unit/Core/GenerationCheckpointRunnerTest.php`: tick-by-tick
state-machine proof (kick_off schedules only, empty queue finalizes, halted run aborts,
multi-idea queue pops one and reschedules).
- **Pipeline_Status** now exports `write_initial()` and `write_error()` for the
checkpoint runner to set the status transient before the first cron tick fires.
### Changed
- `Executor::on_manual_generation()` now delegates to
`Generation_Checkpoint_Runner::on_orchestrate_tick()` instead of running the full
pipeline synchronously. The method body is 1 line; the synchronous path is retired.
- `Pipeline_Runner::orchestrate_only()` added as a public wrapper exposing the private
`orchestrate()` method for use by `Generation_Checkpoint_Runner`.
### Removed
- **SSH-only workaround retired**: `wp eval 'do_action("prautoblogger_manual_generation")'` no
longer fires the pipeline synchronously in one PHP process. Use `wp prautoblogger generate`
instead (routes through the chained-cron checkpoint machine). See ARCHITECTURE.md §25.
## [0.20.1] - 2026-06-12
### Fixed
- **Restore three bootstrap constants deleted by the v0.19.0 constants-block regression**
(`PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD` 0.50, `PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS` 14,
`PRAUTOBLOGGER_DEFAULT_REASONING_MAX_TOKENS` 2048 — verbatim v0.18.3 values). PR #156 (v0.19.0)
dropped them from `prautoblogger.php`; the gap was latent because `tests/bootstrap.php` defines
its own fallbacks (CI green) and no exercised v0.19.x runtime path read them (prod reasoning
disabled). v0.20.0's activator defaults + run-state ceiling read them on the admin
migration path -> fatal 500 on all plugin admin pages immediately after the v0.20.0 deploy
(2026-06-12; front-of-site unaffected; hot-patched on prod within minutes, this commit makes
it canonical). Follow-up owed: CI guard asserting every referenced PRAUTOBLOGGER_* constant
is production-defined or defined()-guarded (maintenance thread).
## [0.20.0] - 2026-06-12
### Added
- **Edit + single-step re-run (Phase 2 admin M3)** under the five CPO guardrails
(cpo seq 8, hard AC). From the Article Dossier, eligible stages can have their
input edited and re-run as QUEUED background jobs (chained-cron — the UI never
implies synchronous execution; board + dossier polling shows queued → pickup →
result).
- **Immutable input versions (guardrail 1).** Edits fork into the new INSERT-only
`prautoblogger_stage_inputs` table (monotonic versions per stage scope); the
executed original in `generation_log.request_json` is never overwritten. The
editor locks message structure (count/roles) so every fork stays replayable.
- **Visible human-modified audit (guardrail 2).** `run_stages.human_modified` +
`run_decisions.human_modified` set when an edited fork enters execution (sticky,
never cleared); surfaced as dossier header + stage chips, a board-card chip, and
decisions recorded while rebuilding a human-modified item are flagged too.
- **Explicit downstream invalidation (guardrail 3).** Re-running a stage marks all
downstream chain stages `stale` (new column — rows STAY 'done', so the resume
machinery can never silently auto-re-run them). Stale chips render per stage, in
the dossier header, and as a Review Queue warning chip linking to the dossier
(stale state visible at publish). Downstream re-runs are deliberate: per-stage
"Re-run from here" rebuilds the target + downstream from current upstream
snapshots via the existing worker resume path.
- **Cost governor applies to re-runs (guardrail 4).** Replays go through the normal
provider seam: reserve-before-call against the SAME run ledger row.
`Run_State::reopen()` re-snapshots the ceiling from the current setting and never
resets accumulated spend. The dossier shows a Run Spend card + per-panel spend
strip and warns when committed spend reaches the ceiling fraction (filterable,
`prautoblogger_rerun_ceiling_warn_fraction`, default 0.8).
- **Published posts are frozen (guardrail 5).** publish/future/private lock the run
server-side (not just hidden UI); `Publisher` refreshes re-run output onto
UNPUBLISHED posts only, preserving their status — a re-run never publishes.
- **B1 — `request_json` persisted on every chat LLM call** (QA M2 F1). The provider
records the outgoing request BODY pre-dispatch (process-scoped recorder, consume-once);
`log_api_call()` writes it to the row. Authorization headers can never be recorded
(built separately; body keys whitelisted — covered by tests). Retention rides the
existing `prautoblogger_request_json_retention_days` prune; the dossier raw-trace
"Request payload" section now renders for new runs.
- **Idea seeds.** `Article_Worker` persists the exact `Article_Idea` payload once per
item (stage_inputs, source='seed') so re-run-from-here reconstructs the precise idea
(re-deriving from post fields could break the item-key hash and duplicate a post).
- **Dossier sidebar consolidation (QA M2 F2).** New "Models & Prompts" card: per-stage
model + prompt version + agent role at a glance, plus the "Run Spend" card.
- **Image sections wired to real data (QA M2 F3).** image_a/image_b (and every
log-only stage: llm_research, image_prompt_rewrite, …) now render from their actual
generation_log rows, with the post's pipeline attachments (featured/og/square via
`_prautoblogger_image_role`) shown as an image grid — chosen/featured highlighted.
- **Schema (db 1.3.0).** `run_stages` + `human_modified`/`stale`; `run_decisions` +
`human_modified`; new `stage_inputs` table. dbDelta-idempotent via the standard
self-healing migration path (admin_init + cron init); half-migrated sites degrade
gracefully (done() retries the legacy statement; M3 features no-op until migrated).
Uninstall drops the new table and clears the two new cron hooks.
### Changed
- **`PRAutoBlogger_Run_Stage_State` split** (300-line cap, M2 pattern): pipeline write
bodies moved verbatim to `Run_Stage_Writes` (public API unchanged via thin proxies);
operator-action mutations (restart / mark_stale / demote_to_pending) isolated in
`Run_Stage_Rerun_State` — the ONLY code allowed to demote a done row. `done()` now
clears `stale` on fresh output.
- **Dossier is item-scoped** (M2 correctness fix): stage rows filter to THIS post's
item (idea-hash meta), and gen_log rows linked to OTHER posts of a multi-article run
are excluded (unlinked run-level rows are kept).
- **`Publisher` idempotency short-circuit** now refreshes existing UNPUBLISHED posts
with re-run content (title/content/verdict meta; status untouched) instead of
silently discarding regenerated output. Published posts remain untouched.
- Review Queue rows show a stale-stages warning chip linking to the dossier; board
cards show a "Human-modified" chip (single batched query, no N+1).
### Fixed
- Dossier "Request payload" raw trace was always empty (request_json never populated —
QA M2 F1); image sections always rendered the absent state (QA M2 F3).
## [0.19.4] - 2026-06-12
### Added
- **GD-rung unit tests.** New `ImageComposerEditorTest.php` (7 tests) covering
`PRAutoBlogger_Image_Composer_Editor`: `wp_get_image_editor` WP_Error, resize WP_Error,
upscale-guard size mismatch, save WP_Error, zero-width target geometry guard, missing-role
guard, and successful OG resize happy path. Orchestrator ladder test
`test_gd_rung_emits_passthrough_featured_and_single_warning` added to `ImageComposerTest.php`,
asserting the Imagick renderer is never called on the `gd` rung and pass-through featured is
always first.
(Source: QA verdict `Peptide Repo CTO/qa-reviews/prautoblogger/2026-06-11-6f76df1.md` P2-1.)
### Fixed
- **Dead layout keys removed.** `caption_margin_right` (og) and `caption_side_padding` (square)
were defined in `Layout::defaults()` and exposed via the `prautoblogger_image_compose_layout`
filter but never consumed by the Imagick renderer. The og renderer positions text from
`logo_x + logo_w + caption_gap` (left-anchor); the square renderer uses centered `x` alignment.
Right-edge overflow is enforced via `caption_chars_per_line` on both variants. Keys removed;
filter users tuning these values had no effect and received no error.
(Source: QA verdict `Peptide Repo CTO/qa-reviews/prautoblogger/2026-06-11-6f76df1.md` P3-3.)
- **Amortized research row now carries audit fields.** `Post_Assembler::amortize_research_costs()`
previously inserted per-article `llm_research` copies without `prompt_version` or `agent_role`,
leaving those columns NULL/empty while the original (correctly stamped) row was deleted.
SELECT now includes both columns; INSERT carries them forward from the original row.
(Source: phase-1 thread seq 49, seq 50 carried-list: "amortized llm_research rows pv=null/role
empty" — `convo/prautoblogger/threads/2026-06-pipeline-v2-phase1/`.)
- **`fetch_page()` error body included in exception.** Non-200 responses from the Runware
modelSearch API now include up to 200 chars of the response body in the `RuntimeException`
message, making diagnosis from logs actionable without re-reproducing the request.
(Source: phase-1 thread seq 50 carried-list: "`fetch_page()` error-body logging".)
### Notes
- cto-review.yml isinstance guard (handoff item 5): the fix (`c.get("severity", "UNKNOWN")`)
is already applied at HEAD (line 454 of the workflow). No changes made.
## [0.19.3] - 2026-06-12
### Fixed
- **Dossier page 403 (P0 hotfix).** `PRAutoBlogger_Dossier_Page::on_register_menu` registered the
dossier as a hidden submenu under `prautoblogger-settings` then called `unset($submenu[...])` to
hide it from the nav. This is the **hide-by-unset anti-pattern**: at registration time WP records
the render callback under hookname `prautoblogger_page_prautoblogger-dossier`; at request time
`get_admin_page_parent()` scans `$submenu`, finds the slug absent (it was unset), returns false,
and WP recomputes the hookname under the orphan `admin_page_*` namespace -- no handler registered
there -- `wp_die(403)`. Same hookname-mismatch class as the board 404 (v0.19.1), different vector.
**Fix:** register under parent `options.php` (canonical hidden-page pattern). Hookname is
`admin_page_prautoblogger-dossier` at both registration and request time. No `$submenu` mutation
required or permitted. Asset enqueue gate updated to match (`admin_page_*` prefix).
Deep-link URLs (`admin.php?page=prautoblogger-dossier&post_id=N`) are parent-agnostic and
unchanged. Board cards and the post metabox link continue to work without modification.
### Changed
- **Regression test upgraded to request-time faithful** (`DossierMenuRegistrationTest`).
New `test_request_time_hookname_matches_registration_hookname` simulates
`get_admin_page_parent()` against the post-registration `$submenu` state and asserts it
equals the registration-time hookname. FAILS on hide-by-unset (4 failures on v0.19.2 code,
captured in test evidence). PASSES on options.php-parent. New
`test_hide_by_unset_would_fail_request_time_check` documents the broken pattern inline.
- **CONVENTIONS.md §Hidden Admin Pages** added: options.php-parent is the only permitted
pattern for hidden admin pages; `$submenu` mutation after registration is explicitly
prohibited; full incident history (board 404 + dossier 403) and test methodology documented.
- **ARCHITECTURE.md §22b** updated with hidden-page convention cross-reference.
**§23** updated to reflect options.php-parent registration and the v0.19.2→v0.19.3 fix.
- **tests/bootstrap.php**: added `PRAUTOBLOGGER_PLUGIN_URL` and `PRAUTOBLOGGER_VERSION`
constants so admin enqueue tests resolve without "Undefined constant" errors.
## [0.19.2] - 2026-06-12
### Added
- **Article Dossier page (M2).** Per-article read-only inspection page at
`admin.php?page=prautoblogger-dossier&post_id=<id>`. Accessible via board card
deep-links and the slim post metabox link. Shows: header (title, verdict pill, tier,
status), sidebar (run metadata, per-stage cost receipt with model + prompt-version),
vertical stage sections (rendered output default, per-stage raw-trace toggle exposing
`generation_log` request JSON + tokens + cost + role), editorial decisions rationale,
image A/B pair sections.
- **Board card deep-links.** All Kanban cards now deep-link directly to the article
dossier page (`dossier_url` on card data; `click_action = dossier` in board JS).
- **Slim post metabox.** `PRAutoBlogger_Post_Metabox` now renders a single "View
generation dossier →" link (was a verbose generation-metadata dump).
- **Dossier CSS/JS.** `assets/css/dossier.css` (warm editorial palette, verdict pills,
cost receipt, raw-trace toggle) + `assets/js/dossier.js` (vanilla JS toggle,
`aria-expanded` / `aria-controls` accessible).
- **Menu registration test coverage.** `DossierMenuRegistrationTest.php` — asserts
dossier hook fires at priority > board (> parent), uses hidden-submenu pattern,
correct slug, and `url_for_post()` URL shape. Mirrors `BoardMenuRegistrationTest`.
- **Raw-trace credential check (binding 4).** `request_json` column contains only
OpenRouter request bodies (model, messages, temperature — no Authorization headers
per architecture). All model output escaped via `esc_html()` / `wp_kses_post()`;
treated as untrusted HTML throughout the render path.
- **Legacy graceful state (binding 5).** Posts with no run record (pre-v0.18.0) render
a clean "No generation record" notice — no notices, no fatals. Amortized research
rows (pv=null, role='') render model as "—" with no raw-trace toggle.
### Changed
- **`class-prautoblogger.php` → `class-db-migrations.php` split (binding 1).**
All DB-migration methods extracted byte-identically into new static class
`PRAutoBlogger_DB_Migrations` (`includes/class-db-migrations.php`, 129 lines).
`class-prautoblogger.php` shrinks from 407 → 258 lines; delegates via thin proxy.
Loaded via explicit `require_once` in `prautoblogger.php` (autoloader filename
collision avoidance — see ARCHITECTURE.md §File Naming Notes).
## [0.19.1] - 2026-06-12
### Fixed
- **Board admin submenu 404 (P0 hotfix).** `PRAutoBlogger_Board_Page::on_register_menu`
was hooked at `admin_menu` priority 10, identical to
`PRAutoBlogger_Admin_Page::on_register_menu`. Because the board hook was
registered first in `register_admin_hooks()`, `add_submenu_page()` fired while
`$admin_page_hooks['prautoblogger-settings']` was still unset. WordPress fell back
to the `admin_page_prautoblogger-board` hookname; at request time it recomputed
`prautoblogger_page_prautoblogger-board`, found no registered callback, and called
`wp_die()` with "Invalid plugin page". Board CSS/JS also never loaded (same
enqueue-gate mismatch). Fix: parent menu hook stays at priority 10; board submenu
hook moves to priority 11, guaranteeing `add_menu_page()` fires first. Board is
kept as the first submenu item via explicit `$submenu` reorder in
`PRAutoBlogger_Board_Page::on_register_menu()`.
- **Regression test added:** `tests/unit/Admin/BoardMenuRegistrationTest.php` —
three assertions covering priority relationship, execution order, and parent-slug
correctness. Includes a "documents the bug" test that fires the unfixed hook order
to prove the broken state.
## [0.19.0] - 2026-06-12
### Added
- **Kanban board dashboard (M1 — Phase 2 admin / Direction C).** New
`Board` submenu page is now the primary landing screen for the plugin.
Four columns — Generating | In Review | Published | Failed — with card
click-throughs: Generating/Failed → Activity Log; In Review → Review
Queue; Published → Post edit screen. (M2 will rewire all to the Article
Dossier.)
- **Live board polling.** AJAX action `prautoblogger_board_status` (nonce
`prautoblogger_board`) returns the full board snapshot every N seconds.
Poll interval backs off to 2× when no article is actively generating
and resets to base as soon as a generating card appears. All nonce and
capability checks enforced.
- **Two new settings** in Schedule & Budget: `prautoblogger_board_poll_interval`
(default 5s, min 3s) and `prautoblogger_board_published_window_days`
(default 7 days). Both localized into board.js — no hardcoded values.
- New files: `includes/admin/class-board-page.php`,
`includes/admin/class-board-data-provider.php`,
`templates/admin/board-page.php`, `assets/css/board.css`,
`assets/js/board.js`.
- PHPUnit behavioral tests: `tests/unit/Admin/BoardDataProviderTest.php`
(8 tests covering all column/state mappings) and
`tests/unit/Core/CostReporterSqlBugFixTest.php` (3 tests for the SQL
inline-comment fix).
### Fixed
- **Cost-reporter SQL inline-comment bug (P2).** `PRAutoBlogger_Cost_Reporter::get_daily_spend()`
and `get_spend_by_stage()` embedded PHP `// phpcs:ignore` annotations
inside SQL string literals, passing the comment text to MySQL. Moved
phpcs suppression annotations to standalone PHP line comments above the
`$wpdb->prepare()` call — matching the pattern already used in
`get_monthly_spend()`. No query behavior change; fixes malformed
prepared-statement strings visible in debug logging.
## [0.17.0] - 2026-06-11
@@ -237,7 +521,6 @@ behavior change** to the Economy (single-pass) and multi-step publish paths.
the catalog + OpenRouter static list. Breaking change: callers that expect a
specific static list should note the new dynamic Runware side.
### Fixed
- Runware image generation now correctly uses the admin-selected model instead
of silently falling back to FLUX.1 schnell (runware:100@1) for all non-schnell
@@ -337,7 +620,6 @@ and this project uses [Semantic Versioning](https://semver.org/).
### Fixed
- Image prompt builder now properly delegates to static parser methods
### Fixed
- **Opik autoloader gap** — `class-autoloader.php` listed `services/` as a scan directory but not `services/opik/`, where all four Opik classes live (`Opik_Client`, `Opik_Trace_Context`, `Opik_Span_Queue`, `Opik_Dispatcher`). Composer's classmap (used in PHPUnit) covers `includes/` recursively so tests passed, but the WordPress runtime autoloader would fatal on first Opik class reference in production. Added `services/opik/` to the scan list.
@@ -1020,6 +1302,7 @@ swap with no user-visible change in article quality.
run_id-based log linking, RuntimeException on WP_Error, action/filter
hook firing. Replaces previous method-existence-only checks.
## [0.2.1] — 2026-04-12
### Changed
@@ -1056,14 +1339,6 @@ swap with no user-visible change in article quality.
- Posts-per-subreddit limit (5–100) in Sources tab.
- LiteSpeed cache purge step in CI/CD deploy pipeline.
### Removed
- `class-reddit-api-client.php` — replaced by `class-reddit-json-client.php`.
- Reddit OAuth credential fields and encrypted storage entry.
### Fixed
- Deploy pipeline now purges LiteSpeed/OPcache after file extraction,
preventing stale PHP bytecode from being served.
## [0.1.0] — 2026-04-10
### Added

77
CONTEXT.md Normal file
View File

@@ -0,0 +1,77 @@
# PRAutoBlogger — Domain Glossary
Quick reference for the generation pipeline domain model. For full design rationale
see `ARCHITECTURE.md` (§21–§25) and `CONVENTIONS.md`.
---
## Core Domain Terms
**run**
A single end-to-end article generation cycle: orchestrate → generate idea(s) →
finalize. Represented as a row in `wp_prautoblogger_runs` (status, cost ceiling,
timestamps). One run may produce multiple articles if the daily idea-count > 1.
**run\_stages** (`wp_prautoblogger_run_stages`)
Sub-steps within a run (research, analysis, generation, publish). Each stage has
its own status and can be replayed independently via `Rerun_Executor`. Generation
checkpoint runs populate stages as Article_Worker progresses.
**checkpoint**
The persistence boundary between pipeline ticks. After each tick the idea queue
and run state are written to `wp_options` so a process kill cannot lose work. The
next tick reads the queue option and continues. See §25 of ARCHITECTURE.md.
**idea queue** (`prautoblogger_article_queue` option)
Serialized array of `Article_Idea` objects written by `on_orchestrate_tick()` and
consumed one-at-a-time by successive `on_generate_tick()` calls. Deleted on
finalize or cleanup.
**Generation\_Lock** (`wp_prautoblogger_generation_lock` or `wp_options`)
DB mutex that prevents two concurrent runs. Acquired at the start of
`on_orchestrate_tick()`, released by `Helpers::finalize()` or on error/halt.
---
## Execution Modes
### Async (wp-cron driven)
Default path for the Board "New Article" button and the plain
`wp prautoblogger generate` WP-CLI command.
1. `kick_off()` schedules `prautoblogger_gen_orchestrate` and calls `fire_cron_now()`.
2. `on_orchestrate_tick()` runs in a background PHP process, schedules
`prautoblogger_gen_tick`, and calls `fire_cron_now()` to spawn the next process.
3. Each `on_generate_tick()` spawns the next until the queue drains.
Limitation: Hostinger kills background PHP processes at ~10 min. Orchestrate tick
(LLM-free) and individual generate ticks (one LLM call each) are both well within
this limit; the checkpoint design is the mitigation.
### Sync (VPS-driven / `--sync`)
Used by the Coolify KVM8 VPS systemd timer via `infra/vps/orchestrate-generation.sh`
in `peptide-e2e`. Introduced v0.22.1.
1. `run_sync()` sets `Generation_Checkpoint_Runner::$sync_mode = true`.
2. Calls `on_orchestrate_tick()` **in-process** (no background spawn).
3. Loops calling `on_generate_tick()` directly until queue empty or terminal state.
4. `$sync_mode = true` suppresses all `wp_schedule_single_event` +
`fire_cron_now` calls inside both tick methods, preventing a competing
background cron chain from forming in parallel.
The VPS SSH session is held open; the process is not subject to Hostinger's
background-kill constraint.
---
## Table Shorthand
Throughout the codebase `wp_prautoblogger_*` tables are referenced by their
`$wpdb->` property names (e.g. `$wpdb->prab_runs`, `$wpdb->prab_run_stages`).
Full table names are defined in `class-activator.php`.
---
*See also: `ARCHITECTURE.md` §25 (checkpoint design), `CONVENTIONS.md` (coding rules).*

View File

@@ -502,3 +502,73 @@ public function my_operation( $input ) {
```
That's it. No config, no feature-flag checks, no try/catch needed.
---
## Hidden Admin Pages (v0.19.3 convention, codified after two incidents)
### Rule: use options.php as parent; NEVER unset/remove registered pages from $submenu
When a WordPress admin page must be accessible by URL but hidden from the nav menu, use the
**options.php-parent pattern** exclusively:
```php
add_submenu_page(
'options.php', // parent: built-in WP page; pages hidden by construction
__( 'My Page', 'prautoblogger' ), // page title
__( 'My Page', 'prautoblogger' ), // menu title (never shown; options.php is hidden)
'manage_options', // capability
'my-page-slug', // page slug
array( $this, 'render_page' ), // callback
);
// No $submenu manipulation. options.php-parent pages are hidden from nav by construction.
```
The resulting hookname is `admin_page_my-page-slug` at BOTH registration time and request time.
### Why the hide-by-unset pattern causes 403 (the incident class)
WordPress computes the page hookname at two distinct moments:
**Registration time** (inside `add_submenu_page`):
WP resolves the parent slug against `$admin_page_hooks`. If found, hookname = `{parent_key}_page_{slug}`. WP registers the render callback and `$_registered_pages` entry under that name.
**Request time** (inside `wp-admin/admin.php`):
WP calls `get_admin_page_parent()` which scans `$submenu` for the page slug. If found, it derives the hookname the same way as (1). If **not found** (e.g., because the entry was unset), it falls back to `admin_page_{slug}`.
When these two hooknames diverge, WP dispatches the request to a hookname with no registered callback → `wp_die(403)`.
**Why options.php avoids this:** `options.php` is a built-in page that WordPress always keeps intact in `$admin_page_hooks` and `$submenu`. For its children, `get_admin_page_parent()` returns `options.php` consistently at both moments, so both produce `admin_page_{slug}`. No mutation required.
### Incident history
| Version | Symptom | Root cause | Fix |
|---------|---------|------------|-----|
| v0.19.1 | Board page 404 | Board submenu registered at same priority as parent (both 10) — add_submenu_page fired before add_menu_page → wrong hookname at registration time | Board moved to priority 11, after parent at 10 |
| v0.19.3 | Dossier page 403 | Dossier registered under `prautoblogger-settings` parent (priority 12, after parent ✓) but post-registration `unset($submenu[...])` removed the slug → get_admin_page_parent() returned false at request time → orphan `admin_page_*` hookname → no handler → 403 | Switched to `options.php` parent; removed all $submenu mutation |
Both incidents are in the same **hookname-mismatch class**: registration-time hookname ≠ request-time hookname.
### Test pattern
The `DossierMenuRegistrationTest::test_request_time_hookname_matches_registration_hookname` test
replicates the request-time resolution algorithm (scan `$submenu` for the slug; if found derive
hookname; if absent return `admin_page_*`) and asserts it equals the registration-time hookname.
This test FAILS on hide-by-unset implementations and passes on the options.php-parent pattern.
Apply the same test strategy to any new hidden admin page.
### Asset enqueue gate
For `options.php`-parent pages, the hook suffix passed to `admin_enqueue_scripts` is
`admin_page_{slug}`. For plugin-parent pages it would be `{parent_key}_page_{slug}`. Always
match the asset enqueue gate to the actual hookname for the chosen parent pattern:
```php
// options.php parent → 'admin_page_{slug}'
public function on_enqueue_assets( string $hook_suffix ): void {
if ( $hook_suffix !== 'admin_page_' . self::PAGE_SLUG ) {
return;
}
// ... enqueue
}
```

View File

@@ -0,0 +1,84 @@
# Information Architecture — PRAutoBlogger
Auto-generated index of all admin surfaces, data flows, and integrations.
Maintained per DoD v1.1.0 (same-PR update rule).
---
## Admin Pages
| Slug | Class | Template | Purpose |
|------|-------|----------|---------|
| `prautoblogger-settings` | `PRAutoBlogger_Admin_Page` | `admin-page.php` | Main settings (schedule, models, budget, board limits) |
| `prautoblogger-board` | `PRAutoBlogger_Board_Page` | `board-page.php` | Kanban board: Generating / Failed / In Review / Published columns. New Article button (v0.21.0). |
| `prautoblogger-articles` | `PRAutoBlogger_Articles_Page` | `articles-page.php` | Paginated list of all articles/runs |
| `prautoblogger-dossier` | `PRAutoBlogger_Dossier_Page` | `dossier-page.php` | Per-article generation log + stage edit/re-run (M3) |
| `prautoblogger-ideas` | `PRAutoBlogger_Ideas_Browser` | `ideas-browser.php` | Analysis results browser with per-idea generation |
| `prautoblogger-activity` | `PRAutoBlogger_Activity_Page` | `activity-page.php` | Cost + generation activity log |
---
## Generation Entry Points (v0.21.0)
```
Board "New Article" button
└─ AJAX prautoblogger_generate_now
└─ Generation_Status_Poller::on_ajax_generate_now()
└─ schedules prautoblogger_manual_generation cron
└─ Executor::on_manual_generation()
└─ Generation_Checkpoint_Runner::on_orchestrate_tick() ← Tick 1
└─ schedules prautoblogger_gen_tick
└─ Generation_Checkpoint_Runner::on_generate_tick() ← Tick 2..N
WP-CLI: wp prautoblogger generate
└─ WP_CLI_Commands::generate_command()
└─ Generation_Checkpoint_Runner::kick_off()
└─ schedules prautoblogger_gen_orchestrate
└─ Generation_Checkpoint_Runner::on_orchestrate_tick() ← Tick 1
Daily auto-generation cron
└─ prautoblogger_daily_generation
└─ Executor::on_cron_run()
└─ Pipeline_Runner::run() (legacy synchronous path — runs in a single PHP process)
```
---
## Database Tables
| Table | DB Version | Purpose |
|-------|-----------|---------|
| `prautoblogger_sources` | 1.0.0 | Collected Reddit/RSS source content |
| `prautoblogger_analysis_results` | 1.0.0 | Scored article ideas |
| `prautoblogger_generation_log` | 1.0.0 | Per-LLM-call log (cost, tokens, status) |
| `prautoblogger_runs` | 1.0.0 | Per-run ledger (status, ceiling_usd) |
| `prautoblogger_run_stages` | 1.2.0 | Per-stage state for M3 re-run |
| `prautoblogger_stage_inputs` | 1.3.0 | Immutable input versions for stage edits (M3) |
| `prautoblogger_run_decisions` | 1.2.0 | Editorial decisions per run |
DB version history: 1.0→1.1 (schema normalisation), 1.1→1.2 (M2 dossier), 1.2→1.3 (M3 stage inputs), 1.3→1.4 (M4 indexes).
---
## Key Options
| Option | Type | Default | Purpose |
|--------|------|---------|---------|
| `prautoblogger_board_column_limit` | int | 20 | Max cards per board column |
| `prautoblogger_ideas_per_page` | int | 30 | Ideas browser page size |
| `prautoblogger_article_queue` | array | — | Checkpoint queue (run_id + ideas + results) |
| `prautoblogger_checkpoint_run_id` | string | — | Current run UUID for Tick 2..N finalize path |
| `prautoblogger_db_version` | string | — | Installed schema version (triggers dbDelta on mismatch) |
---
## WP-CLI Commands
| Command | Since | Description |
|---------|-------|-------------|
| `wp prautoblogger generate` | 0.21.0 | Queue article generation via chained-cron checkpoints |
| `wp prautoblogger opik:eval` | 0.12.0 | Trigger Opik eval run |
---
*Last updated: v0.21.0 (2026-06-14)*

249
assets/css/board.css Normal file
View File

@@ -0,0 +1,249 @@
/**
* PRAutoBlogger — Kanban Board Styles
*
* Warm editorial style (Proposal C / Direction C).
* Inside wp-admin chrome; uses WP admin vars where available.
* No build step — plain CSS, no preprocessor.
*
* Palette:
* Teal #1B8A92 (brand primary — Generating pill, spinner)
* Cream #FAF6EE (card background)
* Ink #0D0D0D (primary text)
* Orange #FF8A3D (In Review pill)
* Lime #7FD600 (Published pill — success)
* Red #D94F4F (Failed pill)
* Mid-grey #8A8A8A (meta text, border)
*
* @see templates/admin/board-page.php — Rendered HTML structure.
* @see assets/js/board.js — DOM updates that match these selectors.
*/
/* ── Page wrapper ──────────────────────────────────────────────────────── */
.prab-board-wrap {
padding: 0;
}
.prab-board-heading {
font-size: 1.4rem;
font-weight: 700;
color: #0d0d0d;
margin: 20px 0 18px 0;
padding: 0 20px;
display: flex;
align-items: baseline;
gap: 10px;
}
.prab-board-heading-sub {
font-size: 1rem;
font-weight: 400;
color: #8a8a8a;
}
/* ── Four-column board grid ────────────────────────────────────────────── */
.prab-board {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
padding: 0 20px 24px;
align-items: start;
}
@media (max-width: 1200px) {
.prab-board {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 720px) {
.prab-board {
grid-template-columns: 1fr;
}
}
/* ── Column ────────────────────────────────────────────────────────────── */
.prab-board-column {
background: #f8f7f5;
border: 1px solid #e2ddd6;
border-radius: 8px;
min-height: 180px;
}
.prab-col-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px 10px;
border-bottom: 1px solid #e2ddd6;
}
.prab-col-title {
font-size: 0.82rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #4a4540;
}
.prab-col-cards {
padding: 10px 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.prab-col-empty {
font-size: 0.82rem;
color: #b0a898;
text-align: center;
padding: 20px 0;
margin: 0;
}
/* ── Status pills ──────────────────────────────────────────────────────── */
.prab-pill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 1px 8px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.5;
color: #fff;
min-width: 24px;
}
.prab-pill--generating {
background: #1b8a92;
}
.prab-pill--review {
background: #ff8a3d;
}
.prab-pill--published {
background: #5a9e28;
}
.prab-pill--failed {
background: #d94f4f;
}
/* ── Card ──────────────────────────────────────────────────────────────── */
.prab-board-card {
background: #faf6ee;
border: 1px solid #e0d8cc;
border-radius: 6px;
padding: 11px 12px 10px;
position: relative;
transition: box-shadow 0.15s ease, border-color 0.15s ease;
}
.prab-board-card:hover {
box-shadow: 0 2px 8px rgba(27, 138, 146, 0.12);
border-color: #b8cfd2;
}
.prab-board-card--generating {
border-left: 3px solid #1b8a92;
}
.prab-card-title {
font-size: 0.875rem;
font-weight: 500;
color: #0d0d0d;
line-height: 1.4;
margin-bottom: 6px;
padding-right: 20px; /* room for spinner */
}
.prab-card-meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 5px;
}
.prab-card-cost {
font-size: 0.75rem;
color: #5a8a50;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.prab-card-stage {
font-size: 0.74rem;
color: #1b8a92;
font-weight: 500;
margin-bottom: 5px;
}
.prab-card-error {
font-size: 0.74rem;
color: #d94f4f;
margin-bottom: 5px;
font-style: italic;
}
.prab-card-actions {
margin-top: 8px;
}
.prab-card-link {
display: inline-block;
font-size: 0.75rem;
color: #1b8a92;
text-decoration: none;
font-weight: 500;
border: 1px solid #b8d8db;
border-radius: 4px;
padding: 2px 8px;
transition: background 0.1s ease;
}
.prab-card-link:hover {
background: #e8f4f5;
color: #136870;
border-color: #8ac4c9;
}
/* ── Generating spinner ────────────────────────────────────────────────── */
.prab-card-spinner {
position: absolute;
top: 11px;
right: 11px;
width: 14px;
height: 14px;
border: 2px solid #c8e6e8;
border-top-color: #1b8a92;
border-radius: 50%;
animation: prab-spin 0.9s linear infinite;
}
@keyframes prab-spin {
to { transform: rotate(360deg); }
}
/* ── Board footer ──────────────────────────────────────────────────────── */
.prab-board-footer {
padding: 0 20px 16px;
font-size: 0.78rem;
color: #a0998f;
}
/* ── Poll-error banner ─────────────────────────────────────────────────── */
.prab-board-error {
background: #fff4f4;
border: 1px solid #f5c4c4;
color: #8b2020;
border-radius: 6px;
padding: 8px 14px;
margin: 0 20px 12px;
font-size: 0.82rem;
display: none;
}
.prab-board-error.is-visible {
display: block;
}

240
assets/css/dossier-edit.css Normal file
View File

@@ -0,0 +1,240 @@
/**
* PRAutoBlogger dossier — edit + re-run styles (M3, v0.20.0).
* Extends dossier.css (kept separate: dossier.css predates the
* 300-line discipline and is not grown further).
*/
/* ── Audit chips (stale / human-edited / attempt) ─────────────────── */
.prab-chip {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
line-height: 1.6;
vertical-align: middle;
}
.prab-chip--stale {
background: #fcf0e4;
color: #8a4a0b;
border: 1px solid #e8c9a8;
}
.prab-chip--human {
background: #e9f2f4;
color: #1b5e6b;
border: 1px solid #b8d8de;
}
.prab-chip--attempt {
background: #f0f0f1;
color: #50575e;
border: 1px solid #dcdcde;
}
.prab-chip--chosen {
background: #edf7ed;
color: #1e6b2e;
border: 1px solid #b9dfc0;
}
.prab-chip--header {
margin-left: 6px;
font-size: 12px;
}
.prab-stage--stale {
border-left: 3px solid #d98c2b;
}
/* ── Stage footer actions ─────────────────────────────────────────── */
.prab-stage-rerun-footer {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-top: 12px;
padding-top: 10px;
border-top: 1px dashed #e2dcd2;
}
.prab-edit-unavailable {
font-size: 12px;
color: #777;
font-style: italic;
max-width: 46em;
}
.prab-rerun-from--stale {
border-color: #d98c2b;
color: #8a4a0b;
}
/* ── Edit panel ───────────────────────────────────────────────────── */
.prab-edit-panel {
margin-top: 12px;
padding: 14px 16px;
background: #fbf8f2;
border: 1px solid #e2dcd2;
border-radius: 6px;
}
.prab-edit-copy {
font-size: 13px;
color: #50575e;
max-width: 60em;
}
.prab-edit-spend {
font-size: 12px;
color: #50575e;
}
.prab-edit-spend--warn,
.prab-spend-warning {
color: #8a2a0b;
font-weight: 600;
}
.prab-run-spend--warn {
border-left: 3px solid #c9501f;
}
.prab-edit-model {
font-size: 12px;
color: #777;
font-family: Consolas, Monaco, monospace;
}
.prab-edit-message {
margin-bottom: 10px;
}
.prab-edit-role {
display: block;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #8c8f94;
margin-bottom: 3px;
}
.prab-edit-textarea {
width: 100%;
font-family: Consolas, Monaco, monospace;
font-size: 12px;
line-height: 1.5;
}
.prab-edit-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-top: 8px;
}
.prab-edit-forkinfo {
font-size: 12px;
color: #777;
}
.prab-edit-feedback {
font-size: 12px;
color: #1e6b2e;
}
.prab-edit-feedback--error {
color: #b32d2e;
}
/* ── Image grid (F3) ──────────────────────────────────────────────── */
.prab-image-grid {
display: flex;
gap: 14px;
flex-wrap: wrap;
margin: 10px 0;
}
.prab-image-card {
margin: 0;
max-width: 220px;
}
.prab-image-card img {
width: 100%;
height: auto;
border-radius: 4px;
border: 1px solid #e2dcd2;
}
.prab-image-card--chosen img {
border: 2px solid #1b8a92;
}
.prab-image-card figcaption {
font-size: 12px;
color: #50575e;
margin-top: 4px;
}
/* ── Sidebar models card (F2) ─────────────────────────────────────── */
.prab-models-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.prab-models-table th {
text-align: left;
font-weight: 600;
padding: 4px 6px 4px 0;
vertical-align: top;
color: #50575e;
width: 38%;
}
.prab-models-table td {
padding: 4px 0;
}
.prab-models-model {
display: block;
font-family: Consolas, Monaco, monospace;
font-size: 11px;
word-break: break-all;
}
.prab-models-meta {
display: block;
color: #8c8f94;
font-size: 11px;
}
/* ── Board card human-modified dot ────────────────────────────────── */
.prab-card-human-chip {
display: inline-block;
margin-top: 4px;
padding: 1px 7px;
border-radius: 9px;
font-size: 10px;
font-weight: 700;
background: #e9f2f4;
color: #1b5e6b;
border: 1px solid #b8d8de;
}
/* ── Review queue stale warning ───────────────────────────────────── */
.prab-review-stale-chip {
display: inline-block;
margin-left: 6px;
padding: 1px 7px;
border-radius: 9px;
font-size: 10px;
font-weight: 700;
background: #fcf0e4;
color: #8a4a0b;
border: 1px solid #e8c9a8;
}

441
assets/css/dossier.css Normal file
View File

@@ -0,0 +1,441 @@
/**
* PRAutoBlogger Article Dossier styles (M2).
*
* Design contract: Proposal C "Editorial Record" -- warm editorial,
* generous whitespace, typographic hierarchy, verdict pills, receipt
* sidebar. Scoped to .prab-dossier-wrap to avoid WP admin conflicts.
*
* @see admin/class-dossier-page.php
* @see templates/admin/dossier-page.php
*/
/* ── Layout ──────────────────────────────────────────────────────────── */
.prab-dossier-wrap {
max-width: 1400px;
}
.prab-dossier-back {
margin-bottom: 12px;
}
.prab-dossier-back a {
color: #1B8A92;
text-decoration: none;
}
.prab-dossier-back a:hover {
text-decoration: underline;
}
.prab-dossier--empty {
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
padding: 32px;
}
.prab-dossier-empty-notice h2 {
font-size: 1.3em;
margin: 0 0 12px;
color: #444;
}
.prab-dossier {
background: #fff;
}
/* ── Header ──────────────────────────────────────────────────────────── */
.prab-dossier-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 24px 28px 20px;
border-bottom: 1px solid #e8e0d8;
background: #FAF6EE;
border-radius: 4px 4px 0 0;
}
.prab-dossier-title {
font-size: 1.6em;
margin: 0 0 8px;
color: #1a1a1a;
line-height: 1.3;
}
.prab-dossier-header-meta {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
/* ── Body: two-column ─────────────────────────────────────────────────── */
.prab-dossier-body {
display: flex;
align-items: flex-start;
gap: 0;
}
.prab-dossier-main {
flex: 1;
min-width: 0;
padding: 24px 28px;
}
.prab-dossier-sidebar {
width: 260px;
flex-shrink: 0;
padding: 24px 20px;
border-left: 1px solid #e8e0d8;
}
/* ── Verdict pills ────────────────────────────────────────────────────── */
.prab-verdict-pill {
display: inline-block;
padding: 3px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.prab-verdict-pill--approved { background: #e6f5ee; color: #1a7a45; }
.prab-verdict-pill--revised { background: #fff4e0; color: #a06000; }
.prab-verdict-pill--rejected { background: #fde8e8; color: #b91c1c; }
.prab-verdict-pill--halted { background: #f0f0f0; color: #555; }
.prab-verdict-pill--pending { background: #e8f0fe; color: #1a55a0; }
/* ── Status pills ─────────────────────────────────────────────────────── */
.prab-dossier-tier {
font-size: 12px;
color: #666;
padding: 2px 8px;
border: 1px solid #ddd;
border-radius: 10px;
}
.prab-dossier-status {
font-size: 12px;
color: #777;
}
/* ── Stage sections ───────────────────────────────────────────────────── */
.prab-dossier-section {
margin-bottom: 28px;
border: 1px solid #e5ddd5;
border-radius: 4px;
overflow: hidden;
}
.prab-dossier-section--stage {
border-left: 4px solid #ccc;
}
.prab-stage-status--done { border-left-color: #1B8A92; }
.prab-stage-status--running { border-left-color: #FF8A3D; }
.prab-stage-status--failed { border-left-color: #e53e3e; }
.prab-stage-status--pending { border-left-color: #a0aec0; }
.prab-stage-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: #faf8f5;
border-bottom: 1px solid #ede6dd;
}
.prab-section-title {
font-size: 1em;
font-weight: 600;
margin: 0;
color: #2d2d2d;
}
.prab-stage-role {
font-weight: 400;
color: #888;
font-size: 0.9em;
margin-left: 4px;
}
.prab-stage-header-meta {
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
color: #666;
}
.prab-stage-status-pill {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
.prab-stage-status-pill--done { background: #e6f5ee; color: #1a7a45; }
.prab-stage-status-pill--running { background: #fff4e0; color: #a06000; }
.prab-stage-status-pill--failed { background: #fde8e8; color: #b91c1c; }
.prab-stage-status-pill--pending { background: #f0f0f0; color: #555; }
.prab-stage-cost {
color: #1B8A92;
font-weight: 600;
}
.prab-stage-pv {
background: #f0f0f0;
padding: 1px 6px;
border-radius: 8px;
font-size: 11px;
}
/* ── Stage output ─────────────────────────────────────────────────────── */
.prab-stage-output {
padding: 16px;
line-height: 1.7;
}
.prab-stage-output-rendered {
font-size: 14px;
color: #333;
max-height: 480px;
overflow-y: auto;
}
.prab-output-pruned,
.prab-output-absent {
color: #888;
font-style: italic;
margin: 0;
}
/* ── Raw trace toggle ─────────────────────────────────────────────────── */
.prab-stage-trace {
padding: 0 16px 12px;
border-top: 1px solid #f0ece6;
}
.prab-trace-toggle {
font-size: 12px;
color: #1B8A92;
cursor: pointer;
padding: 6px 0;
background: none;
border: none;
}
.prab-trace-toggle:hover {
color: #0d6066;
}
.prab-trace-detail {
margin-top: 8px;
}
.prab-trace-entry {
background: #f7f4ef;
border: 1px solid #e5ddd5;
border-radius: 4px;
padding: 10px 12px;
margin-bottom: 8px;
}
.prab-trace-meta {
display: flex;
gap: 12px;
font-size: 11px;
color: #666;
margin-bottom: 6px;
flex-wrap: wrap;
}
.prab-trace-status--success { color: #1a7a45; }
.prab-trace-status--error { color: #b91c1c; }
.prab-trace-request summary {
font-size: 12px;
cursor: pointer;
color: #888;
padding: 4px 0;
}
.prab-trace-pre {
background: #1a1a1a;
color: #e2e8f0;
padding: 10px;
border-radius: 3px;
font-size: 11px;
overflow-x: auto;
max-height: 300px;
white-space: pre-wrap;
word-break: break-all;
}
.prab-trace-error {
color: #b91c1c;
font-size: 12px;
margin: 4px 0 0;
}
/* ── Stage timestamps ─────────────────────────────────────────────────── */
.prab-stage-timestamps {
padding: 4px 16px 10px;
font-size: 11px;
color: #aaa;
}
.prab-ts-sep {
margin: 0 4px;
}
/* ── Decisions section ───────────────────────────────────────────────── */
.prab-dossier-section--decisions {
padding: 16px;
}
.prab-dossier-section--decisions .prab-section-title {
font-size: 1.1em;
margin-bottom: 12px;
}
.prab-decision-row {
display: flex;
align-items: flex-start;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid #f0ece6;
}
.prab-decision-row:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.prab-decision-stage {
font-weight: 600;
font-size: 13px;
min-width: 120px;
}
.prab-decision-rationale {
margin: 4px 0 0;
width: 100%;
color: #555;
font-size: 13px;
}
/* ── Sidebar ──────────────────────────────────────────────────────────── */
.prab-sidebar-card {
background: #faf8f5;
border: 1px solid #e5ddd5;
border-radius: 4px;
padding: 14px 16px;
margin-bottom: 16px;
}
.prab-sidebar-heading {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #888;
margin: 0 0 10px;
}
.prab-sidebar-dl {
margin: 0;
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 10px;
font-size: 12px;
}
.prab-sidebar-dl dt {
color: #888;
white-space: nowrap;
}
.prab-sidebar-dl dd {
margin: 0;
color: #333;
word-break: break-all;
}
.prab-run-id {
font-family: monospace;
font-size: 10px;
}
/* ── Cost receipt ─────────────────────────────────────────────────────── */
.prab-receipt-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.prab-receipt-table td {
padding: 3px 0;
}
.prab-receipt-stage {
color: #555;
}
.prab-receipt-cost {
text-align: right;
color: #1B8A92;
font-family: monospace;
}
.prab-receipt-total {
border-top: 1px solid #ddd;
font-weight: 700;
}
.prab-receipt-total td {
padding-top: 6px;
}
/* ── Run status ───────────────────────────────────────────────────────── */
.prab-run-status {
font-size: 12px;
}
.prab-run-status--done { color: #1a7a45; }
.prab-run-status--running { color: #a06000; }
.prab-run-status--failed { color: #b91c1c; }
/* ── No stages ─────────────────────────────────────────────────────────── */
.prab-dossier-no-stages {
padding: 24px 0;
color: #888;
}
/* ── Metabox ──────────────────────────────────────────────────────────── */
.prab-metabox-dossier .prab-run-id-label {
font-size: 11px;
color: #999;
margin-top: 4px;
}

View File

@@ -0,0 +1,53 @@
/**
* PRAutoBlogger Board -- "New Article" generate button handler (v0.21.0, M4).
*
* Loaded on the board page alongside board.js. Depends on the prabBoard config
* object and jQuery, both already present when board.js loads.
*
* @see assets/js/board.js -- Board poller (loads first).
* @see includes/admin/class-board-page.php -- Enqueues + localizes prabBoard.
*/
( function ( $ ) {
'use strict';
var cfg = window.prabBoard || {};
/**
* Handle click on the "New Article" button. Fires the existing generate_now
* AJAX action and triggers board backoff reset so the new card appears fast.
*/
function onBoardGenerateNow() {
var $btn = $( '#prab-board-generate-now' );
if ( ! $btn.length || $btn.prop( 'disabled' ) ) {
return;
}
$btn.prop( 'disabled', true ).text( cfg.strings && cfg.strings.generatingBtn || 'Generating\u2026' );
var $status = $( '#prab-board-gen-status' );
$status.text( '' ).hide();
$.ajax( {
url: cfg.ajaxUrl,
method: 'POST',
data: { action: 'prautoblogger_generate_now', nonce: cfg.generateNonce },
success: function ( response ) {
if ( response && response.success ) {
$status.text( cfg.strings && cfg.strings.genStarted || 'Generation started.' ).show();
// Notify board poller to reset backoff (run is now active).
$( document ).trigger( 'prab:generation-started' );
} else {
$status.text( cfg.strings && cfg.strings.genError || 'Generation failed.' ).show();
$btn.prop( 'disabled', false ).text( cfg.strings && cfg.strings.newArticle || 'New Article' );
}
},
error: function () {
$status.text( cfg.strings && cfg.strings.genError || 'Generation failed.' ).show();
$btn.prop( 'disabled', false ).text( cfg.strings && cfg.strings.newArticle || 'New Article' );
}
} );
}
$( function () {
$( document ).on( 'click', '#prab-board-generate-now', onBoardGenerateNow );
} );
}( jQuery ) );

236
assets/js/board.js Normal file
View File

@@ -0,0 +1,236 @@
/**
* PRAutoBlogger — Kanban Board Poller
*
* Live-polls the board status AJAX endpoint and updates the four columns
* (Generating | In Review | Published | Failed) in-place without a full
* page reload.
*
* Poll behaviour:
* - Polls at `prabBoard.pollInterval` ms when any article is generating.
* - Doubles the interval (backoff) when `has_active_runs` is false, capping
* at 4× the base interval — prevents needless AJAX on an idle board.
* - Resets to base interval as soon as an active run is detected again.
*
* Click-throughs (M1):
* - Generating → Activity Log
* - In Review → Review Queue
* - Published → Post edit screen
* - Failed → Activity Log
* (M2 rewires all to Article Dossier)
*
* Localised config: `prabBoard` object (ajaxUrl, nonce, pollInterval, action,
* strings) injected by PRAutoBlogger_Board_Page::on_enqueue_assets().
*
* @see includes/admin/class-board-page.php — AJAX handler + localise.
* @see assets/css/board.css — Card and column styles.
* @see templates/admin/board-page.php — Initial server-rendered HTML.
*/
( function ( $ ) {
'use strict';
var cfg = window.prabBoard || {};
var baseInterval = cfg.pollInterval || 5000;
var maxBackoff = 4;
var backoffFactor = 1;
var pollTimer = null;
var $board = null;
var $errorBanner = null;
/** Escape HTML for safe DOM insertion. */
function escHtml( str ) {
return $( '<div>' ).text( String( str ) ).html();
}
/** Format cost to 4 decimal places with a leading $. */
function formatCost( cost ) {
var n = parseFloat( cost ) || 0;
return '$' + n.toFixed( 4 );
}
/**
* Build a card HTML string from a card data object.
*
* @param {Object} card Card data from server.
* @param {string} columnKey Column identifier.
* @returns {string} HTML string.
*/
function buildCardHtml( card, columnKey ) {
var isGenerating = ( columnKey === 'generating' );
var cls = 'prab-board-card' + ( isGenerating ? ' prab-board-card--generating' : '' );
var html = '';
html += '<div class="' + escHtml( cls ) + '"';
html += ' data-run-id="' + escHtml( card.run_id || '' ) + '"';
html += ' data-post-id="' + escHtml( card.post_id || 0 ) + '">';
if ( isGenerating ) {
html += '<div class="prab-card-spinner" aria-label="' + escHtml( cfg.strings.generating ) + '"></div>';
}
html += '<div class="prab-card-title">' + escHtml( card.title || cfg.strings.empty ) + '</div>';
// v0.20.0: human-modified runs are visually distinct at run-list level.
if ( card.human_modified ) {
html += '<span class="prab-card-human-chip">' + escHtml( cfg.strings.humanModified || 'Human-modified' ) + '</span>';
}
if ( card.cost_total ) {
html += '<div class="prab-card-meta"><span class="prab-card-cost">';
html += escHtml( formatCost( card.cost_total ) );
html += '</span></div>';
}
if ( isGenerating && card.stage_current ) {
html += '<div class="prab-card-stage">';
/* translators: stage name */
html += escHtml( cfg.strings.generating + ': ' + card.stage_current );
html += '</div>';
}
if ( card.error_message ) {
var errShort = String( card.error_message ).split( ' ' ).slice( 0, 12 ).join( ' ' );
if ( card.error_message.split( ' ' ).length > 12 ) {
errShort += '…';
}
html += '<div class="prab-card-error">' + escHtml( errShort ) + '</div>';
}
// Click-through link.
var linkUrl = '';
var linkText = '';
if ( card.click_action === 'dossier' && card.dossier_url ) {
linkUrl = card.dossier_url;
linkText = cfg.strings.view;
} else if ( card.click_action === 'review' && card.review_url ) {
linkUrl = card.review_url;
linkText = cfg.strings.view;
} else if ( card.click_action === 'edit' && card.edit_url ) {
linkUrl = card.edit_url;
linkText = cfg.strings.edit;
} else if ( card.click_action === 'logs' && card.log_url ) {
linkUrl = card.log_url;
linkText = cfg.strings.viewLog;
}
if ( linkUrl ) {
html += '<div class="prab-card-actions">';
html += '<a href="' + escHtml( linkUrl ) + '" class="prab-card-link">';
html += escHtml( linkText );
html += '</a></div>';
}
html += '</div>';
return html;
}
/**
* Re-render a single column from the server snapshot.
*
* Updates the pill count and replaces card HTML. Avoids full DOM teardown
* to prevent flicker on the running spinner.
*
* @param {string} colKey Column identifier.
* @param {Array} cards Array of card objects.
* @returns {void}
*/
function renderColumn( colKey, cards ) {
var $col = $board.find( '[data-column="' + colKey + '"]' );
var $cards = $col.find( '[data-column-cards]' );
var $pill = $col.find( '.prab-col-count' );
$pill.text( cards.length );
if ( ! cards.length ) {
$cards.html( '<p class="prab-col-empty">' + escHtml( cfg.strings.empty ) + '</p>' );
return;
}
// Build new card HTML.
var newHtml = '';
for ( var i = 0; i < cards.length; i++ ) {
newHtml += buildCardHtml( cards[ i ], colKey );
}
$cards.html( newHtml );
}
/**
* Apply a server snapshot to all four columns.
*
* @param {Object} snapshot Board snapshot from AJAX.
* @returns {void}
*/
function applySnapshot( snapshot ) {
renderColumn( 'generating', snapshot.generating || [] );
renderColumn( 'in_review', snapshot.in_review || [] );
renderColumn( 'published', snapshot.published || [] );
renderColumn( 'failed', snapshot.failed || [] );
// Re-enable board Generate button once no active runs remain.
if ( ! snapshot.has_active_runs ) {
$( '#prab-board-generate-now' ).prop( 'disabled', false )
.text( ( cfg.strings && cfg.strings.newArticle ) || 'New Article' );
}
}
/**
* Compute the next poll delay based on whether there are active runs.
*
* @param {boolean} hasActiveRuns
* @returns {number} Milliseconds until next poll.
*/
function nextDelay( hasActiveRuns ) {
if ( hasActiveRuns ) {
backoffFactor = 1;
return baseInterval;
}
backoffFactor = Math.min( backoffFactor + 1, maxBackoff );
return baseInterval * backoffFactor;
}
/**
* Schedule the next poll after a given delay.
*
* @param {number} delay Milliseconds.
* @returns {void}
*/
function schedulePoll( delay ) {
clearTimeout( pollTimer );
pollTimer = setTimeout( doPoll, delay );
}
/**
* Execute one board poll: AJAX POST → apply snapshot → reschedule.
*
* @returns {void}
*/
function doPoll() {
$.ajax( {
url: cfg.ajaxUrl,
method: 'POST',
data: {
action: cfg.action,
nonce: cfg.nonce
},
success: function ( response ) {
if ( $errorBanner ) {
$errorBanner.removeClass( 'is-visible' );
}
if ( response && response.success && response.data ) {
applySnapshot( response.data );
schedulePoll( nextDelay( !! response.data.has_active_runs ) );
} else {
schedulePoll( nextDelay( false ) );
}
},
error: function () {
if ( $errorBanner ) {
$errorBanner.addClass( 'is-visible' );
}
schedulePoll( nextDelay( false ) );
}
} );
}
} );
}( jQuery ) );

235
assets/js/dossier-edit.js Normal file
View File

@@ -0,0 +1,235 @@
/**
* PRAutoBlogger Article Dossier — edit + re-run actions (M3, v0.20.0).
*
* Chained-cron UX contract (CPO hard constraint): clicking a re-run
* button QUEUES a background job — the UI says "queued", starts the
* stage-status poll, and renders pickup/result as the rows change.
* Nothing here implies synchronous execution.
*
* Vanilla JS, no build step. Config injected via wp_localize_script
* ('prabDossierEdit': ajaxUrl, nonce, postId, pollInterval, strings).
*
* @see admin/class-dossier-actions.php — The endpoints this calls.
* @see admin/class-dossier-page.php — Enqueue + localization.
*/
( function () {
'use strict';
var cfg = window.prabDossierEdit || null;
function post( action, fields ) {
var data = new window.FormData();
data.append( 'action', action );
data.append( 'nonce', cfg.nonce );
data.append( 'post_id', cfg.postId );
Object.keys( fields || {} ).forEach( function ( key ) {
data.append( key, fields[ key ] );
} );
return window.fetch( cfg.ajaxUrl, { method: 'POST', credentials: 'same-origin', body: data } )
.then( function ( res ) { return res.json(); } );
}
function feedback( panel, message, isError ) {
var el = panel.querySelector( '[data-feedback]' );
if ( el ) {
el.textContent = message || '';
el.classList.toggle( 'prab-edit-feedback--error', !! isError );
}
}
function sectionFor( el ) {
var node = el;
while ( node && ! node.classList.contains( 'prab-dossier-section--stage' ) ) {
node = node.parentElement;
}
return node;
}
// ── Stage-status polling (queued → pickup → result) ────────────────
var pollTimer = null;
var sawActivity = false;
function applyStageState( state ) {
var selector = '.prab-dossier-section--stage[data-stage="' + state.stage + '"][data-agent-role="' + state.agent_role + '"]';
var section = document.querySelector( selector );
if ( ! section ) {
return;
}
var pill = section.querySelector( '.prab-stage-status-pill' );
if ( pill ) {
pill.className = 'prab-stage-status-pill prab-stage-status-pill--' + state.status;
pill.textContent = state.status.charAt( 0 ).toUpperCase() + state.status.slice( 1 );
}
var staleChip = section.querySelector( '[data-chip="stale"]' );
if ( staleChip ) {
staleChip.hidden = ! state.stale;
}
section.classList.toggle( 'prab-stage--stale', !! state.stale );
var humanChip = section.querySelector( '[data-chip="human"]' );
if ( humanChip ) {
humanChip.hidden = ! state.human_modified;
}
}
function poll() {
post( 'prautoblogger_dossier_stage_status', {} ).then( function ( res ) {
if ( ! res || ! res.success || ! res.data ) {
return;
}
var anyRunning = false;
var anyStale = false;
var anyHuman = false;
( res.data.stages || [] ).forEach( function ( state ) {
applyStageState( state );
if ( 'running' === state.status || 'pending' === state.status ) {
anyRunning = true;
}
anyStale = anyStale || !! state.stale;
anyHuman = anyHuman || !! state.human_modified;
} );
var headerHuman = document.querySelector( '[data-header-chip="human"]' );
if ( headerHuman ) {
headerHuman.hidden = ! anyHuman;
}
var headerStale = document.querySelector( '[data-header-chip="stale"]' );
if ( headerStale ) {
headerStale.hidden = ! anyStale;
}
var terminal = -1 === [ 'running', 'pending' ].indexOf( res.data.run_status );
if ( anyRunning || ! terminal ) {
sawActivity = true;
return;
}
// Run settled after activity we watched: re-render the dossier
// fresh (server-rendered outputs, receipts, fork info).
if ( sawActivity ) {
window.clearInterval( pollTimer );
pollTimer = null;
window.location.reload();
}
} ).catch( function () { /* transient poll errors are ignored */ } );
}
function startPolling() {
sawActivity = true;
if ( ! pollTimer ) {
pollTimer = window.setInterval( poll, cfg.pollInterval * 1000 );
}
}
// ── Wiring ──────────────────────────────────────────────────────────
document.addEventListener( 'DOMContentLoaded', function () {
if ( ! cfg ) {
return;
}
// Edit panel toggles.
document.querySelectorAll( '.prab-edit-toggle' ).forEach( function ( btn ) {
btn.addEventListener( 'click', function () {
var panel = document.getElementById( btn.getAttribute( 'aria-controls' ) );
if ( ! panel ) {
return;
}
var open = btn.getAttribute( 'aria-expanded' ) === 'true';
panel.hidden = open;
btn.setAttribute( 'aria-expanded', open ? 'false' : 'true' );
} );
} );
// Save fork.
document.querySelectorAll( '.prab-edit-save' ).forEach( function ( btn ) {
btn.addEventListener( 'click', function () {
var panel = btn.closest( '.prab-edit-panel' );
var messages = [];
panel.querySelectorAll( '.prab-edit-textarea' ).forEach( function ( area ) {
messages.push( { role: area.getAttribute( 'data-role' ), content: area.value } );
} );
btn.disabled = true;
feedback( panel, cfg.strings.saving, false );
post( 'prautoblogger_dossier_save_input', {
stage: panel.getAttribute( 'data-stage' ),
agent_role: panel.getAttribute( 'data-agent-role' ),
messages: JSON.stringify( messages )
} ).then( function ( res ) {
btn.disabled = false;
if ( res && res.success ) {
feedback( panel, res.data.message, false );
var rerunBtn = panel.querySelector( '.prab-edit-rerun' );
if ( rerunBtn ) {
rerunBtn.disabled = false;
}
var info = panel.querySelector( '[data-forkinfo]' );
if ( info && res.data.version ) {
info.textContent = 'v' + res.data.version;
}
} else {
feedback( panel, ( res && res.data && res.data.message ) || cfg.strings.error, true );
}
} ).catch( function () {
btn.disabled = false;
feedback( panel, cfg.strings.error, true );
} );
} );
} );
// Replay latest fork (queued).
document.querySelectorAll( '.prab-edit-rerun' ).forEach( function ( btn ) {
btn.addEventListener( 'click', function () {
var panel = btn.closest( '.prab-edit-panel' );
if ( ! window.confirm( cfg.strings.confirmReplay ) ) {
return;
}
btn.disabled = true;
post( 'prautoblogger_dossier_rerun_stage', {
stage: panel.getAttribute( 'data-stage' ),
agent_role: panel.getAttribute( 'data-agent-role' )
} ).then( function ( res ) {
if ( res && res.success ) {
feedback( panel, res.data.message, false );
startPolling();
} else {
btn.disabled = false;
feedback( panel, ( res && res.data && res.data.message ) || cfg.strings.error, true );
}
} ).catch( function () {
btn.disabled = false;
feedback( panel, cfg.strings.error, true );
} );
} );
} );
// Re-run from here (queued rebuild).
document.querySelectorAll( '.prab-rerun-from' ).forEach( function ( btn ) {
btn.addEventListener( 'click', function () {
var section = sectionFor( btn );
if ( ! window.confirm( cfg.strings.confirmRerunFrom ) ) {
return;
}
btn.disabled = true;
btn.textContent = cfg.strings.queued;
post( 'prautoblogger_dossier_rerun_from', {
stage: btn.getAttribute( 'data-stage' ),
agent_role: section ? section.getAttribute( 'data-agent-role' ) : ''
} ).then( function ( res ) {
if ( res && res.success ) {
startPolling();
} else {
btn.disabled = false;
btn.textContent = cfg.strings.rerunFromLabel;
window.alert( ( res && res.data && res.data.message ) || cfg.strings.error );
}
} ).catch( function () {
btn.disabled = false;
btn.textContent = cfg.strings.rerunFromLabel;
window.alert( cfg.strings.error );
} );
} );
} );
// A stage already running (e.g. revisit mid-job): poll from load.
if ( document.querySelector( '.prab-stage-status-pill--running' ) ) {
startPolling();
}
} );
}() );

41
assets/js/dossier.js Normal file
View File

@@ -0,0 +1,41 @@
/**
* PRAutoBlogger Article Dossier -- raw-trace toggle (M2).
*
* Toggles the hidden raw-trace <div> per stage when the "Show raw trace"
* button is clicked. Vanilla JS, no build step, no external deps.
*
* Accessible: uses aria-expanded + aria-controls; respects the hidden attribute.
*
* @see admin/class-dossier-page.php -- Enqueues this file.
* @see templates/admin/dossier-page.php -- Renders the toggle buttons.
*/
( function () {
'use strict';
document.addEventListener( 'DOMContentLoaded', function () {
var toggles = document.querySelectorAll( '.prab-trace-toggle' );
toggles.forEach( function ( btn ) {
btn.addEventListener( 'click', function () {
var targetId = btn.getAttribute( 'aria-controls' );
if ( ! targetId ) {
return;
}
var panel = document.getElementById( targetId );
if ( ! panel ) {
return;
}
var isOpen = btn.getAttribute( 'aria-expanded' ) === 'true';
if ( isOpen ) {
panel.hidden = true;
btn.setAttribute( 'aria-expanded', 'false' );
btn.textContent = btn.textContent.replace( /hide/i, 'Show' );
} else {
panel.hidden = false;
btn.setAttribute( 'aria-expanded', 'true' );
btn.textContent = btn.textContent.replace( /show/i, 'Hide' );
}
} );
} );
} );
}() );

View File

@@ -1,23 +1,26 @@
{
"name": "peptiderepo/prautoblogger",
"description": "WordPress plugin that auto-researches and publishes blog content using AI.",
"type": "wordpress-plugin",
"license": "GPL-2.0-or-later",
"require": {
"php": ">=7.4"
},
"require-dev": {
"phpunit/phpunit": "^9.5",
"brain/monkey": "^2.6"
},
"autoload": {
"classmap": [
"includes/"
]
},
"autoload-dev": {
"psr-4": {
"PRAutoBlogger\\Tests\\": "tests/unit/"
"name": "peptiderepo/prautoblogger",
"description": "WordPress plugin that auto-researches and publishes blog content using AI.",
"type": "wordpress-plugin",
"license": "GPL-2.0-or-later",
"require": {
"php": ">=7.4"
},
"require-dev": {
"phpunit/phpunit": "^9.5",
"brain/monkey": "^2.6"
},
"autoload": {
"classmap": [
"includes/"
]
},
"autoload-dev": {
"psr-4": {
"PRAutoBlogger\\Tests\\": "tests/unit/"
}
},
"scripts": {
"test": "vendor/bin/phpunit --testdox"
}
}
}
}

View File

@@ -0,0 +1,163 @@
# ADR-0001: Editorial illustration image style — pivot from comic to text-free illustration with caption-as-HTML
- **Status:** Accepted
- **Date:** 2026-05-29
- **Deciders:** CTO, Engineer PRAutoBlogger
- **Supersedes:** —
- **Superseded by:** —
- **Related:** ARCHITECTURE.md §#20; CHANGELOG.md [0.16.0]; PR #146 (d00e746);
`docs/proposals/2026-05-29-image-pipeline-in-plugin-brief.md`;
ARCHITECTURE.md §#21 (v0.17.0 deterministic composer, the follow-on commit)
---
## Context
PRAutoBlogger generates two AI images (A/B pair) for each article and publishes
them as featured images. From the initial comic-strip launch (commit b17ed47,
`feat: switch to single-panel newspaper comic style for article images`) through
v0.15.x, the image style was a **single-panel newspaper comic** — the rewriter
LLM was asked to produce a comic gag or visual pun, and the result was appended
to a style suffix and sent to the diffusion model.
By April 2026 two problems had accumulated:
1. **Text legibility.** Small diffusion models (FLUX.1 schnell is the default
since v0.9.0 at ~$0.0006/image) cannot reliably render readable text inside
an image. Comic panels depend on legible speech bubbles or captions burned
into the image; both were routinely garbled (confirmed in the 2026-04-21 A/B
finding documented in ARCHITECTURE.md §#21).
2. **Caption path already existed.** A separate HTML caption below the image had
been extracted as early as commit 214f57b (`Separate comic captions from
generated images`, 2026-04-18). The scene/caption split in
`Image_Scene_Parser` was already live, meaning there was an established path
for readable text that did not depend on the diffusion model rendering it.
Together these created pressure to stop asking the diffusion model to render
text at all, and instead shift all readable copy to the HTML caption path that
was already in place.
## Decision drivers
1. **Image quality on default model.** FLUX.1 schnell (the default since v0.9.0)
produces garbled text in generated images. A style that depends on readable
in-image text degrades silently on the model we actually ship.
2. **Editorial trust signal.** Peptide Repo's editorial direction is
science-informational; comic gags undercut the authority the site needs for
health-adjacent content.
3. **Reuse the existing caption path.** The scene/caption split and HTML caption
path were already live; the change is a prompt rewrite + template substitution
with no new infrastructure.
4. **Admin configurability.** The style suffix was a single string appended to
every prompt; a template with a named substitution token (`{{ topic_summary
}}`) is more maintainable and admin-overridable without a code deploy.
5. **Cost and provider neutrality.** The change is prompt-only; provider, model,
and pricing are unchanged.
## Options considered
### Option A — Keep the comic style, fix text rendering per-model
Ask the rewriter LLM to omit text from comic panels (no speech bubbles, no
caption burns), and describe a visual scene instead. Keep the style suffix
pattern.
**Pros:**
- Minimal code change — just a prompt edit.
- Comic aesthetic is recognisable and differentiating.
**Cons:**
- Does not solve the underlying mismatch: the style suffix is a static string
that gives no per-article context to the diffusion model. The rewriter scene
still has no slot to insert topic-specific imagery.
- Comic gags require the LLM to invent a joke per peptide, which is inconsistent
in tone.
- Comic aesthetic conflicts with the editorial trust direction for health content.
### Option B — Pivot to text-free editorial scientific illustration + template substitution (chosen)
Change the rewriter system prompt so the LLM emits a concise 1-2 sentence
topic/mechanism summary as the SCENE — one concrete centered focal subject, no
text, no people-as-gag, no logos. Replace the style suffix with an editable
template (`prautoblogger_image_style_template`) that contains a single
`{{ topic_summary }}` token filled per-article by
`PRAutoBlogger_Image_Template_Filler`. The CAPTION line from the scene/caption
split continues to flow to the HTML-caption-below-image path unchanged.
**Pros:**
- Diffusion model is never asked to render text; garbled-text failures are
eliminated.
- The editorial scientific illustration style is consistent with the site's
authority positioning.
- Template substitution gives admins a single editable field to control the
entire visual style without touching the per-article rewriter logic.
- Degradation is explicit: missing token → append + warning; empty summary →
style-only prompt; a blank prompt is never sent to the provider.
- Provider, model, and cost are unchanged.
**Cons:**
- Breaks the comic gag aesthetic for any site running PRAutoBlogger in a
context where that was intentional (low risk for this deployment).
- The old `prautoblogger_image_style_suffix` option must be migrated
(handled via one-time migration `prautoblogger_migrated_style_template_v0160`
and mirroring to `_deprecated` for one cycle).
### Option C — Generate text overlays in PHP (deterministic composer only)
Skip the style change; solve legibility by compositing text onto images in PHP
after the diffusion model runs.
**Pros:**
- Diffusion model input is unchanged (comic style preserved).
- Composited text is always readable regardless of model.
**Cons:**
- Compositing a comic-style gag is much harder than compositing a caption band
(arbitrary layout vs. a fixed panel at the bottom).
- Does not address the editorial direction mismatch.
- The deterministic PHP composer (ARCHITECTURE.md §#21) was already planned as
a follow-on commit specifically for OG/social variants — pursuing it as the
sole fix conflates two separate problems.
## Decision
**Option B** — pivot to text-free editorial scientific illustration with
caption-as-HTML and template substitution.
This was shipped as v0.16.0 (2026-05-29, PR #146, commit d00e746). The changes
are entirely in the prompt layer:
- `Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT` now asks for a concise
topic/mechanism SCENE and keeps the CAPTION line for the HTML path.
- `PRAutoBlogger_Image_Template_Filler::fill()` replaces the old
`trim($scene . ' ' . $style_suffix)` concat at all three entry points
(`build_article_prompt`, `build_source_prompt`, `build_fallback_prompt`).
- New admin setting `prautoblogger_image_style_template` with
single-token validation replaces the Style Suffix field.
- One-time migration preserves the old suffix value for one cycle.
The deterministic PHP composer for OG/social branding (ARCHITECTURE.md §#21)
was deliberately deferred to a follow-on commit (v0.17.0, #152) so that the
prompt-only change could ship and be verified first.
## Consequences
**Accepted benefits:**
- Garbled-text image failures are eliminated on the default FLUX.1 schnell model.
- The editorial scientific illustration style is consistent with the site's
health-content positioning and sustainable across future model changes.
- The template + token pattern makes style updates admin-configurable without
code changes.
**Accepted costs:**
- The comic aesthetic is gone and cannot be restored by toggling a setting;
switching back requires a prompt and template change.
- The `prautoblogger_image_style_suffix` option is deprecated; the migration
runs once on upgrade and the old option is not deleted until a future cycle.
**Follow-up created:**
- ARCHITECTURE.md §#21 (v0.17.0): deterministic PHP image composer to composite
branded OG and square card variants — necessary because the social/OG image
travels without the HTML caption and therefore needs text burned in by PHP,
not by the diffusion model.

26
docs/adr/README.md Normal file
View File

@@ -0,0 +1,26 @@
# PRAutoBlogger — App-Local ADR Log
This folder is the per-repo decision log for the **PRAutoBlogger** plugin.
It records architectural choices that affect this codebase alone.
**Cross-cutting decisions** (spanning multiple plugins, shared infra, or
CI/deploy standards) live centrally in
`Peptide Repo CTO/docs/engineering/decisions/`.
For the routing rule, required sections, and status lifecycle, read the
canonical process document:
`Peptide Repo CTO/docs/engineering/ADR-PROCESS.md`
The canonical template for new ADRs is:
`Peptide Repo CTO/docs/engineering/adr-template.md`
## Numbering
ADRs in this folder use a per-repo zero-padded sequence starting at `0001`.
A new ADR always gets the next unused number; numbers are never reused.
## Index
| Number | Title | Status | Date |
|--------|-------|--------|------|
| [0001](0001-editorial-illustration-image-style.md) | Editorial illustration image style — pivot from comic to text-free illustration with caption-as-HTML | Accepted | 2026-05-29 |

81
includes/admin/class-admin-page.php Executable file → Normal file
View File

@@ -62,6 +62,7 @@ class PRAutoBlogger_Admin_Page {
public function on_enqueue_assets( string $hook_suffix ): void {
$pages = array(
'toplevel_page_' . self::PAGE_SLUG,
'prautoblogger_page_prautoblogger-board',
'prautoblogger_page_prautoblogger-metrics',
'prautoblogger_page_prautoblogger-review-queue',
'prautoblogger_page_prautoblogger-ideas',
@@ -269,84 +270,14 @@ class PRAutoBlogger_Admin_Page {
/**
* Sanitize a settings field value.
*
* Delegates to PRAutoBlogger_Settings_Sanitizer (extracted for 300-line compliance).
*
* @param mixed $value The submitted value.
* @return mixed Sanitized value.
*
* @see admin/class-settings-sanitizer.php
*/
public function sanitize_field( $value ) {
$option_name = '';
$filter = current_filter();
if ( 0 === strpos( $filter, 'sanitize_option_' ) ) {
$option_name = substr( $filter, strlen( 'sanitize_option_' ) );
}
$encrypted = array( 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json', 'prautoblogger_runware_api_key' );
if ( in_array( $option_name, $encrypted, true ) ) {
// Empty value means password field wasn't touched — keep existing.
if ( '' === $value ) {
return get_option( $option_name, '' );
}
// Already encrypted (has "enc:" prefix) — return as-is.
// This is the primary defence against double-encryption:
// PRAutoBlogger_Encryption::encrypt() also checks for this prefix,
// so even if this callback is called multiple times, the value
// is encrypted exactly once and never re-encrypted.
if ( PRAutoBlogger_Encryption::is_encrypted( $value ) ) {
return $value;
}
// New plaintext value — encrypt it (adds "enc:" prefix).
return PRAutoBlogger_Encryption::encrypt( sanitize_text_field( $value ) );
}
$json_fields = array( 'prautoblogger_target_subreddits', 'prautoblogger_topic_exclusions', 'prautoblogger_enabled_sources' );
if ( in_array( $option_name, $json_fields, true ) ) {
// PHP array from checkboxes — sanitize each item and re-encode.
if ( is_array( $value ) ) {
return wp_json_encode( array_values( array_map( 'sanitize_text_field', $value ) ) );
}
$trimmed = trim( (string) $value );
// Already JSON-encoded array — decode, sanitize each item, re-encode.
// This prevents sanitize_text_field() from mangling the JSON string,
// which caused json_decode() on read to return null → empty subreddits.
if ( '[' === substr( $trimmed, 0, 1 ) ) {
$decoded = json_decode( $trimmed, true );
if ( is_array( $decoded ) ) {
return wp_json_encode( array_values( array_map( 'sanitize_text_field', $decoded ) ) );
}
}
// Comma-separated plain text — split, sanitize, encode as JSON array.
$items = array_filter( array_map( 'trim', explode( ',', $trimmed ) ) );
return wp_json_encode( array_values( array_map( 'sanitize_text_field', $items ) ) );
}
$numeric = array( 'prautoblogger_daily_article_target', 'prautoblogger_monthly_budget_usd', 'prautoblogger_min_word_count', 'prautoblogger_max_word_count', 'prautoblogger_default_author', 'prautoblogger_default_category', 'prautoblogger_pullpush_cache_ttl', 'prautoblogger_reddit_posts_per_subreddit' );
if ( in_array( $option_name, $numeric, true ) ) {
return is_numeric( $value ) ? $value : 0;
}
if ( 'prautoblogger_image_model' === $option_name ) {
return $this->sanitize_image_model( (string) $value );
}
if ( 'prautoblogger_image_style_template' === $option_name ) {
// Multi-line template: validation + textarea-safe sanitisation
// (incl. the single-token check, brief A5) lives in the filler.
return PRAutoBlogger_Image_Template_Filler::sanitize_for_save( (string) $value );
}
return sanitize_text_field( (string) $value );
}
/** Validate model id against the registry and persist the derived provider. */
private function sanitize_image_model( string $value ): string {
$candidate = sanitize_text_field( $value );
$provider = PRAutoBlogger_Image_Model_Registry::provider_for( $candidate );
if ( '' !== $provider ) {
update_option( 'prautoblogger_image_provider', $provider );
return $candidate;
}
add_settings_error( 'prautoblogger_image_model', 'prautoblogger_image_model_unknown', sprintf( esc_html__( 'Image model "%s" is not in the registry. Keeping the previous selection.', 'prautoblogger' ), esc_html( $candidate ) ) );
return (string) get_option( 'prautoblogger_image_model', PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL );
return PRAutoBlogger_Settings_Sanitizer::sanitize_field( $value );
}
}

View File

@@ -0,0 +1,230 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Supplies card data for the kanban board page from existing DB tables.
*
* Orchestrates the four board columns:
* Generating | In Review | Published | Failed
*
* Generation-log queries (Generating, Failed) are delegated to
* PRAutoBlogger_Board_Gen_Log_Query. WP_Query columns (In Review, Published)
* are handled here.
*
* M2: cards now include `dossier_url` so board.js can deep-link to the
* Article Dossier instead of the old per-column destinations.
*
* No schema changes -- all data comes from tables that already exist.
*
* Triggered by: PRAutoBlogger_Board_Page::on_ajax_board_status().
* Dependencies: PRAutoBlogger_Board_Gen_Log_Query, PRAutoBlogger_Dossier_Page,
* WordPress WP_Query, get_option().
*
* @see admin/class-board-gen-log-query.php -- Raw gen_log queries (Generating/Failed).
* @see admin/class-board-page.php -- Calls get_board_snapshot().
* @see admin/class-dossier-page.php -- Provides url_for_post() for deep-links.
* @see ARCHITECTURE.md -- Database schema.
*/
class PRAutoBlogger_Board_Data_Provider {
/** @var PRAutoBlogger_Board_Gen_Log_Query Handles raw gen_log DB queries. */
private PRAutoBlogger_Board_Gen_Log_Query $gen_log_query;
/**
* @param PRAutoBlogger_Board_Gen_Log_Query|null $gen_log_query Optional injection for testing.
*/
public function __construct( ?PRAutoBlogger_Board_Gen_Log_Query $gen_log_query = null ) {
$this->gen_log_query = $gen_log_query ?? new PRAutoBlogger_Board_Gen_Log_Query();
}
/**
* Return the full board snapshot consumed by the AJAX poller.
*
* @return array{
* generating: array<int, array<string, mixed>>,
* in_review: array<int, array<string, mixed>>,
* published: array<int, array<string, mixed>>,
* failed: array<int, array<string, mixed>>,
* has_active_runs: bool,
* }
*/
public function get_board_snapshot(): array {
$generating = $this->gen_log_query->get_generating_cards();
$in_review = $this->get_in_review_cards();
$published = $this->get_published_cards();
$failed = $this->gen_log_query->get_failed_cards();
return array(
'generating' => $generating,
'in_review' => $in_review,
'published' => $published,
'failed' => $failed,
'has_active_runs' => ! empty( $generating ),
);
}
/**
* Cards for runs that are currently in-progress.
*
* @return array<int, array<string, mixed>>
*/
public function get_generating_cards(): array {
return $this->gen_log_query->get_generating_cards();
}
/**
* Cards for generation runs that ended in an error state.
*
* @return array<int, array<string, mixed>>
*/
public function get_failed_cards(): array {
return $this->gen_log_query->get_failed_cards();
}
/**
* Cards for generated draft posts awaiting human review.
* M2: cards include dossier_url for deep-linking.
*
* @return array<int, array<string, mixed>>
*/
public function get_in_review_cards(): array {
$query = new WP_Query(
array(
'post_type' => 'post',
'post_status' => 'draft',
'posts_per_page' => max( 5, (int) get_option( 'prautoblogger_board_column_limit', PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT ) ),
'meta_query' => array(
array(
'key' => '_prautoblogger_generated',
'value' => '1',
),
),
'orderby' => 'date',
'order' => 'DESC',
)
);
$cards = array();
foreach ( $query->posts as $post ) {
if ( ! ( $post instanceof WP_Post ) ) {
continue;
}
$run_id = (string) get_post_meta( $post->ID, '_prautoblogger_run_id', true );
$cost_total = (float) get_post_meta( $post->ID, '_prautoblogger_total_cost', true );
$cards[] = array(
'run_id' => $run_id,
'post_id' => $post->ID,
'title' => $post->post_title,
'cost_total' => $cost_total,
'created_at' => strtotime( $post->post_date_gmt ),
'click_action' => 'dossier',
'dossier_url' => PRAutoBlogger_Dossier_Page::url_for_post( $post->ID ),
'review_url' => admin_url( 'admin.php?page=prautoblogger-review-queue' ),
'edit_url' => get_edit_post_link( $post->ID, 'raw' ),
);
}
return $this->flag_human_modified( $cards );
}
/**
* Cards for published posts within the configured window.
* M2: cards include dossier_url for deep-linking.
*
* @return array<int, array<string, mixed>>
*/
public function get_published_cards(): array {
$window_days = max( 1, (int) get_option( 'prautoblogger_board_published_window_days', 7 ) );
$date_after = gmdate( 'Y-m-d H:i:s', time() - ( $window_days * DAY_IN_SECONDS ) );
$query = new WP_Query(
array(
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => max( 5, (int) get_option( 'prautoblogger_board_column_limit', PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT ) ),
'date_query' => array(
array(
'after' => $date_after,
'inclusive' => true,
),
),
'meta_query' => array(
array(
'key' => '_prautoblogger_generated',
'value' => '1',
),
),
'orderby' => 'date',
'order' => 'DESC',
)
);
$cards = array();
foreach ( $query->posts as $post ) {
if ( ! ( $post instanceof WP_Post ) ) {
continue;
}
$run_id = (string) get_post_meta( $post->ID, '_prautoblogger_run_id', true );
$cost_total = (float) get_post_meta( $post->ID, '_prautoblogger_total_cost', true );
$cards[] = array(
'run_id' => $run_id,
'post_id' => $post->ID,
'title' => $post->post_title,
'cost_total' => $cost_total,
'published_at' => strtotime( $post->post_date_gmt ),
'click_action' => 'dossier',
'dossier_url' => PRAutoBlogger_Dossier_Page::url_for_post( $post->ID ),
'edit_url' => get_edit_post_link( $post->ID, 'raw' ),
'post_url' => get_permalink( $post->ID ),
);
}
return $this->flag_human_modified( $cards );
}
/**
* Set 'human_modified' on each card in ONE batched query (v0.20.0 —
* CPO product AC: human-modified runs are visually distinct at the
* run-list level). No per-card queries; self-healing on a missing
* table/column (flag simply stays false).
*
* @param array<int, array<string, mixed>> $cards Column cards.
* @return array<int, array<string, mixed>> Cards with the flag set.
*/
private function flag_human_modified( array $cards ): array {
$run_ids = array();
foreach ( $cards as $i => $card ) {
$cards[ $i ]['human_modified'] = false;
if ( ! empty( $card['run_id'] ) ) {
$run_ids[] = (string) $card['run_id'];
}
}
if ( empty( $run_ids ) || ! PRAutoBlogger_Run_Stage_State::is_available() ) {
return $cards;
}
global $wpdb;
$table = PRAutoBlogger_Run_Stage_State::table_name();
$placeholders = implode( ',', array_fill( 0, count( $run_ids ), '%s' ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- $placeholders is a fixed %s list.
$flagged = $wpdb->get_col(
$wpdb->prepare(
"SELECT DISTINCT run_id FROM {$table} WHERE human_modified = 1 AND run_id IN ({$placeholders})",
$run_ids
)
);
if ( ! is_array( $flagged ) || empty( $flagged ) ) {
return $cards;
}
$flagged = array_flip( array_map( 'strval', $flagged ) );
foreach ( $cards as $i => $card ) {
if ( isset( $flagged[ (string) ( $card['run_id'] ?? '' ) ] ) ) {
$cards[ $i ]['human_modified'] = true;
}
}
return $cards;
}
}

View File

@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Raw generation-log queries for the kanban board.
*
* Extracted from PRAutoBlogger_Board_Data_Provider to stay under the 300-line
* cap. Handles the two columns that read directly from prab_generation_log:
* Generating (in-progress runs) | Failed (error runs with no linked post)
*
* No schema changes — data comes from prab_generation_log which already exists.
*
* Triggered by: PRAutoBlogger_Board_Data_Provider::get_generating_cards()
* PRAutoBlogger_Board_Data_Provider::get_failed_cards()
* Dependencies: WordPress $wpdb, get_transient().
*
* @see admin/class-board-data-provider.php — Orchestrator that calls this class.
* @see ARCHITECTURE.md — Database schema (generation_log).
*/
class PRAutoBlogger_Board_Gen_Log_Query {
/** Seconds of gen-log inactivity after which a run is no longer "generating". */
public const GENERATING_GRACE_SECONDS = 1800;
/** Transient key written by PRAutoBlogger_Executor for the active generation run. */
public const STATUS_TRANSIENT = 'prautoblogger_generation_status';
/**
* Cards for runs that are currently in-progress.
*
* Detection strategy: check the generation status transient first (authoritative
* for the active manual-run or daily-cron run), then fall back to recent gen_log
* run_ids that have no associated published/draft post yet.
*
* @return array<int, array<string, mixed>>
*/
public function get_generating_cards(): array {
global $wpdb;
$cards = array();
// Primary: check if the executor transient signals an active run.
$status = get_transient( self::STATUS_TRANSIENT );
if ( is_array( $status ) && 'running' === ( $status['status'] ?? '' ) ) {
$cards[] = array(
'run_id' => '',
'post_id' => 0,
'title' => $status['stage'] ?? __( 'Generating…', 'prautoblogger' ),
'cost_total' => 0.0,
'stage_current' => $status['stage'] ?? '',
'stage_count' => 0,
'started_at' => $status['started'] ?? 0,
'click_action' => 'logs',
'log_url' => admin_url( 'admin.php?page=prautoblogger-logs' ),
);
}
// Secondary: runs in gen_log that are recent and have no linked post.
$table = $wpdb->prefix . 'prautoblogger_generation_log';
$since = gmdate( 'Y-m-d H:i:s', time() - self::GENERATING_GRACE_SECONDS );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$raw_runs = $wpdb->get_results(
$wpdb->prepare(
"SELECT run_id,
MIN(created_at) as started_at,
MAX(stage) as last_stage,
COUNT(*) as stage_count,
SUM(CASE WHEN response_status = 'success' THEN estimated_cost ELSE 0 END) as cost_total
FROM {$table}
WHERE created_at >= %s
AND run_id IS NOT NULL
AND post_id IS NULL
GROUP BY run_id
ORDER BY started_at DESC
LIMIT %d",
$since,
(int) max( 5, get_option( 'prautoblogger_board_column_limit', PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT ) )
),
ARRAY_A
);
if ( ! is_array( $raw_runs ) ) {
return $cards;
}
foreach ( $raw_runs as $row ) {
$run_id = (string) $row['run_id'];
if ( '' === $run_id ) {
continue;
}
// Skip if we already have a transient card (avoid duplicate).
if ( ! empty( $cards ) && '' === $cards[0]['run_id'] ) {
// Only add secondary cards if we have a real run_id.
// The transient card already covers the active run.
}
$cards[] = array(
'run_id' => $run_id,
'post_id' => 0,
'title' => sprintf(
/* translators: %s: pipeline stage name */
__( 'Generating — %s', 'prautoblogger' ),
esc_html( ucfirst( (string) $row['last_stage'] ) )
),
'cost_total' => round( (float) $row['cost_total'], 6 ),
'stage_current' => (string) $row['last_stage'],
'stage_count' => (int) $row['stage_count'],
'started_at' => strtotime( (string) $row['started_at'] ),
'click_action' => 'logs',
'log_url' => admin_url( 'admin.php?page=prautoblogger-logs' ),
);
}
return $cards;
}
/**
* Cards for generation runs that ended in an error state.
*
* Looks for recent gen_log rows with response_status='error' that have
* no corresponding published post — these are pipeline failures.
*
* @return array<int, array<string, mixed>>
*/
public function get_failed_cards(): array {
global $wpdb;
$table = $wpdb->prefix . 'prautoblogger_generation_log';
$since = gmdate( 'Y-m-d H:i:s', time() - ( 7 * DAY_IN_SECONDS ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$raw = $wpdb->get_results(
$wpdb->prepare(
"SELECT run_id,
MIN(created_at) as started_at,
MAX(error_message) as last_error,
MAX(stage) as last_stage,
COUNT(*) as call_count
FROM {$table}
WHERE created_at >= %s
AND response_status = 'error'
AND run_id IS NOT NULL
AND post_id IS NULL
GROUP BY run_id
ORDER BY started_at DESC
LIMIT %d",
$since,
(int) max( 5, get_option( 'prautoblogger_board_column_limit', PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT ) )
),
ARRAY_A
);
if ( ! is_array( $raw ) ) {
return array();
}
$cards = array();
foreach ( $raw as $row ) {
$run_id = (string) $row['run_id'];
if ( '' === $run_id ) {
continue;
}
$cards[] = array(
'run_id' => $run_id,
'post_id' => 0,
'title' => sprintf(
/* translators: %s: pipeline stage name */
__( 'Failed — %s', 'prautoblogger' ),
esc_html( ucfirst( (string) $row['last_stage'] ) )
),
'error_message' => (string) $row['last_error'],
'started_at' => strtotime( (string) $row['started_at'] ),
'click_action' => 'logs',
'log_url' => admin_url( 'admin.php?page=prautoblogger-logs' ),
);
}
return $cards;
}
}

View File

@@ -0,0 +1,195 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Registers and renders the kanban board -- the primary landing screen for PRAutoBlogger.
*
* Board columns: Generating | In Review | Published | Failed.
* Cards live-update via AJAX polling at a settings-backed interval (default 5s),
* backing off to 2x when no active generating run is detected.
*
* Card click-throughs (M1):
* - Generating -> Activity Log filtered to run context
* - In Review -> Review Queue
* - Published -> Post edit screen
* - Failed -> Activity Log
*
* M2 will rewire all click-throughs to the Article Dossier page.
*
* Triggered by: PRAutoBlogger::register_admin_hooks() on `admin_menu`.
* Dependencies: PRAutoBlogger_Board_Data_Provider, wp_localize_script.
*
* @see admin/class-board-data-provider.php -- Supplies card data.
* @see templates/admin/board-page.php -- HTML template.
* @see assets/js/board.js -- Polling + DOM updates.
* @see ARCHITECTURE.md -- Section: Board (kanban dashboard).
*/
class PRAutoBlogger_Board_Page {
private const PAGE_SLUG = 'prautoblogger-board';
/** AJAX action name for board polling. */
public const AJAX_ACTION = 'prautoblogger_board_status';
/** Nonce action string for board AJAX. */
public const NONCE_ACTION = 'prautoblogger_board';
/**
* Register the Board as the PRIMARY (first) submenu item, overriding the
* implicit Settings landing page. The trick is: WordPress auto-generates a
* submenu duplicate of the top-level page; we name it "Board" so the
* first click goes to the board, not the raw settings page.
*
* Registration order note: this method is hooked at admin_menu priority 11
* so that PRAutoBlogger_Admin_Page::on_register_menu() (priority 10) has
* already called add_menu_page(). This ensures WordPress has populated
* $admin_page_hooks['prautoblogger-settings'] before add_submenu_page()
* runs here, so get_plugin_page_hookname() resolves to the correct
* 'prautoblogger_page_prautoblogger-board' suffix rather than the fallback
* 'admin_page_prautoblogger-board'. See ARCHITECTURE.md Section: Board.
*
* @return void
*/
public function on_register_menu(): void {
// Override the auto-created "PRAutoBlogger" submenu entry with "Board".
add_submenu_page(
'prautoblogger-settings',
__( 'PRAutoBlogger Board', 'prautoblogger' ),
__( 'Board', 'prautoblogger' ),
'manage_options',
self::PAGE_SLUG,
array( $this, 'render_page' )
);
// Reorder submenu so Board is the FIRST entry (primary click target).
// WordPress auto-appended a duplicate of the top-level page as submenu
// index 0; our Board was appended at index 1. We swap them so the
// top-level PRAutoBlogger click lands on the Board page, not Settings.
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- intentional submenu reorder.
global $submenu;
if ( isset( $submenu['prautoblogger-settings'] ) ) {
$items = $submenu['prautoblogger-settings'];
// Find the Board entry and move it to position 0.
$board_idx = null;
foreach ( $items as $i => $item ) {
if ( isset( $item[2] ) && self::PAGE_SLUG === $item[2] ) {
$board_idx = $i;
break;
}
}
if ( null !== $board_idx && $board_idx > 0 ) {
$board_item = $items[ $board_idx ];
unset( $items[ $board_idx ] );
// Prepend board item at position 0 while preserving remaining order.
$submenu['prautoblogger-settings'] = array_merge( array( $board_item ), array_values( $items ) );
}
}
}
/**
* Render the board page.
*
* @return void
*/
public function render_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/board-page.php';
}
/**
* Enqueue board-specific JS + CSS and localize the poller config.
*
* Called via `admin_enqueue_scripts` action, gated to this page's hook suffix.
*
* @param string $hook_suffix Current admin page hook suffix.
* @return void
*/
public function on_enqueue_assets( string $hook_suffix ): void {
$board_hook = 'prautoblogger_page_' . self::PAGE_SLUG;
if ( $hook_suffix !== $board_hook ) {
return;
}
wp_enqueue_style(
'prautoblogger-board',
PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/board.css',
array( 'prautoblogger-admin' ),
PRAUTOBLOGGER_VERSION
);
wp_enqueue_script(
'prautoblogger-board',
PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/board.js',
array( 'jquery' ),
PRAUTOBLOGGER_VERSION,
true
);
wp_enqueue_script(
'prautoblogger-board-generate',
PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/board-generate.js',
array( 'jquery', 'prautoblogger-board' ),
PRAUTOBLOGGER_VERSION,
true
);
$poll_interval = max( 3, (int) get_option( 'prautoblogger_board_poll_interval', 5 ) );
$published_days = max( 1, (int) get_option( 'prautoblogger_board_published_window_days', 7 ) );
wp_localize_script(
'prautoblogger-board',
'prabBoard',
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( self::NONCE_ACTION ),
'generateNonce' => wp_create_nonce( 'prautoblogger_generate_now' ),
'pollInterval' => $poll_interval * 1000,
'publishedWindowDays' => $published_days,
'action' => self::AJAX_ACTION,
'strings' => array(
'generating' => __( 'Generating', 'prautoblogger' ),
'inReview' => __( 'In Review', 'prautoblogger' ),
'published' => __( 'Published', 'prautoblogger' ),
'failed' => __( 'Failed', 'prautoblogger' ),
'empty' => __( 'Nothing here yet.', 'prautoblogger' ),
'cost' => __( 'Cost', 'prautoblogger' ),
'view' => __( 'View', 'prautoblogger' ),
'edit' => __( 'Edit', 'prautoblogger' ),
'viewLog' => __( 'View Log', 'prautoblogger' ),
'pollError' => __( 'Board update failed -- retrying.', 'prautoblogger' ),
'humanModified' => __( 'Human-modified', 'prautoblogger' ),
'newArticle' => __( 'New Article', 'prautoblogger' ),
'generatingBtn' => __( 'Generating...', 'prautoblogger' ),
'genStarted' => __( 'Generation started. Board will update automatically.', 'prautoblogger' ),
'genError' => __( 'Generation failed to start. Check the Activity Log.', 'prautoblogger' ),
),
)
);
}
/**
* AJAX handler: return the current board snapshot (all four columns).
*
* Nonce: `prautoblogger_board`. Requires `manage_options` capability.
*
* Side effects: DB reads only -- no writes.
*
* @return void
*/
public function on_ajax_board_status(): void {
check_ajax_referer( self::NONCE_ACTION, 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
return;
}
$provider = new PRAutoBlogger_Board_Data_Provider();
$snapshot = $provider->get_board_snapshot();
wp_send_json_success( $snapshot );
}
}

View File

@@ -0,0 +1,299 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* AJAX endpoints for the dossier's edit + re-run actions (v0.20.0, M3).
*
* What: Four manage_options + nonce gated endpoints. The mutation
* endpoints follow the chained-cron contract strictly: save_input
* only INSERTs an immutable fork version; the two rerun endpoints
* only validate + queue (Rerun_Job_Support::queue) — execution and
* ALL run/stage state mutations happen in the cron handler after
* re-validation under the generation lock. Run identity (run_id /
* item_key) is always derived server-side from the post's meta —
* never trusted from the client; the client only names the stage
* row it acted on, which is validated against an existing row.
* stage_status is the read-only poll the dossier JS uses to show
* queued -> pickup -> result.
* Who triggers it: assets/js/dossier-edit.js via admin-ajax.php;
* registered in class-prautoblogger.php.
* Dependencies: Rerun_Eligibility, Stage_Input_Store, Stage_Replay
* (decode), Rerun_Job_Support/Executor, Run_Stage_State.
*
* @see assets/js/dossier-edit.js — The frontend caller.
* @see core/class-rerun-executor.php — The cron handlers jobs land in.
* @see ARCHITECTURE.md #24 — Edit + re-run design.
*/
class PRAutoBlogger_Dossier_Actions {
/** Nonce action shared by all four endpoints. */
public const NONCE_ACTION = 'prautoblogger_dossier_actions';
/**
* AJAX: save an edited stage input as a new immutable fork version.
*
* The fork body is the stage's base request (latest fork, else the
* recorded request_json) with ONLY the message contents substituted —
* roles, message count, model and call options are not editable, so
* a fork is always a well-formed replayable chat body. Prompt text
* is stored raw (it is LLM input, never rendered unescaped).
*
* Side effects: one stage_inputs INSERT.
*
* @return void Sends JSON.
*/
public function on_save_input(): void {
$request = $this->authorize_and_resolve();
$check = PRAutoBlogger_Rerun_Eligibility::check( $request['run_id'], $request['post_id'] );
if ( ! PRAutoBlogger_Rerun_Eligibility::is_editable_stage( $request['stage'] ) || ! $check['ok'] ) {
$reason = $check['ok'] ? __( 'This stage cannot be replayed with an edited input.', 'prautoblogger' ) : $check['reason'];
wp_send_json_error( array( 'message' => $reason ), 400 );
return;
}
$base_body = $this->resolve_base_body( $request );
if ( null === $base_body ) {
wp_send_json_error( array( 'message' => __( 'No persisted input exists for this stage.', 'prautoblogger' ) ), 400 );
return;
}
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- raw prompt text for the LLM; structure-validated below, stored raw, escaped at render.
$raw = isset( $_POST['messages'] ) ? wp_unslash( (string) $_POST['messages'] ) : '';
$messages = json_decode( $raw, true );
$merged = $this->merge_messages( $base_body, is_array( $messages ) ? $messages : null );
if ( null === $merged ) {
wp_send_json_error( array( 'message' => __( 'Edited messages must match the original structure (same count and roles, non-empty contents).', 'prautoblogger' ) ), 400 );
return;
}
$user = wp_get_current_user();
$version = PRAutoBlogger_Stage_Input_Store::save_fork(
$request['run_id'],
$request['stage'],
$request['agent_role'],
$request['item_key'],
(string) wp_json_encode( $merged ),
(string) $user->user_login
);
if ( null === $version ) {
wp_send_json_error( array( 'message' => __( 'The edited input could not be saved.', 'prautoblogger' ) ), 500 );
return;
}
wp_send_json_success(
array(
'version' => $version,
'message' => sprintf(
/* translators: %d: fork version number. */
__( 'Saved as version %d. The original input is preserved unchanged; re-running this stage will use your edited version and mark all downstream stages stale.', 'prautoblogger' ),
$version
),
)
);
}
/**
* AJAX: queue a single-stage replay of the latest edited fork.
*
* Validates, then ONLY schedules — chained-cron semantics. The UI
* copy never implies synchronous execution.
*
* @return void Sends JSON.
*/
public function on_rerun_stage(): void {
$request = $this->authorize_and_resolve();
$check = PRAutoBlogger_Rerun_Eligibility::check_replay(
$request['run_id'],
$request['post_id'],
$request['stage'],
$request['agent_role'],
$request['item_key']
);
if ( ! $check['ok'] ) {
wp_send_json_error( array( 'message' => $check['reason'] ), 400 );
return;
}
PRAutoBlogger_Rerun_Job_Support::queue(
PRAutoBlogger_Rerun_Executor::REPLAY_ACTION,
array( $request['run_id'], $request['post_id'], $request['stage'], $request['agent_role'], $request['item_key'] ),
sprintf(
/* translators: %s: stage display label. */
__( 'Queued: re-running %s with the edited input…', 'prautoblogger' ),
PRAutoBlogger_Stage_Display_Map::label( $request['stage'] )
)
);
wp_send_json_success(
array(
'queued' => true,
'message' => __( 'Re-run queued. The pipeline will pick it up shortly — progress appears here and on the board.', 'prautoblogger' ),
)
);
}
/**
* AJAX: queue re-run-from-here (rebuild this stage + downstream).
*
* @return void Sends JSON.
*/
public function on_rerun_from(): void {
$request = $this->authorize_and_resolve();
$check = PRAutoBlogger_Rerun_Eligibility::check_rebuild(
$request['run_id'],
$request['post_id'],
$request['stage'],
$request['item_key']
);
if ( ! $check['ok'] ) {
wp_send_json_error( array( 'message' => $check['reason'] ), 400 );
return;
}
PRAutoBlogger_Rerun_Job_Support::queue(
PRAutoBlogger_Rerun_Executor::REBUILD_ACTION,
array( $request['run_id'], $request['post_id'], $request['stage'], $request['item_key'] ),
sprintf(
/* translators: %s: stage display label. */
__( 'Queued: re-running the pipeline from %s…', 'prautoblogger' ),
PRAutoBlogger_Stage_Display_Map::label( $request['stage'] )
)
);
wp_send_json_success(
array(
'queued' => true,
'message' => __( 'Re-run from here queued. Stages will rebuild in sequence — progress appears here and on the board.', 'prautoblogger' ),
)
);
}
/**
* AJAX: read-only stage-status poll for the dossier.
*
* @return void Sends JSON: run status, per-stage state, live message.
*/
public function on_stage_status(): void {
$request = $this->authorize_and_resolve( false );
$stages = array();
foreach ( PRAutoBlogger_Run_Stage_State::stages_for_item( $request['run_id'], $request['item_key'] ) as $row ) {
$stages[] = array(
'stage' => (string) ( $row['stage'] ?? '' ),
'agent_role' => (string) ( $row['agent_role'] ?? '' ),
'status' => (string) ( $row['status'] ?? '' ),
'stale' => ! empty( $row['stale'] ),
'human_modified' => ! empty( $row['human_modified'] ),
'attempt' => (int) ( $row['attempt'] ?? 1 ),
'finished_at' => (string) ( $row['finished_at'] ?? '' ),
);
}
$live = get_transient( PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT );
wp_send_json_success(
array(
'run_status' => (string) ( PRAutoBlogger_Run_State::get_status( $request['run_id'] ) ?? '' ),
'stages' => $stages,
'live' => is_array( $live ) ? $live : null,
)
);
}
/**
* Shared gate: nonce + capability, then derive run identity from the
* POSTed post_id server-side. Sends a JSON error (and exits) on any
* failure.
*
* @param bool $require_stage Whether the request must name a stage row.
* @return array{post_id: int, run_id: string, item_key: string, stage: string, agent_role: string}
*/
private function authorize_and_resolve( bool $require_stage = true ): array {
check_ajax_referer( self::NONCE_ACTION, 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
}
$post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0;
$run_id = $post_id > 0 ? (string) get_post_meta( $post_id, '_prautoblogger_run_id', true ) : '';
if ( 0 === $post_id || '' === $run_id ) {
wp_send_json_error( array( 'message' => __( 'No generation run is recorded for this article.', 'prautoblogger' ) ), 400 );
}
$idea_hash = (string) get_post_meta( $post_id, '_prautoblogger_idea_hash', true );
$item_key = '' !== $idea_hash ? 'idea:' . $idea_hash : '';
$stage = isset( $_POST['stage'] ) ? sanitize_key( (string) wp_unslash( $_POST['stage'] ) ) : '';
$agent_role = isset( $_POST['agent_role'] ) ? sanitize_key( (string) wp_unslash( $_POST['agent_role'] ) ) : '';
if ( $require_stage && '' === $stage ) {
wp_send_json_error( array( 'message' => __( 'No stage specified.', 'prautoblogger' ) ), 400 );
}
return array(
'post_id' => $post_id,
'run_id' => $run_id,
'item_key' => $item_key,
'stage' => $stage,
'agent_role' => $agent_role,
);
}
/**
* The base body an edit forks from: latest saved fork, else the
* stage's recorded request_json.
*
* @param array<string, mixed> $request Resolved request context.
* @return array<string, mixed>|null Decoded body, or null when absent.
*/
private function resolve_base_body( array $request ): ?array {
$fork = PRAutoBlogger_Stage_Input_Store::latest_fork( $request['run_id'], $request['stage'], $request['agent_role'], $request['item_key'] );
if ( null !== $fork && ! empty( $fork['request_json'] ) ) {
return PRAutoBlogger_Stage_Replay::decode_body( (string) $fork['request_json'] );
}
$log_rows = ( new PRAutoBlogger_Dossier_Gen_Log_Query() )->get_by_run( $request['run_id'] );
$rows = $log_rows[ $request['stage'] ] ?? array();
foreach ( array_reverse( $rows ) as $row ) {
if ( ! empty( $row['request_json'] ) ) {
$body = PRAutoBlogger_Stage_Replay::decode_body( (string) $row['request_json'] );
if ( null !== $body ) {
return $body;
}
}
}
return null;
}
/**
* Substitute edited message contents into the base body. Structure
* is locked: same message count, same roles, all contents non-empty
* strings — so every fork stays a replayable chat body.
*
* @param array<string, mixed> $base Decoded base body.
* @param array<int, mixed>|null $messages Edited messages payload.
* @return array<string, mixed>|null Merged body, or null when invalid.
*/
private function merge_messages( array $base, ?array $messages ): ?array {
if ( null === $messages || count( $messages ) !== count( $base['messages'] ) ) {
return null;
}
$merged = $base;
foreach ( array_values( $messages ) as $i => $message ) {
if ( ! is_array( $message ) || ! isset( $message['role'], $message['content'] )
|| ! is_string( $message['role'] ) || ! is_string( $message['content'] ) ) {
return null;
}
if ( ( $base['messages'][ $i ]['role'] ?? null ) !== $message['role'] ) {
return null;
}
if ( '' === trim( $message['content'] ) ) {
return null;
}
$merged['messages'][ $i ]['content'] = $message['content'];
}
return $merged;
}
}

View File

@@ -0,0 +1,275 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Assembles the view model for the Article Dossier page.
*
* Orchestrates 5 queries (all indexed by run_id or post_id):
* 1. wp_prautoblogger_runs -- run ledger row
* 2. wp_prautoblogger_run_stages -- all stage rows for the run
* 3. wp_prautoblogger_generation_log -- per-stage cost + raw trace
* 4. wp_prautoblogger_run_decisions -- rationale per stage
* 5. post meta (WP object cache) -- run_id + verdict + keywords
*
* Legacy posts (no run row) return ['has_run' => false] -- never notices.
* Amortized research rows (pv=null, role='') render gracefully.
*
* v0.20.0 (M3): stage rows are filtered to THIS post's item (item_key
* from the idea-hash meta; multi-article runs no longer collide) and
* gen_log rows exclude entries linked to OTHER posts; per-stage edit/
* re-run affordance data + run spend ride the view model
* (Dossier_Rerun_Panel_Data); log-only stages (image_a/image_b/
* llm_research/... -- generation_log rows with no run_stages row) and
* the post's pipeline attachments are exposed so the image sections
* render from their REAL data sources (QA M2 F3).
*
* Triggered by: PRAutoBlogger_Dossier_Page::render_page().
* Dependencies: PRAutoBlogger_Dossier_Gen_Log_Query, PRAutoBlogger_Stage_Display_Map,
* PRAutoBlogger_Run_Stage_State.
*
* @see admin/class-dossier-gen-log-query.php -- Raw generation_log queries.
* @see core/class-run-stage-state.php -- Stage I/O access.
* @see ARCHITECTURE.md -- Substrate schema.
*/
class PRAutoBlogger_Dossier_Data_Assembler {
/** @var PRAutoBlogger_Dossier_Gen_Log_Query */
private PRAutoBlogger_Dossier_Gen_Log_Query $log_query;
/**
* @param PRAutoBlogger_Dossier_Gen_Log_Query|null $log_query Injectable for tests.
*/
public function __construct( ?PRAutoBlogger_Dossier_Gen_Log_Query $log_query = null ) {
$this->log_query = $log_query ?? new PRAutoBlogger_Dossier_Gen_Log_Query();
}
/**
* Assemble the full dossier view model for a post.
*
* @param int $post_id WordPress post ID (0 = bogus/not found).
* @return array<string, mixed> View model consumed by dossier-page.php.
*/
public function assemble( int $post_id ): array {
$base = array(
'post_id' => $post_id,
'has_run' => false,
'post_title' => '',
'post_status' => '',
'verdict' => '',
'tier' => '',
'run_id' => '',
'run' => null,
'stages' => array(),
'decisions' => array(),
'gen_log_index' => array(),
'item_key' => '',
'eligibility' => array(
'ok' => false,
'reason' => '',
),
'spend' => PRAutoBlogger_Dossier_Rerun_Panel_Data::spend( null ),
'human_modified_any' => false,
'stale_any' => false,
'log_only_stages' => array(),
'images' => array(),
);
if ( $post_id <= 0 ) {
return $base;
}
$post = get_post( $post_id );
if ( ! ( $post instanceof WP_Post ) ) {
return $base;
}
$base['post_title'] = $post->post_title;
$base['post_status'] = $post->post_status;
$base['verdict'] = (string) get_post_meta( $post_id, '_prautoblogger_editor_verdict', true );
$base['tier'] = (string) get_post_meta( $post_id, '_prautoblogger_article_type', true );
$run_id = (string) get_post_meta( $post_id, '_prautoblogger_run_id', true );
if ( '' === $run_id ) {
return $base;
}
$base['run_id'] = $run_id;
$run = $this->get_run_row( $run_id );
if ( null === $run ) {
return $base;
}
$idea_hash = (string) get_post_meta( $post_id, '_prautoblogger_idea_hash', true );
$item_key = '' !== $idea_hash ? 'idea:' . $idea_hash : '';
$base['item_key'] = $item_key;
$base['has_run'] = true;
$base['run'] = $run;
$base['stages'] = $this->get_stage_rows( $run_id, $item_key );
$base['decisions'] = $this->get_decision_rows( $run_id );
$base['gen_log_index'] = $this->filter_log_for_post( $this->log_query->get_by_run( $run_id ), $post_id );
$base['eligibility'] = PRAutoBlogger_Rerun_Eligibility::check( $run_id, $post_id );
$base['spend'] = PRAutoBlogger_Dossier_Rerun_Panel_Data::spend( $run );
foreach ( $base['stages'] as $role_key => $stage_row ) {
$stage_name = (string) ( $stage_row['stage'] ?? '' );
$rerun = PRAutoBlogger_Dossier_Rerun_Panel_Data::for_stage(
$stage_row,
$base['gen_log_index'][ $stage_name ] ?? array(),
$run_id,
$base['eligibility']
);
$base['stages'][ $role_key ]['rerun'] = $rerun;
$base['human_modified_any'] = $base['human_modified_any'] || $rerun['human_modified'];
$base['stale_any'] = $base['stale_any'] || $rerun['stale'];
}
// QA M2 F3: stages that exist only in generation_log (image_a/
// image_b, llm_research, image_prompt_rewrite, ...) render from
// their real source instead of silently disappearing.
$run_stage_names = array();
foreach ( $base['stages'] as $stage_row ) {
$run_stage_names[ (string) ( $stage_row['stage'] ?? '' ) ] = true;
}
$base['log_only_stages'] = array_values( array_diff( array_keys( $base['gen_log_index'] ), array_keys( $run_stage_names ) ) );
$base['images'] = PRAutoBlogger_Dossier_Image_Data::for_post( $post_id );
return $base;
}
/**
* Fetch the run ledger row.
*
* @param string $run_id Pipeline run UUID.
* @return array<string, mixed>|null
*/
private function get_run_row( string $run_id ): ?array {
global $wpdb;
if ( null === $wpdb ) {
return null;
}
$table = $wpdb->prefix . 'prautoblogger_runs';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$row = $wpdb->get_row(
$wpdb->prepare( "SELECT * FROM {$table} WHERE run_id = %s", $run_id ),
ARRAY_A
);
return is_array( $row ) ? $row : null;
}
/**
* Fetch run_stages rows for a run, indexed by stage name — scoped to
* one item (+ run-level rows) when an item key is known.
*
* @param string $run_id Pipeline run UUID.
* @param string $item_key Item key ('' = no scoping, M2 behavior).
* @return array<string, array<string, mixed>> Stage rows keyed by stage name.
*/
private function get_stage_rows( string $run_id, string $item_key = '' ): array {
if ( ! PRAutoBlogger_Run_Stage_State::is_available() ) {
return array();
}
global $wpdb;
if ( null === $wpdb ) {
return array();
}
$table = PRAutoBlogger_Run_Stage_State::table_name();
// v0.20.0: scope to THIS post's item + run-level rows. When the
// idea-hash meta is absent (pre-v0.18 posts) fall back to the
// unfiltered M2 behavior (single-item runs are unaffected).
if ( '' !== $item_key ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
$wpdb->prepare( "SELECT * FROM {$table} WHERE run_id = %s AND item_key IN (%s, '') ORDER BY id ASC", $run_id, $item_key ),
ARRAY_A
);
} else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
$wpdb->prepare( "SELECT * FROM {$table} WHERE run_id = %s ORDER BY id ASC", $run_id ),
ARRAY_A
);
}
$index = array();
foreach ( ( $rows ?? array() ) as $row ) {
if ( ! is_array( $row ) ) {
continue;
}
$stage = (string) ( $row['stage'] ?? '' );
$role_key = $stage . ':' . ( $row['agent_role'] ?? '' );
$row['display_output'] = $this->extract_output( $row );
$index[ $role_key ] = $row;
}
return $index;
}
/**
* Drop gen_log rows linked to OTHER posts (multi-article runs share
* one run_id). Rows with no post linkage (NULL/0 — run-level work
* and pre-link entries) are kept.
*
* @param array<string, array<int, array<string, mixed>>> $log_index Rows keyed by stage.
* @param int $post_id This dossier's post.
* @return array<string, array<int, array<string, mixed>>>
*/
private function filter_log_for_post( array $log_index, int $post_id ): array {
$filtered = array();
foreach ( $log_index as $stage => $rows ) {
foreach ( $rows as $row ) {
$row_post = (int) ( $row['post_id'] ?? 0 );
if ( 0 !== $row_post && $row_post !== $post_id ) {
continue;
}
$filtered[ $stage ][] = $row;
}
}
return $filtered;
}
/**
* Extract the text output from a run_stages meta_json blob.
* Returns null when meta_json is NULL (pruned) or absent.
*
* @param array<string, mixed> $row Stage row.
* @return string|null
*/
private function extract_output( array $row ): ?string {
if ( empty( $row['meta_json'] ) ) {
return null;
}
$meta = json_decode( (string) $row['meta_json'], true );
return ( is_array( $meta ) && isset( $meta['output'] ) ) ? (string) $meta['output'] : null;
}
/**
* Fetch all run_decisions rows, indexed by stage name.
*
* @param string $run_id Pipeline run UUID.
* @return array<string, array<string, mixed>> Decision rows keyed by stage.
*/
private function get_decision_rows( string $run_id ): array {
global $wpdb;
if ( null === $wpdb ) {
return array();
}
$table = $wpdb->prefix . 'prautoblogger_run_decisions';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
$wpdb->prepare( "SELECT * FROM {$table} WHERE run_id = %s ORDER BY id ASC", $run_id ),
ARRAY_A
);
$index = array();
foreach ( ( $rows ?? array() ) as $row ) {
if ( ! is_array( $row ) ) {
continue;
}
$stage = (string) ( $row['stage'] ?? 'unknown' );
$index[ $stage ] = $row;
}
return $index;
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Raw generation_log queries for the Article Dossier (M2).
*
* Returns per-stage generation log data indexed by stage name: model,
* prompt_version, agent_role, token counts, estimated cost, request_json
* (raw trace), and response_status. Amortized research rows (pv=null,
* role='') are included and rendered gracefully by the template.
*
* Triggered by: PRAutoBlogger_Dossier_Data_Assembler::assemble().
* Dependencies: WordPress $wpdb.
*
* @see admin/class-dossier-data-assembler.php -- Calls get_by_run().
* @see ARCHITECTURE.md -- generation_log schema.
*/
class PRAutoBlogger_Dossier_Gen_Log_Query {
/**
* Fetch all generation_log rows for a run, indexed by stage.
*
* Multiple rows per stage (e.g. multi-step draft) are accumulated as an
* array so the template can render multiple raw-trace entries per section.
*
* SECURITY: request_json is stored without API keys (never logged per
* architecture). The template must escape this via esc_html() before render.
*
* @param string $run_id Pipeline run UUID.
* @return array<string, array<int, array<string, mixed>>> Rows keyed by stage.
*/
public function get_by_run( string $run_id ): array {
global $wpdb;
if ( null === $wpdb || '' === $run_id ) {
return array();
}
$table = $wpdb->prefix . 'prautoblogger_generation_log';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT id, stage, model, agent_role, prompt_version,
prompt_tokens, completion_tokens, estimated_cost,
request_json, response_status, error_message, created_at
FROM {$table}
WHERE run_id = %s
ORDER BY id ASC",
$run_id
),
ARRAY_A
);
$index = array();
foreach ( ( $rows ?? array() ) as $row ) {
if ( ! is_array( $row ) ) {
continue;
}
$stage = (string) ( $row['stage'] ?? 'unknown' );
if ( ! isset( $index[ $stage ] ) ) {
$index[ $stage ] = array();
}
$index[ $stage ][] = $row;
}
return $index;
}
/**
* Aggregate cost + token totals per stage for the sidebar receipt.
*
* @param array<string, array<int, array<string, mixed>>> $log_index Output of get_by_run().
* @return array<string, array{cost: float, prompt_tokens: int, completion_tokens: int, calls: int}>
*/
public function aggregate_per_stage( array $log_index ): array {
$totals = array();
foreach ( $log_index as $stage => $rows ) {
$cost = 0.0;
$prompt_tokens = 0;
$completion_tokens = 0;
foreach ( $rows as $row ) {
$cost += (float) ( $row['estimated_cost'] ?? 0 );
$prompt_tokens += (int) ( $row['prompt_tokens'] ?? 0 );
$completion_tokens += (int) ( $row['completion_tokens'] ?? 0 );
}
$totals[ $stage ] = array(
'cost' => $cost,
'prompt_tokens' => $prompt_tokens,
'completion_tokens' => $completion_tokens,
'calls' => count( $rows ),
);
}
return $totals;
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Resolves a post's pipeline image attachments for the dossier (M3/F3).
*
* What: The image sections' REAL data source — the attachments the
* image pipeline produced (featured + composed og/square variants,
* discovered via the `_prautoblogger_image_role` attachment meta
* and the post thumbnail), paired in the template with the
* image_a/image_b generation_log rows. Replaces the M2 sections
* that keyed off run_stages rows the image pipeline never writes
* (QA M2 F3 — wiring the dossier to real data was the smaller,
* safer change vs. teaching the live image pipeline to write
* run_stages; the pipeline restructure is Phase 2b).
* Who triggers it: PRAutoBlogger_Dossier_Data_Assembler::assemble().
* Dependencies: get_children(), attachment meta, wp_get_attachment_image_url().
*
* @see admin/class-dossier-data-assembler.php — Caller.
* @see templates/admin/dossier-log-stage-section.php — Renders the pairs.
* @see ARCHITECTURE.md #24 — M3 design (F3 note).
*/
class PRAutoBlogger_Dossier_Image_Data {
/**
* The post's pipeline attachments (featured/og/square) via the
* `_prautoblogger_image_role` attachment meta — the image sections'
* real data source (QA M2 F3).
*
* @param int $post_id Post ID.
* @return array<int, array{id: int, role: string, url: string, is_featured: bool}>
*/
public static function for_post( int $post_id ): array {
$featured_id = (int) get_post_thumbnail_id( $post_id );
$children = get_children(
array(
'post_parent' => $post_id,
'post_type' => 'attachment',
'numberposts' => 12,
)
);
$images = array();
$seen = array();
foreach ( (array) $children as $attachment ) {
$att_id = (int) ( $attachment->ID ?? 0 );
if ( $att_id <= 0 ) {
continue;
}
$role = (string) get_post_meta( $att_id, '_prautoblogger_image_role', true );
if ( '' === $role && $att_id !== $featured_id ) {
continue; // Not a pipeline attachment.
}
$url = (string) wp_get_attachment_image_url( $att_id, 'medium' );
if ( '' === $url ) {
continue;
}
$seen[ $att_id ] = true;
$images[] = array(
'id' => $att_id,
'role' => '' !== $role ? $role : 'featured',
'url' => $url,
'is_featured' => $att_id === $featured_id,
);
}
// Featured image attached elsewhere (not a child) still renders.
if ( $featured_id > 0 && ! isset( $seen[ $featured_id ] ) ) {
$url = (string) wp_get_attachment_image_url( $featured_id, 'medium' );
if ( '' !== $url ) {
$images[] = array(
'id' => $featured_id,
'role' => 'featured',
'url' => $url,
'is_featured' => true,
);
}
}
return $images;
}
}

View File

@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Registers and renders the Article Dossier admin page (M2).
*
* The dossier is link-accessed -- it is NOT a visible submenu item. It uses
* the canonical hidden-admin-page pattern: register under parent 'options.php'
* so WordPress computes the hookname as `admin_page_prautoblogger-dossier` at
* BOTH registration time AND request time. The page is hidden from nav by
* construction (options.php is a built-in WP page with no visible submenu block)
* so no $submenu mutation is needed or permitted.
*
* Why NOT the hide-by-unset pattern (the v0.19.2 bug):
* add_submenu_page('prautoblogger-settings', ...) → hookname 'prautoblogger_page_prautoblogger-dossier'
* post-registration unset($submenu['prautoblogger-settings'][$idx])
* → at request time get_admin_page_parent() no longer finds the slug in $submenu
* → WP recomputes hookname in the 'admin_page_*' orphan namespace
* → no callback registered there → wp_die(403).
* See ARCHITECTURE.md §22b + CONVENTIONS.md §Hidden Admin Pages for the full incident
* history (board 404 v0.19.1, dossier 403 v0.19.2).
*
* Board cards and post-metabox deep-link here via
* admin.php?page=prautoblogger-dossier&post_id=X. Deep-link URLs are parent-agnostic
* (they reference only the page slug) so they are unchanged by this fix.
*
* Design contract: Proposal C "Editorial Record" -- warm editorial dossier,
* verdict pills, receipt-style cost sidebar, per-stage I/O with raw-trace toggle.
*
* Triggered by: PRAutoBlogger::register_admin_hooks() on `admin_menu` (priority 12).
* Dependencies: PRAutoBlogger_Dossier_Data_Assembler, templates/admin/dossier-page.php.
*
* @see admin/class-dossier-data-assembler.php -- Builds the view model.
* @see templates/admin/dossier-page.php -- HTML template.
* @see ARCHITECTURE.md -- §22b (hidden admin page convention).
* @see CONVENTIONS.md -- §Hidden Admin Pages.
*/
class PRAutoBlogger_Dossier_Page {
/** Admin page slug. */
public const PAGE_SLUG = 'prautoblogger-dossier';
/**
* Parent slug used at registration.
* 'options.php' is the canonical WP hidden-page parent: the hookname resolves
* to `admin_page_{slug}` at both registration time and request time, so there is
* no hookname mismatch and no need to manipulate $submenu.
*/
public const PARENT_SLUG = 'options.php';
/** Nonce action for any future stateful actions. */
public const NONCE_ACTION = 'prautoblogger_dossier';
/**
* Register the dossier as a hidden admin page.
*
* Uses the canonical options.php-parent pattern so the hookname is stable at
* both registration time and request time. No $submenu manipulation required
* or permitted (see CONVENTIONS.md §Hidden Admin Pages).
*
* @return void
*/
public function on_register_menu(): void {
add_submenu_page(
self::PARENT_SLUG,
__( 'Article Dossier', 'prautoblogger' ),
__( 'Dossier', 'prautoblogger' ),
'manage_options',
self::PAGE_SLUG,
array( $this, 'render_page' )
);
// No $submenu mutation. options.php-parent pages are hidden by construction.
}
/**
* Render the dossier page.
*
* @return void
*/
public function render_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only dossier; no state changes.
$post_id = isset( $_GET['post_id'] ) ? absint( $_GET['post_id'] ) : 0;
$assembler = new PRAutoBlogger_Dossier_Data_Assembler();
$dossier = $assembler->assemble( $post_id );
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/dossier-page.php';
}
/**
* Enqueue dossier-specific CSS + JS.
*
* The hook suffix for an options.php-parent page is `admin_page_{slug}`.
*
* @param string $hook_suffix Current admin page hook suffix.
* @return void
*/
public function on_enqueue_assets( string $hook_suffix ): void {
// options.php-parent pages resolve to 'admin_page_{slug}' (not 'prautoblogger_page_*').
$dossier_hook = 'admin_page_' . self::PAGE_SLUG;
if ( $hook_suffix !== $dossier_hook ) {
return;
}
wp_enqueue_style(
'prautoblogger-dossier',
PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/dossier.css',
array( 'prautoblogger-admin' ),
PRAUTOBLOGGER_VERSION
);
wp_enqueue_script(
'prautoblogger-dossier',
PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/dossier.js',
array(),
PRAUTOBLOGGER_VERSION,
true
);
// M3: edit + re-run layer (separate files; dossier.css is not grown).
wp_enqueue_style(
'prautoblogger-dossier-edit',
PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/dossier-edit.css',
array( 'prautoblogger-dossier' ),
PRAUTOBLOGGER_VERSION
);
wp_enqueue_script(
'prautoblogger-dossier-edit',
PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/dossier-edit.js',
array(),
PRAUTOBLOGGER_VERSION,
true
);
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only context resolution for script config.
$post_id = isset( $_GET['post_id'] ) ? absint( $_GET['post_id'] ) : 0;
wp_localize_script(
'prautoblogger-dossier-edit',
'prabDossierEdit',
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( PRAutoBlogger_Dossier_Actions::NONCE_ACTION ),
'postId' => $post_id,
// Settings read at point of use -- same poll setting as the board.
'pollInterval' => max( 3, (int) get_option( 'prautoblogger_board_poll_interval', 5 ) ),
'strings' => array(
'saving' => __( 'Saving…', 'prautoblogger' ),
'error' => __( 'Request failed — try again.', 'prautoblogger' ),
'queued' => __( 'Queued…', 'prautoblogger' ),
'rerunFromLabel' => __( 'Re-run from here', 'prautoblogger' ),
'confirmReplay' => __( 'Queue a re-run of this stage with your edited input? This makes a new LLM call billed against this run\'s ceiling, and all downstream stages will be marked stale (nothing re-runs automatically).', 'prautoblogger' ),
'confirmRerunFrom' => __( 'Queue a re-run of this stage AND every stage after it? Each re-run stage makes new LLM calls billed against this run\'s ceiling. The article draft will be refreshed; published posts are never touched.', 'prautoblogger' ),
),
)
);
}
/**
* Build the URL to the dossier for a given post.
*
* Deep-link URLs use admin.php?page=<slug> which is parent-agnostic.
* This method is unchanged from v0.19.2 -- only the registration parent changed.
*
* @param int $post_id WordPress post ID.
* @return string Admin URL to the dossier page.
*/
public static function url_for_post( int $post_id ): string {
return admin_url( 'admin.php?page=' . self::PAGE_SLUG . '&post_id=' . $post_id );
}
}

View File

@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Builds the edit + re-run view-model layer for the dossier (v0.20.0, M3).
*
* What: Per-stage affordance data (editable? why not? latest fork,
* prefill messages from the fork or the stage's recorded
* request_json) plus the run-level spend strip (settled + reserved
* vs ceiling, warn fraction — guardrail 4's visible cost
* implication) and the overall eligibility verdict. Pure
* assembly: reads the rows the Dossier_Data_Assembler already
* fetched plus the stage_inputs store; performs NO mutations.
* Disabled affordances always carry an operator-readable reason —
* the UI never hides why an edit is unavailable.
* Who triggers it: PRAutoBlogger_Dossier_Data_Assembler::assemble().
* Dependencies: Rerun_Eligibility (policy), Stage_Input_Store (forks),
* Stage_Replay (body decode).
*
* @see admin/class-dossier-data-assembler.php — Caller.
* @see templates/admin/dossier-edit-panel.php — Renders this data.
* @see ARCHITECTURE.md #24 — Edit + re-run design.
*/
class PRAutoBlogger_Dossier_Rerun_Panel_Data {
/** Default warn threshold as a fraction of the per-run ceiling. */
private const DEFAULT_WARN_FRACTION = 0.8;
/**
* Run-level spend strip data (guardrail 4 visibility).
*
* @param array<string, mixed>|null $run Run ledger row, or null.
* @return array{settled: float, reserved: float, ceiling: float, fraction: float, warn: bool}
*/
public static function spend( ?array $run ): array {
$settled = null !== $run ? (float) ( $run['settled_usd'] ?? 0 ) : 0.0;
$reserved = null !== $run ? (float) ( $run['reserved_usd'] ?? 0 ) : 0.0;
$ceiling = null !== $run ? (float) ( $run['ceiling_usd'] ?? 0 ) : 0.0;
$fraction = $ceiling > 0 ? ( $settled + $reserved ) / $ceiling : 0.0;
/**
* Filters the spend fraction at which re-run confirms warn about
* approaching the per-run ceiling.
*
* @param float $warn_fraction Fraction of ceiling (default 0.8).
*/
$warn_fraction = (float) apply_filters( 'prautoblogger_rerun_ceiling_warn_fraction', self::DEFAULT_WARN_FRACTION );
return array(
'settled' => $settled,
'reserved' => $reserved,
'ceiling' => $ceiling,
'fraction' => $fraction,
'warn' => $ceiling > 0 && $fraction >= $warn_fraction,
);
}
/**
* Edit + re-run affordance data for one stage row.
*
* @param array<string, mixed> $stage_row run_stages row.
* @param array<int, array<string, mixed>> $log_rows generation_log rows for the stage.
* @param string $run_id Run UUID.
* @param array{ok: bool, reason: string} $eligibility Overall run eligibility.
* @return array<string, mixed> Affordance view model (see keys below).
*/
public static function for_stage( array $stage_row, array $log_rows, string $run_id, array $eligibility ): array {
$stage = (string) ( $stage_row['stage'] ?? '' );
$role = (string) ( $stage_row['agent_role'] ?? '' );
$item_key = (string) ( $stage_row['item_key'] ?? '' );
$data = array(
'stale' => ! empty( $stage_row['stale'] ),
'human_modified' => ! empty( $stage_row['human_modified'] ),
'attempt' => (int) ( $stage_row['attempt'] ?? 1 ),
'editable' => false,
'edit_reason' => '',
'fork' => null,
'prefill' => null,
'rerun_from' => $eligibility['ok'] && PRAutoBlogger_Rerun_Eligibility::is_chain_stage( $stage ),
);
if ( ! PRAutoBlogger_Rerun_Eligibility::is_editable_stage( $stage ) ) {
$data['edit_reason'] = self::policy_reason( $stage );
return $data;
}
if ( ! $eligibility['ok'] ) {
$data['edit_reason'] = $eligibility['reason'];
return $data;
}
$fork = PRAutoBlogger_Stage_Input_Store::latest_fork( $run_id, $stage, $role, $item_key );
if ( null !== $fork ) {
$data['fork'] = array(
'version' => (int) $fork['version'],
'author' => (string) $fork['author'],
'created_at' => (string) $fork['created_at'],
'has_body' => ! empty( $fork['request_json'] ),
);
}
$base_json = ( null !== $fork && ! empty( $fork['request_json'] ) )
? (string) $fork['request_json']
: self::latest_request_json( $log_rows );
if ( null === $base_json ) {
$days = (int) get_option( 'prautoblogger_request_json_retention_days', PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS );
$data['edit_reason'] = sprintf(
/* translators: %d: retention days. */
__( 'No persisted input for this stage — request bodies are recorded from v0.20.0 onward and retained for %d days.', 'prautoblogger' ),
$days
);
return $data;
}
$body = PRAutoBlogger_Stage_Replay::decode_body( $base_json );
if ( null === $body ) {
$data['edit_reason'] = __( 'The persisted input for this stage is not a replayable chat request.', 'prautoblogger' );
return $data;
}
$data['editable'] = true;
$data['prefill'] = array(
'model' => (string) $body['model'],
'messages' => $body['messages'],
);
return $data;
}
/**
* Why a non-writer stage has no edit affordance (always shown —
* the UI never silently hides the option).
*
* @param string $stage Stage name.
* @return string Operator-readable reason.
*/
private static function policy_reason( string $stage ): string {
if ( 'review' === $stage ) {
return __( 'The review input is derived entirely from the article content — edit a writing stage, then use "Re-run from here" on this one.', 'prautoblogger' );
}
if ( 'publish' === $stage ) {
return __( 'Publish is not an LLM stage — use "Re-run from here" to refresh the post from current outputs.', 'prautoblogger' );
}
if ( in_array( $stage, array( 'analysis', 'research', 'llm_research' ), true ) ) {
return __( 'Run-level stage: its output fed idea selection for the whole run and cannot be re-flowed into a single article.', 'prautoblogger' );
}
if ( 0 === strpos( $stage, 'image' ) ) {
return __( 'Image stages are not replayable yet (Phase 2b restructure).', 'prautoblogger' );
}
return __( 'This stage cannot be replayed with an edited input.', 'prautoblogger' );
}
/**
* The most recent recorded request body among a stage's log rows
* (success rows preferred, then latest id).
*
* @param array<int, array<string, mixed>> $log_rows generation_log rows.
* @return string|null
*/
private static function latest_request_json( array $log_rows ): ?string {
$best = null;
foreach ( $log_rows as $row ) {
if ( empty( $row['request_json'] ) ) {
continue;
}
if ( null === $best ) {
$best = $row;
continue;
}
$row_success = ( 'success' === ( $row['response_status'] ?? '' ) );
$best_success = ( 'success' === ( $best['response_status'] ?? '' ) );
if ( ( $row_success && ! $best_success )
|| ( $row_success === $best_success && (int) ( $row['id'] ?? 0 ) > (int) ( $best['id'] ?? 0 ) ) ) {
$best = $row;
}
}
return null !== $best ? (string) $best['request_json'] : null;
}
}

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Query helpers for PRAutoBlogger_Ideas_Browser.
*
* Extracted to keep class-ideas-browser.php under 300 lines (CONVENTIONS §1).
* All methods are static; class is never instantiated.
*/
class PRAutoBlogger_Ideas_Browser_Query {
/** Fall-through if setting is absent. */
public const PER_PAGE_DEFAULT = 30;
/** Transient key prefix for per-idea generation status. */
public const STATUS_PREFIX = 'prab_idea_gen_';
/** How long to keep per-idea status (seconds). */
public const STATUS_TTL = 600;
/** Query analysis results with optional filtering and pagination. */
public static function query_ideas( int $paged, string $type ): array {
global $wpdb;
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
$where = array();
$params = array();
if ( '' !== $type ) {
$where[] = 'analysis_type = %s';
$params[] = $type;
}
$where_sql = empty( $where ) ? '' : 'WHERE ' . implode( ' AND ', $where );
$per_page = max( 5, (int) get_option( 'prautoblogger_ideas_per_page', self::PER_PAGE_DEFAULT ) );
$offset = ( $paged - 1 ) * $per_page;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
empty( $params ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
? "SELECT COUNT(*) FROM {$table} {$where_sql}" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
: $wpdb->prepare( "SELECT COUNT(*) FROM {$table} {$where_sql}", ...$params ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
);
$order_sql = 'ORDER BY analyzed_at DESC, relevance_score DESC';
$limit_sql = sprintf( 'LIMIT %d OFFSET %d', $per_page, $offset );
$full_sql = "SELECT * FROM {$table} {$where_sql} {$order_sql} {$limit_sql}";
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$rows = empty( $params ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
? $wpdb->get_results( $full_sql, ARRAY_A ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
: $wpdb->get_results( $wpdb->prepare( $full_sql, ...$params ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
return array(
'rows' => $rows ?? array(),
'total' => $total,
);
}
/** Get counts per analysis_type for the filter badges. */
public static function get_type_counts(): array {
global $wpdb;
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT analysis_type, COUNT(*) AS cnt FROM {$table} GROUP BY analysis_type ORDER BY cnt DESC",
ARRAY_A
);
$counts = array();
foreach ( $rows ?? array() as $row ) {
$counts[ $row['analysis_type'] ] = (int) $row['cnt'];
}
return $counts;
}
/**
* Load an analysis result row and map it to Article_Idea constructor data.
*
* @param int $idea_id Analysis result row ID.
* @return array<string, mixed>|null Article_Idea-compatible array, or null.
*/
public static function load_idea_data( int $idea_id ): ?array {
global $wpdb;
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$row = $wpdb->get_row( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $idea_id ),
ARRAY_A
);
if ( ! $row ) {
return null;
}
$meta = json_decode( $row['metadata_json'] ?? '{}', true ) ?? array();
return array(
'topic' => $row['topic'],
'article_type' => $row['analysis_type'],
'suggested_title' => $meta['suggested_title'] ?? $row['topic'],
'summary' => $row['summary'] ?? '',
'score' => (float) $row['relevance_score'],
'analysis_id' => (int) $row['id'],
'source_ids' => json_decode( $row['source_ids_json'] ?? '[]', true ) ?? array(),
'key_points' => $meta['key_points'] ?? array(),
'target_keywords' => $meta['target_keywords'] ?? array(),
);
}
/** Find the most recent post created by a specific run_id. */
public static function find_post_by_run_id( string $run_id ): ?int {
global $wpdb;
$table = $wpdb->prefix . 'prautoblogger_generation_log';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$post_id = $wpdb->get_var(
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT DISTINCT post_id FROM {$table} WHERE run_id = %s AND post_id IS NOT NULL LIMIT 1",
$run_id
)
);
return null !== $post_id ? (int) $post_id : null;
}
/** Update the per-idea status transient with a stage message. */
public static function update_idea_stage( int $idea_id, string $stage ): void {
$key = self::STATUS_PREFIX . $idea_id;
$current = get_transient( $key );
if ( is_array( $current ) ) {
$current['stage'] = $stage;
set_transient( $key, $current, self::STATUS_TTL );
}
}
}

View File

@@ -8,20 +8,14 @@ declare(strict_types=1);
*
* Triggered by: PRAutoBlogger::register_admin_hooks() on `admin_menu`.
* Dependencies: WordPress $wpdb, Article_Worker, Cost_Tracker, Generation_Lock.
* Query helpers: class-ideas-browser-query.php (split for 300-line rule, CONVENTIONS §1).
*
* @see core/class-article-worker.php — Generates articles from ideas.
* @see templates/admin/ideas-browser.php — Renders the HTML.
* @see class-ideas-browser-query.php — Static query helpers.
*/
class PRAutoBlogger_Ideas_Browser {
private const PER_PAGE = 30;
/** Transient prefix for per-idea generation status. */
private const STATUS_PREFIX = 'prab_idea_gen_';
/** How long to keep per-idea status (seconds). */
private const STATUS_TTL = 600;
/** Register the Ideas submenu page under PRAutoBlogger. */
public function on_register_menu(): void {
add_submenu_page(
@@ -43,11 +37,12 @@ class PRAutoBlogger_Ideas_Browser {
$paged = isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1;
$type_filter = isset( $_GET['type'] ) ? sanitize_key( $_GET['type'] ) : '';
$result = $this->query_ideas( $paged, $type_filter );
$rows = $result['rows'];
$total = $result['total'];
$total_pages = (int) ceil( $total / self::PER_PAGE );
$type_counts = $this->get_type_counts();
$result = PRAutoBlogger_Ideas_Browser_Query::query_ideas( $paged, $type_filter );
$rows = $result['rows'];
$total = $result['total'];
$per_page_view = max( 5, (int) get_option( 'prautoblogger_ideas_per_page', PRAutoBlogger_Ideas_Browser_Query::PER_PAGE_DEFAULT ) );
$total_pages = (int) ceil( $total / $per_page_view );
$type_counts = PRAutoBlogger_Ideas_Browser_Query::get_type_counts();
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/ideas-browser.php';
}
@@ -74,23 +69,26 @@ class PRAutoBlogger_Ideas_Browser {
return;
}
$status_prefix = PRAutoBlogger_Ideas_Browser_Query::STATUS_PREFIX;
$status_ttl = PRAutoBlogger_Ideas_Browser_Query::STATUS_TTL;
// Build Article_Idea from the analysis row.
$idea_data = self::load_idea_data( $idea_id );
$idea_data = PRAutoBlogger_Ideas_Browser_Query::load_idea_data( $idea_id );
if ( null === $idea_data ) {
wp_send_json_error( array( 'message' => 'Idea not found.' ) );
return;
}
// Store idea for the cron handler and set initial "running" status.
set_transient( self::STATUS_PREFIX . 'data_' . $idea_id, $idea_data, self::STATUS_TTL );
set_transient( $status_prefix . 'data_' . $idea_id, $idea_data, $status_ttl );
set_transient(
self::STATUS_PREFIX . $idea_id,
$status_prefix . $idea_id,
array(
'status' => 'running',
'stage' => __( 'Starting generation…', 'prautoblogger' ),
'started' => time(),
),
self::STATUS_TTL
$status_ttl
);
// Schedule background cron — passes idea_id as argument.
@@ -124,7 +122,7 @@ class PRAutoBlogger_Ideas_Browser {
}
$idea_id = absint( $_POST['idea_id'] ?? 0 );
$status = get_transient( self::STATUS_PREFIX . $idea_id );
$status = get_transient( PRAutoBlogger_Ideas_Browser_Query::STATUS_PREFIX . $idea_id );
if ( ! is_array( $status ) ) {
wp_send_json_success( array( 'status' => 'idle' ) );
return;
@@ -149,16 +147,19 @@ class PRAutoBlogger_Ideas_Browser {
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@set_time_limit( 300 );
$key = self::STATUS_PREFIX . $idea_id;
$idea_data = get_transient( self::STATUS_PREFIX . 'data_' . $idea_id );
$prefix = PRAutoBlogger_Ideas_Browser_Query::STATUS_PREFIX;
$ttl = PRAutoBlogger_Ideas_Browser_Query::STATUS_TTL;
$key = $prefix . $idea_id;
$idea_data = get_transient( $prefix . 'data_' . $idea_id );
if ( ! is_array( $idea_data ) ) {
set_transient(
$key,
array(
'status' => 'error',
'status' => 'error',
'message' => 'Idea data expired.',
),
self::STATUS_TTL
$ttl
);
return;
}
@@ -167,10 +168,10 @@ class PRAutoBlogger_Ideas_Browser {
set_transient(
$key,
array(
'status' => 'error',
'status' => 'error',
'message' => 'Another generation is running.',
),
self::STATUS_TTL
$ttl
);
return;
}
@@ -180,7 +181,7 @@ class PRAutoBlogger_Ideas_Browser {
$cost_tracker = new PRAutoBlogger_Cost_Tracker();
$cost_tracker->set_run_id( wp_generate_uuid4() );
self::update_idea_stage( $idea_id, __( 'Generating article draft…', 'prautoblogger' ) );
PRAutoBlogger_Ideas_Browser_Query::update_idea_stage( $idea_id, __( 'Generating article draft…', 'prautoblogger' ) );
$worker = new PRAutoBlogger_Article_Worker( $cost_tracker );
$result = $worker->generate( $idea );
@@ -188,7 +189,7 @@ class PRAutoBlogger_Ideas_Browser {
PRAutoBlogger_Post_Assembler::amortize_research_costs( $cost_tracker->get_run_id() );
// Find the generated post ID for the "View" link.
$post_id = self::find_post_by_run_id( $cost_tracker->get_run_id() );
$post_id = PRAutoBlogger_Ideas_Browser_Query::find_post_by_run_id( $cost_tracker->get_run_id() );
set_transient(
$key,
@@ -199,7 +200,7 @@ class PRAutoBlogger_Ideas_Browser {
'cost' => $result['cost'],
'post_id' => $post_id,
),
self::STATUS_TTL
$ttl
);
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->error(
@@ -209,129 +210,14 @@ class PRAutoBlogger_Ideas_Browser {
set_transient(
$key,
array(
'status' => 'error',
'status' => 'error',
'message' => $e->getMessage(),
),
self::STATUS_TTL
$ttl
);
}
PRAutoBlogger_Generation_Lock::release();
delete_transient( self::STATUS_PREFIX . 'data_' . $idea_id );
}
// ── Private helpers ─────────────────────────────────────────────────
/** Update the per-idea status transient with a stage message. */
private static function update_idea_stage( int $idea_id, string $stage ): void {
$key = self::STATUS_PREFIX . $idea_id;
$current = get_transient( $key );
if ( is_array( $current ) ) {
$current['stage'] = $stage;
set_transient( $key, $current, self::STATUS_TTL );
}
}
/** Find the most recent post created by a specific run_id. */
private static function find_post_by_run_id( string $run_id ): ?int {
global $wpdb;
$table = $wpdb->prefix . 'prautoblogger_generation_log';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$post_id = $wpdb->get_var(
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT DISTINCT post_id FROM {$table} WHERE run_id = %s AND post_id IS NOT NULL LIMIT 1",
$run_id
)
);
return null !== $post_id ? (int) $post_id : null;
}
/**
* Load an analysis result row and map it to Article_Idea constructor data.
*
* @param int $idea_id Analysis result row ID.
* @return array<string, mixed>|null Article_Idea-compatible array, or null.
*/
private static function load_idea_data( int $idea_id ): ?array {
global $wpdb;
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$row = $wpdb->get_row( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $idea_id ),
ARRAY_A
);
if ( ! $row ) {
return null;
}
$meta = json_decode( $row['metadata_json'] ?? '{}', true ) ?? array();
return array(
'topic' => $row['topic'],
'article_type' => $row['analysis_type'],
'suggested_title' => $meta['suggested_title'] ?? $row['topic'],
'summary' => $row['summary'] ?? '',
'score' => (float) $row['relevance_score'],
'analysis_id' => (int) $row['id'],
'source_ids' => json_decode( $row['source_ids_json'] ?? '[]', true ) ?? array(),
'key_points' => $meta['key_points'] ?? array(),
'target_keywords' => $meta['target_keywords'] ?? array(),
);
}
/** Query analysis results with optional filtering and pagination. */
private function query_ideas( int $paged, string $type ): array {
global $wpdb;
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
$where = array();
$params = array();
if ( '' !== $type ) {
$where[] = 'analysis_type = %s';
$params[] = $type;
}
$where_sql = empty( $where ) ? '' : 'WHERE ' . implode( ' AND ', $where );
$offset = ( $paged - 1 ) * self::PER_PAGE;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
empty( $params ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
? "SELECT COUNT(*) FROM {$table} {$where_sql}" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
: $wpdb->prepare( "SELECT COUNT(*) FROM {$table} {$where_sql}", ...$params ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
);
$order_sql = 'ORDER BY analyzed_at DESC, relevance_score DESC';
$limit_sql = sprintf( 'LIMIT %d OFFSET %d', self::PER_PAGE, $offset );
$full_sql = "SELECT * FROM {$table} {$where_sql} {$order_sql} {$limit_sql}";
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$rows = empty( $params ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
? $wpdb->get_results( $full_sql, ARRAY_A ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
: $wpdb->get_results( $wpdb->prepare( $full_sql, ...$params ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
return array(
'rows' => $rows ?? array(),
'total' => $total,
);
}
/** Get counts per analysis_type for the filter badges. */
private function get_type_counts(): array {
global $wpdb;
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT analysis_type, COUNT(*) AS cnt FROM {$table} GROUP BY analysis_type ORDER BY cnt DESC",
ARRAY_A
);
$counts = array();
foreach ( $rows ?? array() as $row ) {
$counts[ $row['analysis_type'] ] = (int) $row['cnt'];
}
return $counts;
delete_transient( $prefix . 'data_' . $idea_id );
}
}

View File

@@ -4,21 +4,22 @@ declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Adds a metabox to posts showing PRAutoBlogger generation metadata.
* Adds a metabox to PRAutoBlogger-generated posts.
*
* Only appears on posts that were generated by PRAutoBlogger (checked via
* _prautoblogger_generated post meta).
* M2: Slimmed to a "View generation dossier" link. Full generation metadata
* lives on the Article Dossier page (PRAutoBlogger_Dossier_Page).
*
* Triggered by: PRAutoBlogger::register_admin_hooks() on `add_meta_boxes`.
* Dependencies: WordPress post meta API.
* Dependencies: PRAutoBlogger_Dossier_Page (for URL helper).
*
* @see class-prautoblogger.php — Registers the hook.
* @see core/class-publisher.php — Writes the metadata this class displays.
* @see class-prautoblogger.php -- Registers the hook.
* @see admin/class-dossier-page.php -- Dossier target; provides url_for_post().
* @see core/class-publisher.php -- Writes the run_id meta this class reads.
*/
class PRAutoBlogger_Post_Metabox {
/**
* Register the metabox for the post editor.
* Register the metabox for PRAutoBlogger-generated posts only.
*
* @return void
*/
@@ -35,7 +36,7 @@ class PRAutoBlogger_Post_Metabox {
add_meta_box(
'prautoblogger_generation_info',
__( 'PRAutoBlogger Generation Info', 'prautoblogger' ),
__( 'PRAutoBlogger', 'prautoblogger' ),
array( $this, 'render_metabox' ),
'post',
'side',
@@ -44,13 +45,12 @@ class PRAutoBlogger_Post_Metabox {
}
/**
* Render the generation metadata metabox.
*
* @param \WP_Post $post
* Render the dossier-link metabox.
*
* @param \WP_Post $post Current post.
* @return void
*/
public function render_metabox( \WP_Post $post ): void {
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/metabox-generation-info.php';
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/metabox-dossier-link.php';
}
}

View File

@@ -35,280 +35,194 @@ class PRAutoBlogger_Settings_Fields_Extended {
* @return array<int, array<string, mixed>>
*/
public static function get_fields(): array {
return array(
// ── Sources (extended) ──────────────────────────────────────
return array_merge(
array(
'id' => 'prautoblogger_research_model',
'label' => __( 'Research Model', 'prautoblogger' ),
'type' => 'model_select',
'section' => 'prautoblogger_sources',
'default' => PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL,
'capability' => 'text→text',
'description' => __( 'Model for LLM Deep Research. Pick a reasoning-capable model (e.g. Grok 4.1 Fast, DeepSeek-R1) for best results. Only used when LLM Deep Research is enabled above.', 'prautoblogger' ),
'badge' => __( 'Reasoning', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_research_prompt',
'label' => __( 'Research Prompt', 'prautoblogger' ),
'type' => 'textarea',
'section' => 'prautoblogger_sources',
'default' => 'Conduct a deep research sweep of the {niche} space and identify the most substantive, actionable topics that practitioners and curious newcomers are searching for or actively discussing.' . "\n\n" . 'Focus on:' . "\n" . '- Practical questions real readers would search for (not theoretical debates)' . "\n" . '- Emerging trends gaining momentum in the community' . "\n" . '- Common misconceptions that deserve clarification' . "\n" . '- Comparative analysis (product vs. product, protocol vs. protocol)' . "\n" . '- Safety and efficacy topics that drive concern or curiosity' . "\n" . '- Beginner-to-advanced mix — cover entry-level and depth-focused topics' . "\n\n" . 'Aim for topics that feel timely, underserved by existing content, and likely to drive reader engagement. Return 8-12 findings with detailed analysis in each.',
'description' => __( 'The research brief sent to the LLM. Use {niche} as a placeholder for your niche description. The system prompt handles output format — this controls WHAT to research.', 'prautoblogger' ),
),
// ── AI Models (extended) ────────────────────────────────────
array(
'id' => 'prautoblogger_reasoning_enabled',
'label' => __( 'Enable Reasoning', 'prautoblogger' ),
'type' => 'toggle',
'section' => 'prautoblogger_models',
'default' => '0',
'description' => __( 'Send reasoning instructions to models that support it (e.g. Grok, DeepSeek-R1). Reasoning tokens are billed as output tokens — expect higher costs per call. Models that don\'t support reasoning will ignore this.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_reasoning_effort',
'label' => __( 'Reasoning Effort', 'prautoblogger' ),
'type' => 'select',
'section' => 'prautoblogger_models',
'default' => 'medium',
'options' => array(
'xhigh' => __( 'Extra High — maximum depth, highest cost', 'prautoblogger' ),
'high' => __( 'High — thorough reasoning', 'prautoblogger' ),
'medium' => __( 'Medium — balanced (recommended)', 'prautoblogger' ),
'low' => __( 'Low — light reasoning, lower cost', 'prautoblogger' ),
'minimal' => __( 'Minimal — barely any reasoning', 'prautoblogger' ),
// ── Sources (extended) ──────────────────────────────────────
array(
'id' => 'prautoblogger_research_model',
'label' => __( 'Research Model', 'prautoblogger' ),
'type' => 'model_select',
'section' => 'prautoblogger_sources',
'default' => PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL,
'capability' => 'text→text',
'description' => __( 'Model for LLM Deep Research. Pick a reasoning-capable model (e.g. Grok 4.1 Fast, DeepSeek-R1) for best results. Only used when LLM Deep Research is enabled above.', 'prautoblogger' ),
'badge' => __( 'Reasoning', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_research_prompt',
'label' => __( 'Research Prompt', 'prautoblogger' ),
'type' => 'textarea',
'section' => 'prautoblogger_sources',
'default' => 'Conduct a deep research sweep of the {niche} space and identify the most substantive, actionable topics that practitioners and curious newcomers are searching for or actively discussing.' . "\n\n" . 'Focus on:' . "\n" . '- Practical questions real readers would search for (not theoretical debates)' . "\n" . '- Emerging trends gaining momentum in the community' . "\n" . '- Common misconceptions that deserve clarification' . "\n" . '- Comparative analysis (product vs. product, protocol vs. protocol)' . "\n" . '- Safety and efficacy topics that drive concern or curiosity' . "\n" . '- Beginner-to-advanced mix — cover entry-level and depth-focused topics' . "\n\n" . 'Aim for topics that feel timely, underserved by existing content, and likely to drive reader engagement. Return 8-12 findings with detailed analysis in each.',
'description' => __( 'The research brief sent to the LLM. Use {niche} as a placeholder for your niche description. The system prompt handles output format — this controls WHAT to research.', 'prautoblogger' ),
),
'description' => __( 'How much effort the model spends reasoning before answering. Higher effort = more reasoning tokens = higher cost. Only applies when reasoning is enabled above.', 'prautoblogger' ),
),
// ── Schedule & Budget ───────────────────────────────────────
array(
'id' => 'prautoblogger_daily_article_target',
'label' => __( 'Articles per Day', 'prautoblogger' ),
'type' => 'number',
'section' => 'prautoblogger_schedule',
'default' => 1,
'min' => 1,
'max' => 10,
),
array(
'id' => 'prautoblogger_schedule_time',
'label' => __( 'Generation Time', 'prautoblogger' ),
'type' => 'time',
'section' => 'prautoblogger_schedule',
'default' => '03:00',
'description' => sprintf(
/* translators: %s: site timezone string, e.g. "Asia/Singapore". */
esc_html__( "Daily generation runs at this time in your site's configured timezone (Settings → General → Timezone). Current site timezone: %s.", 'prautoblogger' ),
esc_html( function_exists( 'wp_timezone_string' ) ? wp_timezone_string() : 'UTC' )
// ── AI Models (extended) ────────────────────────────────────
array(
'id' => 'prautoblogger_reasoning_enabled',
'label' => __( 'Enable Reasoning', 'prautoblogger' ),
'type' => 'toggle',
'section' => 'prautoblogger_models',
'default' => '0',
'description' => __( 'Send reasoning instructions to models that support it (e.g. Grok, DeepSeek-R1). Reasoning tokens are billed as output tokens — expect higher costs per call. Models that don\'t support reasoning will ignore this.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_reasoning_effort',
'label' => __( 'Reasoning Effort', 'prautoblogger' ),
'type' => 'select',
'section' => 'prautoblogger_models',
'default' => 'medium',
'options' => array(
'xhigh' => __( 'Extra High — maximum depth, highest cost', 'prautoblogger' ),
'high' => __( 'High — thorough reasoning', 'prautoblogger' ),
'medium' => __( 'Medium — balanced (recommended)', 'prautoblogger' ),
'low' => __( 'Low — light reasoning, lower cost', 'prautoblogger' ),
'minimal' => __( 'Minimal — barely any reasoning', 'prautoblogger' ),
),
'description' => __( 'How much effort the model spends reasoning before answering. Higher effort = more reasoning tokens = higher cost. Only applies when reasoning is enabled above.', 'prautoblogger' ),
),
// ── Board Settings ─────────────────────────────────────────
array(
'id' => 'prautoblogger_board_poll_interval',
'label' => __( 'Board Poll Interval (seconds)', 'prautoblogger' ),
'type' => 'number',
'section' => 'prautoblogger_schedule',
'default' => 5,
'min' => 3,
'max' => 60,
'description' => __( 'How often the kanban board polls for status updates (seconds). Minimum 3s. Doubles automatically when no article is actively generating.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_board_published_window_days',
'label' => __( 'Published Column Window (days)', 'prautoblogger' ),
'type' => 'number',
'section' => 'prautoblogger_schedule',
'default' => 7,
'min' => 1,
'max' => 90,
'description' => __( 'How many days back the Published column shows articles. Increase to see older published articles on the board.', 'prautoblogger' ),
),
// ── Schedule & Budget ───────────────────────────────────────
array(
'id' => 'prautoblogger_daily_article_target',
'label' => __( 'Articles per Day', 'prautoblogger' ),
'type' => 'number',
'section' => 'prautoblogger_schedule',
'default' => 1,
'min' => 1,
'max' => 10,
),
array(
'id' => 'prautoblogger_schedule_time',
'label' => __( 'Generation Time', 'prautoblogger' ),
'type' => 'time',
'section' => 'prautoblogger_schedule',
'default' => '03:00',
'description' => sprintf(
/* translators: %s: site timezone string, e.g. "Asia/Singapore". */
esc_html__( "Daily generation runs at this time in your site's configured timezone (Settings → General → Timezone). Current site timezone: %s.", 'prautoblogger' ),
esc_html( function_exists( 'wp_timezone_string' ) ? wp_timezone_string() : 'UTC' )
),
),
array(
'id' => 'prautoblogger_monthly_budget_usd',
'label' => __( 'Monthly Budget (USD)', 'prautoblogger' ),
'type' => 'number',
'section' => 'prautoblogger_schedule',
'default' => 50,
'min' => 0,
'step' => '0.01',
'description' => __( 'Hard stop when reached. 0 = unlimited.', 'prautoblogger' ),
),
// ── Publishing ──────────────────────────────────────────────
array(
'id' => 'prautoblogger_auto_publish',
'label' => __( 'Auto-Publish', 'prautoblogger' ),
'type' => 'toggle',
'section' => 'prautoblogger_publishing',
'default' => '0',
'description' => __( 'Automatically publish editor-approved posts. When off, all posts go to the Review Queue as drafts.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_default_author',
'label' => __( 'Default Author', 'prautoblogger' ),
'type' => 'author_select',
'section' => 'prautoblogger_publishing',
'default' => 0,
'description' => __( 'WordPress user assigned as author of generated posts.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_default_category',
'label' => __( 'Default Category', 'prautoblogger' ),
'type' => 'category_select',
'section' => 'prautoblogger_publishing',
'default' => 0,
'description' => __( 'Fallback category for generated posts.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_log_level',
'label' => __( 'Log Level', 'prautoblogger' ),
'type' => 'select',
'section' => 'prautoblogger_publishing',
'default' => 'info',
'options' => array(
'error' => __( 'Error — only failures', 'prautoblogger' ),
'warning' => __( 'Warning — errors + warnings', 'prautoblogger' ),
'info' => __( 'Info — key events (recommended)', 'prautoblogger' ),
'debug' => __( 'Debug — everything (verbose)', 'prautoblogger' ),
),
'description' => __( 'Controls detail level in the Activity Log.', 'prautoblogger' ),
),
// ── Analytics ───────────────────────────────────────────────
array(
'id' => 'prautoblogger_ga4_property_id',
'label' => __( 'GA4 Property ID', 'prautoblogger' ),
'type' => 'text',
'section' => 'prautoblogger_analytics',
'description' => __( 'Your GA4 Property ID — digits only, e.g. 123456789 (the code adds the "properties/" prefix internally). Find it in GA4 → Admin → Property details. Leave blank to skip GA4.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_ga4_credentials_json',
'label' => __( 'Service Account JSON', 'prautoblogger' ),
'type' => 'password',
'section' => 'prautoblogger_analytics',
'description' => __( 'Paste the full JSON key file for a service account with Analytics read access.', 'prautoblogger' ),
),
// ── Display ────────────────────────────────────────────────
array(
'id' => 'prautoblogger_article_font_family',
'label' => __( 'Article Font', 'prautoblogger' ),
'type' => 'select',
'section' => 'prautoblogger_display',
'default' => 'default',
'options' => PRAutoBlogger_Article_Typography::get_font_choices(),
'description' => __( 'Font family used for generated article body text. Serif fonts improve long-form readability.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_article_font_size',
'label' => __( 'Article Font Size (px)', 'prautoblogger' ),
'type' => 'number',
'section' => 'prautoblogger_display',
'default' => 0,
'min' => 0,
'max' => 32,
'description' => __( 'Base font size for article body text. 0 uses the theme default (13px). Recommended: 16–18px for comfortable reading.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_table_borders',
'label' => __( 'Table Borders', 'prautoblogger' ),
'type' => 'toggle',
'section' => 'prautoblogger_display',
'default' => '1',
'description' => __( 'Add visible borders, padding, and alternating row colors to tables in generated articles.', 'prautoblogger' ),
),
),
array(
'id' => 'prautoblogger_monthly_budget_usd',
'label' => __( 'Monthly Budget (USD)', 'prautoblogger' ),
'type' => 'number',
'section' => 'prautoblogger_schedule',
'default' => 50,
'min' => 0,
'step' => '0.01',
'description' => __( 'Hard stop when reached. 0 = unlimited.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_request_json_retention_days',
'label' => __( 'Audit Payload Retention (days)', 'prautoblogger' ),
'type' => 'number',
'section' => 'prautoblogger_schedule',
'default' => PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS,
'min' => 0,
'step' => '1',
'description' => __( 'Heavy audit payloads (request_json on the generation log, stage output snapshots) are emptied after this many days by the daily cleanup cron. Cost/token rows are kept forever. 0 = keep payloads forever.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_per_run_cost_ceiling_usd',
'label' => __( 'Per-Run Cost Ceiling (USD)', 'prautoblogger' ),
'type' => 'number',
'section' => 'prautoblogger_schedule',
'default' => PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD,
'min' => 0,
'step' => '0.01',
'description' => __( 'Hard per-run stop: every API call reserves its worst-case cost against this ceiling before dispatch; a breach halts the run and routes it to the Review Queue. Snapshotted at run start. 0 = disabled.', 'prautoblogger' ),
),
// ── Publishing ──────────────────────────────────────────────
array(
'id' => 'prautoblogger_auto_publish',
'label' => __( 'Auto-Publish', 'prautoblogger' ),
'type' => 'toggle',
'section' => 'prautoblogger_publishing',
'default' => '0',
'description' => __( 'Automatically publish editor-approved posts. When off, all posts go to the Review Queue as drafts.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_default_author',
'label' => __( 'Default Author', 'prautoblogger' ),
'type' => 'author_select',
'section' => 'prautoblogger_publishing',
'default' => 0,
'description' => __( 'WordPress user assigned as author of generated posts.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_default_category',
'label' => __( 'Default Category', 'prautoblogger' ),
'type' => 'category_select',
'section' => 'prautoblogger_publishing',
'default' => 0,
'description' => __( 'Fallback category for generated posts.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_log_level',
'label' => __( 'Log Level', 'prautoblogger' ),
'type' => 'select',
'section' => 'prautoblogger_publishing',
'default' => 'info',
'options' => array(
'error' => __( 'Error — only failures', 'prautoblogger' ),
'warning' => __( 'Warning — errors + warnings', 'prautoblogger' ),
'info' => __( 'Info — key events (recommended)', 'prautoblogger' ),
'debug' => __( 'Debug — everything (verbose)', 'prautoblogger' ),
),
'description' => __( 'Controls detail level in the Activity Log.', 'prautoblogger' ),
),
// ── Analytics ───────────────────────────────────────────────
array(
'id' => 'prautoblogger_ga4_property_id',
'label' => __( 'GA4 Property ID', 'prautoblogger' ),
'type' => 'text',
'section' => 'prautoblogger_analytics',
'description' => __( 'Your GA4 Property ID — digits only, e.g. 123456789 (the code adds the "properties/" prefix internally). Find it in GA4 → Admin → Property details. Leave blank to skip GA4.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_ga4_credentials_json',
'label' => __( 'Service Account JSON', 'prautoblogger' ),
'type' => 'password',
'section' => 'prautoblogger_analytics',
'description' => __( 'Paste the full JSON key file for a service account with Analytics read access.', 'prautoblogger' ),
),
// ── Display ────────────────────────────────────────────────
array(
'id' => 'prautoblogger_article_font_family',
'label' => __( 'Article Font', 'prautoblogger' ),
'type' => 'select',
'section' => 'prautoblogger_display',
'default' => 'default',
'options' => PRAutoBlogger_Article_Typography::get_font_choices(),
'description' => __( 'Font family used for generated article body text. Serif fonts improve long-form readability.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_article_font_size',
'label' => __( 'Article Font Size (px)', 'prautoblogger' ),
'type' => 'number',
'section' => 'prautoblogger_display',
'default' => 0,
'min' => 0,
'max' => 32,
'description' => __( 'Base font size for article body text. 0 uses the theme default (13px). Recommended: 16–18px for comfortable reading.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_table_borders',
'label' => __( 'Table Borders', 'prautoblogger' ),
'type' => 'toggle',
'section' => 'prautoblogger_display',
'default' => '1',
'description' => __( 'Add visible borders, padding, and alternating row colors to tables in generated articles.', 'prautoblogger' ),
),
// ── Images ─────────────────────────────────────────────────
array(
'id' => 'prautoblogger_image_enabled',
'label' => __( 'Enable Image Generation', 'prautoblogger' ),
'type' => 'toggle',
'section' => 'prautoblogger_images',
'default' => '0',
'description' => __( 'Generate images for each published article.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_image_b_enabled',
'label' => __( 'Generate Second Image (B)', 'prautoblogger' ),
'type' => 'toggle',
'section' => 'prautoblogger_images',
'default' => '1',
'description' => __( 'Generate a second image from source data for A/B testing. Disabling saves one image generation + one LLM prompt rewrite per article.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_image_model',
'label' => __( 'Image Model', 'prautoblogger' ),
'type' => 'model_select',
'section' => 'prautoblogger_images',
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL,
'capability' => 'image_generation',
'description' => __( 'Pick an image model. The provider (Runware or OpenRouter) is derived from the model registry on save, so mismatched pairs are no longer possible.', 'prautoblogger' ),
'badge' => __( 'Quality', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_runware_api_key',
'label' => __( 'Runware API Key', 'prautoblogger' ),
'type' => 'password',
'section' => 'prautoblogger_images',
'description' => __( 'Required when using a Runware FLUX model. Get your key at runware.ai/signup.', 'prautoblogger' ),
'icon' => '🔑',
),
array(
'id' => 'prautoblogger_image_style_template',
'label' => __( 'Style Template', 'prautoblogger' ),
'type' => 'textarea',
'section' => 'prautoblogger_images',
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE,
'description' => __( 'The full image prompt template. Must contain exactly one {{ topic_summary }} token, which is filled with a 1-2 sentence topic/mechanism summary per article. The rest is brand-locked editorial style. Replaces the old Style Suffix (now deprecated). Changing mid-run causes visible style drift.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_image_prompt_instructions',
'label' => __( 'Image Prompt Instructions', 'prautoblogger' ),
'type' => 'textarea',
'section' => 'prautoblogger_images',
'default' => PRAutoBlogger_Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT,
'description' => __( 'System prompt given to the rewriter LLM that turns each article into a SCENE (a 1-2 sentence editorial topic/mechanism summary, substituted into the Style Template) plus a CAPTION (HTML text shown below the image). Changing this reshapes the look of all future images. Leave blank to use the default.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_image_nsfw_retry',
'label' => __( 'Retry NSFW-Blocked Images', 'prautoblogger' ),
'type' => 'toggle',
'section' => 'prautoblogger_images',
'default' => '1',
'description' => __( 'When the provider rejects an image prompt as NSFW, retry once with a generic fallback scene built from the article title. Disable to fail fast if the filter gets trigger-happy.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_image_compose_enabled',
'label' => __( 'Compose Branded Variants', 'prautoblogger' ),
'type' => 'toggle',
'section' => 'prautoblogger_images',
'default' => '1',
'description' => __( 'Render branded social variants (teal band, logo, baked caption) from each generated image via the local deterministic composer. Auto-degrades to plain resizing or pass-through if the server lacks an image library — publishing is never blocked.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_image_compose_variants',
'label' => __( 'Composed Variant Set', 'prautoblogger' ),
'type' => 'text',
'section' => 'prautoblogger_images',
'default' => 'og,square',
'description' => __( 'Comma-separated variants to render for Image A. Supported: og (1200×630 social share), square (1080×1080 card). Unknown values are ignored.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_image_featured_mark_enabled',
'label' => __( 'Featured Image Corner Mark', 'prautoblogger' ),
'type' => 'toggle',
'section' => 'prautoblogger_images',
'default' => '1',
'description' => __( 'Overlay a small semi-transparent Peptide Repo mark in the bottom-right corner of featured images, so scraped or re-shared images carry attribution.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_runware_model_catalog',
'label' => __( 'Runware Model Catalog', 'prautoblogger' ),
'type' => 'runware_catalog_sync',
'section' => 'prautoblogger_images',
'description' => __( 'Live model catalog sync status and on-demand refresh.', 'prautoblogger' ),
),
PRAutoBlogger_Settings_Fields_Images::get_fields()
);
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Image settings fields for PRAutoBlogger.
*
* Declarative field definitions for the Images section of the settings page.
* Extracted from PRAutoBlogger_Settings_Fields_Extended for 300-line compliance.
*
* What: Returns the images-section field array consumed by the settings registry.
* Who triggers: PRAutoBlogger_Settings_Fields_Extended::get_fields() via merge.
* Dependencies: PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL, PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE,
* PRAutoBlogger_Image_Prompt_Builder, PRAutoBlogger_Image_Model_Registry.
*
* @see admin/class-settings-fields-extended.php -- Merges these fields in get_fields().
* @see admin/class-admin-page.php -- Renders the fields.
* @see CONVENTIONS.md -- "How To: Add a New Admin Setting".
*/
class PRAutoBlogger_Settings_Fields_Images {
/**
* Get image generation settings fields.
*
* @return array<int, array<string, mixed>>
*/
public static function get_fields(): array {
return array(
// ── Images ─────────────────────────────────────────────────
array(
'id' => 'prautoblogger_image_enabled',
'label' => __( 'Enable Image Generation', 'prautoblogger' ),
'type' => 'toggle',
'section' => 'prautoblogger_images',
'default' => '0',
'description' => __( 'Generate images for each published article.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_image_b_enabled',
'label' => __( 'Generate Second Image (B)', 'prautoblogger' ),
'type' => 'toggle',
'section' => 'prautoblogger_images',
'default' => '1',
'description' => __( 'Generate a second image from source data for A/B testing. Disabling saves one image generation + one LLM prompt rewrite per article.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_image_model',
'label' => __( 'Image Model', 'prautoblogger' ),
'type' => 'model_select',
'section' => 'prautoblogger_images',
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL,
'capability' => 'image_generation',
'description' => __( 'Pick an image model. The provider (Runware or OpenRouter) is derived from the model registry on save, so mismatched pairs are no longer possible.', 'prautoblogger' ),
'badge' => __( 'Quality', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_runware_api_key',
'label' => __( 'Runware API Key', 'prautoblogger' ),
'type' => 'password',
'section' => 'prautoblogger_images',
'description' => __( 'Required when using a Runware FLUX model. Get your key at runware.ai/signup.', 'prautoblogger' ),
'icon' => '🔑',
),
array(
'id' => 'prautoblogger_image_style_template',
'label' => __( 'Style Template', 'prautoblogger' ),
'type' => 'textarea',
'section' => 'prautoblogger_images',
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE,
'description' => __( 'The full image prompt template. Must contain exactly one {{ topic_summary }} token, which is filled with a 1-2 sentence topic/mechanism summary per article. The rest is brand-locked editorial style. Replaces the old Style Suffix (now deprecated). Changing mid-run causes visible style drift.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_image_prompt_instructions',
'label' => __( 'Image Prompt Instructions', 'prautoblogger' ),
'type' => 'textarea',
'section' => 'prautoblogger_images',
'default' => PRAutoBlogger_Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT,
'description' => __( 'System prompt given to the rewriter LLM that turns each article into a SCENE (a 1-2 sentence editorial topic/mechanism summary, substituted into the Style Template) plus a CAPTION (HTML text shown below the image). Changing this reshapes the look of all future images. Leave blank to use the default.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_image_nsfw_retry',
'label' => __( 'Retry NSFW-Blocked Images', 'prautoblogger' ),
'type' => 'toggle',
'section' => 'prautoblogger_images',
'default' => '1',
'description' => __( 'When the provider rejects an image prompt as NSFW, retry once with a generic fallback scene built from the article title. Disable to fail fast if the filter gets trigger-happy.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_image_compose_enabled',
'label' => __( 'Compose Branded Variants', 'prautoblogger' ),
'type' => 'toggle',
'section' => 'prautoblogger_images',
'default' => '1',
'description' => __( 'Render branded social variants (teal band, logo, baked caption) from each generated image via the local deterministic composer. Auto-degrades to plain resizing or pass-through if the server lacks an image library — publishing is never blocked.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_image_compose_variants',
'label' => __( 'Composed Variant Set', 'prautoblogger' ),
'type' => 'text',
'section' => 'prautoblogger_images',
'default' => 'og,square',
'description' => __( 'Comma-separated variants to render for Image A. Supported: og (1200×630 social share), square (1080×1080 card). Unknown values are ignored.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_image_featured_mark_enabled',
'label' => __( 'Featured Image Corner Mark', 'prautoblogger' ),
'type' => 'toggle',
'section' => 'prautoblogger_images',
'default' => '1',
'description' => __( 'Overlay a small semi-transparent Peptide Repo mark in the bottom-right corner of featured images, so scraped or re-shared images carry attribution.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_runware_model_catalog',
'label' => __( 'Runware Model Catalog', 'prautoblogger' ),
'type' => 'runware_catalog_sync',
'section' => 'prautoblogger_images',
'description' => __( 'Live model catalog sync status and on-demand refresh.', 'prautoblogger' ),
),
);
}
}

View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Settings field sanitizer for PRAutoBlogger.
*
* Centralises all option sanitization logic: encryption for API keys, JSON
* encoding for multi-value fields, numeric coercion, image-model registry
* validation. Extracted from PRAutoBlogger_Admin_Page for 300-line compliance.
*
* What: Static utility called by register_setting() sanitize_callback.
* Who triggers: WordPress Settings API on option save; PRAutoBlogger_Admin_Page::sanitize_field().
* Dependencies: PRAutoBlogger_Encryption, PRAutoBlogger_Image_Model_Registry,
* PRAutoBlogger_Image_Template_Filler.
*
* @see admin/class-admin-page.php -- Registers settings and delegates here.
* @see admin/class-image-model-registry.php -- Validates image model slug on save.
* @see CONVENTIONS.md -- "How To: Add a New Admin Setting".
*/
class PRAutoBlogger_Settings_Sanitizer {
/**
* Sanitize a settings field value.
*
* @param mixed $value The submitted value.
* @return mixed Sanitized value.
*/
public static function sanitize_field( $value ) {
$option_name = '';
$filter = current_filter();
if ( 0 === strpos( $filter, 'sanitize_option_' ) ) {
$option_name = substr( $filter, strlen( 'sanitize_option_' ) );
}
$encrypted = array( 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json', 'prautoblogger_runware_api_key' );
if ( in_array( $option_name, $encrypted, true ) ) {
// Empty value means password field wasn't touched — keep existing.
if ( '' === $value ) {
return get_option( $option_name, '' );
}
// Already encrypted (has "enc:" prefix) — return as-is.
// This is the primary defence against double-encryption:
// PRAutoBlogger_Encryption::encrypt() also checks for this prefix,
// so even if this callback is called multiple times, the value
// is encrypted exactly once and never re-encrypted.
if ( PRAutoBlogger_Encryption::is_encrypted( $value ) ) {
return $value;
}
// New plaintext value — encrypt it (adds "enc:" prefix).
return PRAutoBlogger_Encryption::encrypt( sanitize_text_field( $value ) );
}
$json_fields = array( 'prautoblogger_target_subreddits', 'prautoblogger_topic_exclusions', 'prautoblogger_enabled_sources' );
if ( in_array( $option_name, $json_fields, true ) ) {
// PHP array from checkboxes — sanitize each item and re-encode.
if ( is_array( $value ) ) {
return wp_json_encode( array_values( array_map( 'sanitize_text_field', $value ) ) );
}
$trimmed = trim( (string) $value );
// Already JSON-encoded array — decode, sanitize each item, re-encode.
// This prevents sanitize_text_field() from mangling the JSON string,
// which caused json_decode() on read to return null → empty subreddits.
if ( '[' === substr( $trimmed, 0, 1 ) ) {
$decoded = json_decode( $trimmed, true );
if ( is_array( $decoded ) ) {
return wp_json_encode( array_values( array_map( 'sanitize_text_field', $decoded ) ) );
}
}
// Comma-separated plain text — split, sanitize, encode as JSON array.
$items = array_filter( array_map( 'trim', explode( ',', $trimmed ) ) );
return wp_json_encode( array_values( array_map( 'sanitize_text_field', $items ) ) );
}
$numeric = array( 'prautoblogger_daily_article_target', 'prautoblogger_monthly_budget_usd', 'prautoblogger_min_word_count', 'prautoblogger_max_word_count', 'prautoblogger_default_author', 'prautoblogger_default_category', 'prautoblogger_pullpush_cache_ttl', 'prautoblogger_reddit_posts_per_subreddit', 'prautoblogger_board_poll_interval', 'prautoblogger_board_published_window_days', 'prautoblogger_board_column_limit', 'prautoblogger_ideas_per_page' );
if ( in_array( $option_name, $numeric, true ) ) {
return is_numeric( $value ) ? $value : 0;
}
if ( 'prautoblogger_image_model' === $option_name ) {
return self::sanitize_image_model( (string) $value );
}
if ( 'prautoblogger_image_style_template' === $option_name ) {
// Multi-line template: validation + textarea-safe sanitisation
// (incl. the single-token check, brief A5) lives in the filler.
return PRAutoBlogger_Image_Template_Filler::sanitize_for_save( (string) $value );
}
return sanitize_text_field( (string) $value );
}
/** Validate model id against the registry and persist the derived provider. */
private static function sanitize_image_model( string $value ): string {
$candidate = sanitize_text_field( $value );
$provider = PRAutoBlogger_Image_Model_Registry::provider_for( $candidate );
if ( '' !== $provider ) {
update_option( 'prautoblogger_image_provider', $provider );
return $candidate;
}
add_settings_error( 'prautoblogger_image_model', 'prautoblogger_image_model_unknown', sprintf( esc_html__( 'Image model "%s" is not in the registry. Keeping the previous selection.', 'prautoblogger' ), esc_html( $candidate ) ) );
return (string) get_option( 'prautoblogger_image_model', PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL );
}
}

View File

@@ -8,6 +8,14 @@ declare(strict_types=1);
*/
class PRAutoBlogger_WP_CLI_Commands {
/**
* Max generate-tick iterations for the --sync loop (covers daily_article_target = 1
* with ample headroom; prevents infinite loops if the queue never drains).
*
* @var int
*/
private const SYNC_MAX_TICKS = 20;
/**
* Register WP-CLI commands.
*
@@ -18,7 +26,22 @@ class PRAutoBlogger_WP_CLI_Commands {
return;
}
// Command: wp prautoblogger opik:eval
\WP_CLI::add_command(
'prautoblogger generate',
array( self::class, 'generate_command' ),
array(
'shortdesc' => 'Queue (or synchronously run) a new article generation run',
'synopsis' => array(
array(
'type' => 'flag',
'name' => 'sync',
'description' => 'Run the full pipeline synchronously in this process (VPS orchestrator mode). Blocks until complete.',
'optional' => true,
),
),
)
);
\WP_CLI::add_command(
'prautoblogger opik:eval',
array( self::class, 'opik_eval_command' ),
@@ -43,6 +66,148 @@ class PRAutoBlogger_WP_CLI_Commands {
);
}
/**
* Queue or synchronously run a new article generation run.
*
* Without --sync: routes through kick_off() (same as the board "New Article"
* button). Returns immediately; all pipeline work happens in cron.
*
* With --sync: runs the pipeline synchronously in this SSH/CLI process.
* 1. Acquires the generation lock (skips if a live run is in progress).
* 2. Calls on_orchestrate_tick() to collect/score ideas in-process.
* 3. Loops calling on_generate_tick() until the queue is empty or the run
* reaches a terminal state, up to SYNC_MAX_TICKS iterations.
* 4. Emits a JSON summary line on stdout and exits 0 (success) or 1 (error).
*
* The --sync path suppresses wp-cron reschedule inside on_generate_tick() so
* the external loop cannot race with a background cron tick.
*
* ## OPTIONS
*
* [--sync]
* : Run synchronously (VPS orchestrator mode).
*
* ## EXAMPLES
*
* wp prautoblogger generate
* wp prautoblogger generate --sync
*
* @param array $args Positional args (unused).
* @param array $assoc_args Associative args: sync.
* @return void
*/
public static function generate_command( array $args, array $assoc_args ): void {
if ( isset( $assoc_args['sync'] ) ) {
self::run_sync();
return;
}
PRAutoBlogger_Generation_Checkpoint_Runner::kick_off();
\WP_CLI::success( 'Generation queued on chained-cron checkpoints. Check the board or Activity Log for progress.' );
}
/**
* Run the full pipeline synchronously in this process (--sync mode).
*
* Called only from generate_command(); never from wp-cron or AJAX paths.
*
* @return void
*/
private static function run_sync(): void {
// Honour ignore_user_abort so a dropped SSH session does not kill us.
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@ignore_user_abort( true );
// Check for a live (non-expired) lock. Generation_Lock::acquire() already
// cleans expired locks on its DELETE pass, so if acquire() returns false
// there is a genuinely live concurrent run — skip cleanly.
if ( ! PRAutoBlogger_Generation_Lock::is_locked() ) {
// Lock is not held; proceed. acquire() is called inside on_orchestrate_tick().
} else {
$acquired_at = PRAutoBlogger_Generation_Lock::get_acquired_at();
$age = null !== $acquired_at ? ( time() - $acquired_at ) : HOUR_IN_SECONDS;
if ( $age < HOUR_IN_SECONDS ) {
\WP_CLI::log(
json_encode(
array(
'status' => 'skipped',
'reason' => 'run_in_progress',
'lock_age_s' => $age,
)
)
);
exit( 0 );
}
// Expired lock — on_orchestrate_tick()'s acquire() will clean it.
}
// Enable sync-mode: on_generate_tick() will skip the wp-cron reschedule.
PRAutoBlogger_Generation_Checkpoint_Runner::set_sync_mode( true );
\WP_CLI::log( 'PRAB-SYNC: starting orchestrate tick' );
PRAutoBlogger_Generation_Checkpoint_Runner::on_orchestrate_tick();
// If the orchestrate tick failed to acquire the lock or found no ideas,
// the RUN_ID option will be absent. Detect and exit cleanly.
$run_id = (string) get_option( 'prautoblogger_checkpoint_run_id', '' );
if ( '' === $run_id ) {
$queue = get_option( 'prautoblogger_article_queue' );
if ( ! is_array( $queue ) || empty( $queue['ideas'] ) ) {
\WP_CLI::log(
json_encode(
array(
'status' => 'done',
'generated' => 0,
'reason' => 'no_ideas_or_lock_failed',
)
)
);
exit( 0 );
}
}
// Tick loop: drive generate ticks synchronously.
$ticks = 0;
while ( $ticks < self::SYNC_MAX_TICKS ) {
$queue = get_option( 'prautoblogger_article_queue' );
if ( ! is_array( $queue ) || empty( $queue['ideas'] ) ) {
break; // Queue drained or finalized.
}
++$ticks;
\WP_CLI::log( sprintf( 'PRAB-SYNC: generate tick %d (ideas remaining: %d)', $ticks, count( $queue['ideas'] ) ) );
PRAutoBlogger_Generation_Checkpoint_Runner::on_generate_tick();
}
PRAutoBlogger_Generation_Checkpoint_Runner::set_sync_mode( false );
if ( $ticks >= self::SYNC_MAX_TICKS ) {
\WP_CLI::warning( 'PRAB-SYNC: hit max-tick guard (' . self::SYNC_MAX_TICKS . '). Queue may not be fully drained.' );
}
// Read final status transient for summary.
$status = get_transient( 'prautoblogger_generation_status' );
$result = is_array( $status ) ? $status : array();
$summary = array(
'status' => 'done',
'generated' => (int) ( $result['generated'] ?? 0 ),
'published' => (int) ( $result['published'] ?? 0 ),
'rejected' => (int) ( $result['rejected'] ?? 0 ),
'cost_usd' => round( (float) ( $result['cost'] ?? 0.0 ), 4 ),
'ticks' => $ticks,
);
\WP_CLI::log( json_encode( $summary ) );
if ( 'error' === ( $result['status'] ?? '' ) ) {
\WP_CLI::error( 'PRAB-SYNC: generation completed with error: ' . ( $result['message'] ?? 'unknown' ), false );
exit( 1 );
}
\WP_CLI::success( 'PRAB-SYNC: generation complete.' );
exit( 0 );
}
/**
* Run eval on the frozen dataset.
*

View File

@@ -80,7 +80,10 @@ class PRAutoBlogger_Activator {
'prautoblogger_table_borders' => '1',
'prautoblogger_schedule_time' => '03:00',
'prautoblogger_log_level' => 'info',
'prautoblogger_image_nsfw_retry' => '1',
'prautoblogger_image_nsfw_retry' => '1',
// M4 (v0.21.0): board column card limit + ideas page size -- settings-backed.
'prautoblogger_board_column_limit' => PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT,
'prautoblogger_ideas_per_page' => PRAUTOBLOGGER_DEFAULT_IDEAS_PER_PAGE,
);
foreach ( $defaults as $key => $value ) {

View File

@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* One-time and version-gated DB/option migrations for PRAutoBlogger.
*
* Extracted from PRAutoBlogger (class-prautoblogger.php) in v0.19.2 so the
* main orchestrator stays under the 300-line cap. All migration logic lives
* here; the orchestrator holds thin proxy methods for backward compat.
*
* Each migration is idempotent: guarded by a stored option flag so it never
* runs more than once per site.
*
* Triggered by: PRAutoBlogger::on_check_db_version() (admin_init)
* PRAutoBlogger::on_check_db_version_for_cron() (init, cron-only).
* Dependencies: PRAutoBlogger_Activator, PRAutoBlogger_Image_Model_Registry,
* PRAutoBlogger_Encryption, PRAutoBlogger_Migrate_Remove_Cloudflare_V0100.
*
* @see class-prautoblogger.php -- Orchestrator; holds proxy methods.
* @see class-activator.php -- Schema install / activate logic called here.
* @see ARCHITECTURE.md -- DB version history and migration discipline.
*/
class PRAutoBlogger_DB_Migrations {
/**
* Run all pending migrations.
*
* Called on `admin_init` (and on `init` for cron contexts). Idempotent.
*
* Side effects: may update database schema and wp_options rows.
*
* @return void
*/
public static function run(): void {
$stored_version = get_option( 'prautoblogger_db_version', '0' );
if ( version_compare( $stored_version, PRAUTOBLOGGER_DB_VERSION, '<' ) ) {
PRAutoBlogger_Activator::activate();
}
// One-time migration: switch to Gemini 2.5 Flash Lite for cost/speed.
if ( ! get_option( 'prautoblogger_migrated_gemini_flash_lite' ) ) {
update_option( 'prautoblogger_analysis_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL );
update_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
update_option( 'prautoblogger_editor_model', PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL );
update_option( 'prautoblogger_migrated_gemini_flash_lite', '1' );
}
// One-time migration (v0.8.0): the admin no longer has an independent
// Image Provider dropdown; provider is derived from the image model on
// save. Auto-heal any existing site where the saved provider doesn't
// match the saved model's provider. Runs once.
if ( ! get_option( 'prautoblogger_migrated_image_provider_v080' ) ) {
$saved_model = (string) get_option( 'prautoblogger_image_model', '' );
$provider = PRAutoBlogger_Image_Model_Registry::provider_for( $saved_model );
if ( '' !== $provider ) {
update_option( 'prautoblogger_image_provider', $provider );
}
update_option( 'prautoblogger_migrated_image_provider_v080', '1' );
}
// One-time migration (v0.8.2): reschedule the daily-generation cron in
// the site's configured timezone. Pre-v0.8.2 activator interpreted the
// admin "Generation Time" input as UTC; after v0.8.2 it honours the
// site timezone. Clears the stale UTC-scheduled event and re-queues
// using the timezone-aware helper. See class-activator.php.
PRAutoBlogger_Activator::reschedule_daily_in_site_timezone_v082();
// One-time migration (v3): switch to single-panel newspaper comic style.
if ( ! get_option( 'prautoblogger_migrated_style_suffix_v3' ) ) {
$known_old_prefixes = array(
'Style: a screengrab from a 1995', // v1: infomercial.
'Style: premium scientific lifestyle', // v2: photography.
);
$current = get_option( 'prautoblogger_image_style_suffix', '' );
$is_old = ( '' === $current );
foreach ( $known_old_prefixes as $prefix ) {
if ( false !== strpos( $current, $prefix ) ) {
$is_old = true;
break;
}
}
if ( $is_old ) {
update_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
}
update_option( 'prautoblogger_migrated_style_suffix_v3', '1' );
}
// One-time migration (v4): remove caption-in-image instruction from style suffix.
if ( ! get_option( 'prautoblogger_migrated_style_suffix_v4' ) ) {
$current = get_option( 'prautoblogger_image_style_suffix', '' );
if ( false !== strpos( $current, 'caption text in a clean sans-serif font below the panel' ) ) {
update_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
}
update_option( 'prautoblogger_migrated_style_suffix_v4', '1' );
}
// One-time migration (v0.16.0): editorial pivot -- comic Style Suffix
// replaced by editorial Style Template. Old value mirrored to a
// deprecated-keyed option for one version cycle.
if ( ! get_option( 'prautoblogger_migrated_style_template_v0160' ) ) {
$old_suffix = get_option( 'prautoblogger_image_style_suffix', '' );
if ( '' !== $old_suffix && false === get_option( 'prautoblogger_image_style_suffix_deprecated', false ) ) {
update_option( 'prautoblogger_image_style_suffix_deprecated', $old_suffix );
}
if ( '' === (string) get_option( 'prautoblogger_image_style_template', '' ) ) {
update_option( 'prautoblogger_image_style_template', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE );
}
update_option( 'prautoblogger_migrated_style_template_v0160', '1' );
}
// v0.9.0 -- Runware as default image model. v0.10.0 -- remove CF Workers AI.
PRAutoBlogger_Activator::migrate_default_image_v090();
PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
// One-time migration: re-wrap existing encrypted values with "enc:" prefix.
if ( ! get_option( 'prautoblogger_migrated_enc_prefix' ) ) {
$enc_options = array( 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json', 'prautoblogger_runware_api_key' );
foreach ( $enc_options as $opt ) {
$val = get_option( $opt, '' );
if ( '' !== $val && ! PRAutoBlogger_Encryption::is_encrypted( $val ) ) {
delete_option( $opt );
}
}
update_option( 'prautoblogger_migrated_enc_prefix', '1' );
}
// v0.22.1 self-heal: restore low-stakes maintenance cron events that were
// lost when the WP-Cron schedule vanished after the 06-15 deploy cycle.
// NOTE: wp-cron reliability on this Hostinger host is suspect (background
// PHP processes are killed at ~10 min). These low-stakes events (metrics,
// reaper, Runware sync) are acceptable on wp-cron; daily GENERATION is NOT
// re-registered here — the VPS systemd timer owns generation scheduling.
// The prautoblogger_migrated_schedule_tz_v082 guard is intentionally NOT
// re-used here; this migration targets the missing events themselves.
if ( ! get_option( 'prautoblogger_migrated_maintenance_crons_v0221' ) ) {
// Re-register maintenance crons only — daily_generation deliberately excluded.
// Activator::schedule_cron() uses wp_next_scheduled() guards so it is
// idempotent and will not overwrite events that are already present.
if ( ! wp_next_scheduled( 'prautoblogger_reap_orphan_research_rows' ) ) {
$tomorrow_reap = strtotime( 'tomorrow 03:15' );
if ( false !== $tomorrow_reap ) {
wp_schedule_event( (int) $tomorrow_reap, 'daily', 'prautoblogger_reap_orphan_research_rows' );
}
}
if ( ! wp_next_scheduled( 'prautoblogger_collect_metrics' ) ) {
wp_schedule_event( time() + HOUR_IN_SECONDS, 'prautoblogger_six_hours', 'prautoblogger_collect_metrics' );
}
if ( ! wp_next_scheduled( 'prautoblogger_sync_runware_models' ) ) {
wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'prautoblogger_sync_runware_models' );
}
update_option( 'prautoblogger_migrated_maintenance_crons_v0221', '1' );
}
}
}

View File

@@ -4,25 +4,26 @@ declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Cron/AJAX handlers for content generation, metrics collection, model registry.
* Cron handlers for content generation and metrics collection.
*
* What: Daily generation, manual generation, queued-article processing,
* and metrics collection cron handlers. AJAX generation handlers
* live in PRAutoBlogger_Generation_Status_Poller (v0.18.3 split).
* Triggered by: PRAutoBlogger hook registration.
* Dependencies: Pipeline_Runner, Metrics_Collector, Generation_Lock, Logger.
*
* @see class-prautoblogger.php — Hook wiring.
* @see core/class-pipeline-runner.php — Actual generation pipeline.
* @see class-generation-lock.php — DB mutex.
* @see class-generation-status-poller.php — "Generate Now" AJAX + status poll.
* @see class-prautoblogger.php — Hook wiring.
* @see core/class-pipeline-runner.php — Actual generation pipeline.
* @see class-generation-lock.php — DB mutex.
*/
class PRAutoBlogger_Executor {
/** @var PRAutoBlogger_OpenRouter_Model_Registry|null Lazy-loaded singleton. */
private ?PRAutoBlogger_OpenRouter_Model_Registry $model_registry = null;
/** Transient key for background generation status. */
private const STATUS_TRANSIENT = 'prautoblogger_generation_status';
/** How long to keep the result available for polling (seconds). */
private const STATUS_TTL = 600;
/** @var PRAutoBlogger_Generation_Status_Poller|null Lazy-loaded poller. */
private ?PRAutoBlogger_Generation_Status_Poller $poller = null;
/**
* Handle the daily generation cron event.
@@ -58,20 +59,6 @@ class PRAutoBlogger_Executor {
}
}
/**
* Mark this process's run failed after a fatal pipeline error
* (no-op outside a run; a governor-halted run stays halted — final
* states are sticky).
*
* @return void
*/
private function mark_current_run_failed(): void {
$run_id = PRAutoBlogger_Run_Context::current_run_id();
if ( null !== $run_id ) {
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
}
}
/**
* Handle the metrics collection cron event.
* Side effects: API calls to GA4, database writes to ab_content_scores.
@@ -89,144 +76,30 @@ class PRAutoBlogger_Executor {
/**
* AJAX handler: kick off manual generation as a background cron job.
*
* Returns immediately so the browser never hits Hostinger's 120-second
* connection timeout. The frontend polls on_ajax_generation_status()
* every few seconds to get progress and final results.
* Delegates to PRAutoBlogger_Generation_Status_Poller.
*
* Side effects: schedules a WP-Cron event, writes a status transient.
*/
public function on_ajax_generate_now(): void {
check_ajax_referer( 'prautoblogger_generate_now', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
return;
}
$force = isset( $_POST['force'] ) && '1' === $_POST['force'];
if ( $force ) {
PRAutoBlogger_Generation_Lock::release();
delete_transient( self::STATUS_TRANSIENT );
}
// Check if a run is already in progress.
$current = get_transient( self::STATUS_TRANSIENT );
if ( is_array( $current ) && 'running' === ( $current['status'] ?? '' ) ) {
wp_send_json_success(
array(
'background' => true,
'message' => __( 'Generation already in progress.', 'prautoblogger' ),
)
);
return;
}
// Write initial "running" status for the frontend to poll.
set_transient(
self::STATUS_TRANSIENT,
array(
'status' => 'running',
'stage' => __( 'Starting generation...', 'prautoblogger' ),
'started' => time(),
'last_updated' => time(),
),
self::STATUS_TTL
);
// Schedule immediate one-shot cron event. WordPress will fire this
// on the next page load (or via wp-cron.php) in a separate PHP process
// that is not subject to the 120-second connection timeout.
if ( ! wp_next_scheduled( 'prautoblogger_manual_generation' ) ) {
wp_schedule_single_event( time(), 'prautoblogger_manual_generation' );
}
// Spawn the cron immediately via a non-blocking loopback request
// so we don't depend on the next visitor to trigger it.
spawn_cron();
// Direct non-blocking loopback to wp-cron.php — ensures the event
// fires even if DISABLE_WP_CRON is true or spawn_cron() no-ops.
wp_remote_post(
site_url( 'wp-cron.php?doing_wp_cron=' . sprintf( '%.22F', microtime( true ) ) ),
array(
'timeout' => 0.01,
'blocking' => false,
'sslverify' => false,
)
);
wp_send_json_success(
array(
'background' => true,
'message' => __( 'Generation started in background. Polling for status...', 'prautoblogger' ),
)
);
$this->poller()->on_ajax_generate_now();
}
/**
* Cron handler: runs pipeline orchestration + first article.
* Cron handler: kick off the chained-cron orchestration tick (v0.21.0, M4).
*
* If the pipeline queues additional articles (2..N), they will fire as
* chained cron events — each in its own PHP process. The lock is released
* by the pipeline when the last article completes, not here.
* Delegates to Generation_Checkpoint_Runner::on_orchestrate_tick() which
* runs Tick 1 (collect -> analyze -> score), then schedules Tick 2..N for
* article generation. This replaces the former monolithic pipeline run and
* retires the SSH-only `do_action("prautoblogger_manual_generation")` workaround
* (see ARCHITECTURE.md ss25).
*
* Lock acquisition and status transient writes are fully owned by the
* checkpoint runner; this method is a thin routing wrapper.
*
* @return void
*/
public function on_manual_generation(): void {
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@ignore_user_abort( true );
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@set_time_limit( 300 );
if ( ! PRAutoBlogger_Generation_Lock::acquire() ) {
set_transient(
self::STATUS_TRANSIENT,
array(
'status' => 'error',
'message' => __( 'Could not acquire generation lock. Another run may be in progress.', 'prautoblogger' ),
),
self::STATUS_TTL
);
return;
}
try {
$this->update_generation_stage( __( 'Collecting sources from Reddit...', 'prautoblogger' ) );
$runner = ( new PRAutoBlogger_Pipeline_Runner() )->set_skip_dedup( true );
$results = $runner->run();
// If no articles were queued (target=1 or 0 ideas), finalize here.
// When articles ARE queued, the pipeline handles status + lock release.
if ( ! get_option( 'prautoblogger_article_queue' ) ) {
set_transient(
self::STATUS_TRANSIENT,
array(
'status' => 'complete',
'generated' => $results['generated'],
'published' => $results['published'],
'rejected' => $results['rejected'],
'cost' => $results['cost'],
),
self::STATUS_TTL
);
PRAutoBlogger_Generation_Lock::release();
}
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->error(
sprintf( 'Manual generation %s: %s', get_class( $e ), $e->getMessage() ),
'pipeline'
);
set_transient(
self::STATUS_TRANSIENT,
array(
'status' => 'error',
'message' => $e->getMessage(),
),
self::STATUS_TTL
);
$this->mark_current_run_failed();
PRAutoBlogger_Generation_Lock::release();
}
PRAutoBlogger_Generation_Checkpoint_Runner::on_orchestrate_tick();
}
/** Cron handler: process next queued article. Chained by Pipeline_Runner. */
@@ -247,89 +120,44 @@ class PRAutoBlogger_Executor {
delete_option( 'prautoblogger_article_queue' );
PRAutoBlogger_Generation_Lock::release();
set_transient(
self::STATUS_TRANSIENT,
PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
array(
'status' => 'error',
'message' => $e->getMessage(),
),
self::STATUS_TTL
PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
);
}
}
/** AJAX: return generation status for frontend polling. Recovers orphaned runs. */
/** AJAX: return generation status for frontend polling. Delegates to poller. */
public function on_ajax_generation_status(): void {
check_ajax_referer( 'prautoblogger_generate_now', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
return;
}
$status = get_transient( self::STATUS_TRANSIENT );
if ( ! is_array( $status ) ) {
wp_send_json_success( array( 'status' => 'idle' ) );
return;
}
if ( 'running' === $status['status'] ) {
$elapsed = time() - ( $status['started'] ?? 0 );
// Absolute timeout — give up after 10 minutes.
if ( $elapsed > self::STATUS_TTL ) {
$this->abort_orphaned_run( __( 'Generation timed out. Check the Activity Log.', 'prautoblogger' ) );
return;
}
// After 90s, check if a queued job died and needs re-scheduling.
if ( $elapsed > 90 && get_option( 'prautoblogger_article_queue' ) ) {
if ( ! wp_next_scheduled( PRAutoBlogger_Pipeline_Runner::CRON_ACTION ) ) {
wp_schedule_single_event( time(), PRAutoBlogger_Pipeline_Runner::CRON_ACTION );
spawn_cron();
}
}
// Stall = 5 min since last stage update (not since start).
$last_activity = $status['last_updated'] ?? $status['started'] ?? 0;
$idle_seconds = time() - $last_activity;
if ( $idle_seconds > 300 ) {
$this->abort_orphaned_run( __( 'Generation stalled. Check Activity Log.', 'prautoblogger' ) );
return;
}
}
wp_send_json_success( $status );
}
/** Clean up an orphaned generation run and report error. */
private function abort_orphaned_run( string $message ): void {
// v0.18.0: the dead run renders as failed/"incomplete" in audit
// queries instead of silently lingering as 'running'.
$queue = get_option( 'prautoblogger_article_queue' );
if ( is_array( $queue ) && ! empty( $queue['run_id'] ) ) {
PRAutoBlogger_Run_State::mark_status( (string) $queue['run_id'], 'failed' );
}
delete_transient( self::STATUS_TRANSIENT );
delete_option( 'prautoblogger_article_queue' );
PRAutoBlogger_Generation_Lock::release();
wp_send_json_success(
array(
'status' => 'error',
'message' => $message,
)
);
$this->poller()->on_ajax_generation_status();
}
/**
* Helper: update the generation stage for frontend polling.
* Lazy-load the generation status poller.
*
* @param string $stage Human-readable stage description.
* @return PRAutoBlogger_Generation_Status_Poller
*/
private function update_generation_stage( string $stage ): void {
$current = get_transient( self::STATUS_TRANSIENT );
if ( is_array( $current ) ) {
$current['stage'] = $stage;
$current['last_updated'] = time();
set_transient( self::STATUS_TRANSIENT, $current, self::STATUS_TTL );
private function poller(): PRAutoBlogger_Generation_Status_Poller {
if ( null === $this->poller ) {
$this->poller = new PRAutoBlogger_Generation_Status_Poller();
}
return $this->poller;
}
/**
* Mark this process's run failed after a fatal pipeline error
* (no-op outside a run; a governor-halted run stays halted — final
* states are sticky).
*
* @return void
*/
private function mark_current_run_failed(): void {
$run_id = PRAutoBlogger_Run_Context::current_run_id();
if ( null !== $run_id ) {
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
}
}

View File

@@ -76,6 +76,29 @@ class PRAutoBlogger_Generation_Lock {
return null !== $row;
}
/**
* Get the Unix timestamp when the lock was acquired, or null if not held.
*
* Returns null when the lock is not set. The timestamp is the value written
* by acquire() — used by the status poller to detect infrastructure-timeout
* runs (lock held longer than STATUS_TTL → background PHP process died).
*
* @return int|null Unix timestamp, or null when the lock is absent.
*/
public static function get_acquired_at(): ?int {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$row = $wpdb->get_var(
$wpdb->prepare(
"SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
self::LOCK_NAME
)
);
return null !== $row ? (int) $row : null;
}
/**
* Release the generation mutex.
*

View File

@@ -0,0 +1,260 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* AJAX handlers for "Generate Now" + status polling.
*
* What: Two AJAX endpoints: kick-off (fire-and-forget cron spawn) and the
* status poll that drives the admin UI spinner. Extracted from
* PRAutoBlogger_Executor in v0.18.3 to keep each file under 300 lines.
*
* R2/R3 resilience (v0.18.3):
* - R2(a): on_ajax_generate_now() renews the transient with the
* full STATUS_TTL on every stage update via update_generation_stage().
* - R2(b): on_ajax_generation_status() detects lock-held-but-transient-gone
* when the lock has been held > STATUS_TTL seconds (Hostinger background-
* process death); marks the stuck run failed + surfaces a human-readable
* "infrastructure timeout" message for the Activity Log.
* - R3: on_ajax_generation_status() returns status:running (with
* started_at) when the transient is absent but the lock IS held and
* within TTL — so the button reflects reality during long runs rather
* than silently resetting to idle.
*
* Who calls it: PRAutoBlogger hook registration (wp_ajax_prautoblogger_*).
* Dependencies: PRAutoBlogger_Generation_Lock, PRAutoBlogger_Run_State,
* PRAutoBlogger_Run_Context, PRAutoBlogger_Pipeline_Runner,
* PRAutoBlogger_Logger.
*
* @see class-executor.php — Cron handlers (daily, manual, queued).
* @see class-generation-lock.php — DB mutex (acquire/release/timestamps).
* @see core/class-pipeline-status.php — Transient broadcast helpers.
* @see ARCHITECTURE.md #21 — Run lifecycle and reaper design.
*/
class PRAutoBlogger_Generation_Status_Poller {
/** Transient key for background generation status (shared with Pipeline_Status). */
public const STATUS_TRANSIENT = 'prautoblogger_generation_status';
/**
* How long (seconds) to keep the status transient readable.
* Renewed on every stage transition so a long run doesn't expire it early.
*/
public const STATUS_TTL = 600;
/**
* AJAX handler: kick off manual generation as a background cron job.
*
* Returns immediately so the browser never hits Hostinger's connection
* timeout. The frontend polls on_ajax_generation_status() every few
* seconds for progress and final results.
*
* Side effects: schedules a WP-Cron event, writes a status transient.
*/
public function on_ajax_generate_now(): void {
check_ajax_referer( 'prautoblogger_generate_now', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
return;
}
$force = isset( $_POST['force'] ) && '1' === $_POST['force'];
if ( $force ) {
PRAutoBlogger_Generation_Lock::release();
delete_transient( self::STATUS_TRANSIENT );
}
// Check if a run is already in progress.
$current = get_transient( self::STATUS_TRANSIENT );
if ( is_array( $current ) && 'running' === ( $current['status'] ?? '' ) ) {
wp_send_json_success(
array(
'background' => true,
'message' => __( 'Generation already in progress.', 'prautoblogger' ),
)
);
return;
}
// Write initial "running" status for the frontend to poll.
set_transient(
self::STATUS_TRANSIENT,
array(
'status' => 'running',
'stage' => __( 'Starting generation...', 'prautoblogger' ),
'started' => time(),
'last_updated' => time(),
),
self::STATUS_TTL
);
// Schedule immediate one-shot cron event. WordPress will fire this
// on the next page load (or via wp-cron.php) in a separate PHP process
// not subject to the connection timeout.
if ( ! wp_next_scheduled( 'prautoblogger_manual_generation' ) ) {
wp_schedule_single_event( time(), 'prautoblogger_manual_generation' );
}
// Spawn the cron immediately via a non-blocking loopback request
// so we don't depend on the next visitor to trigger it.
spawn_cron();
// Direct non-blocking loopback to wp-cron.php — ensures the event
// fires even if DISABLE_WP_CRON is true or spawn_cron() no-ops.
wp_remote_post(
site_url( 'wp-cron.php?doing_wp_cron=' . sprintf( '%.22F', microtime( true ) ) ),
array(
'timeout' => 0.01,
'blocking' => false,
'sslverify' => false,
)
);
wp_send_json_success(
array(
'background' => true,
'message' => __( 'Generation started in background. Polling for status...', 'prautoblogger' ),
)
);
}
/** AJAX: return generation status for frontend polling. Recovers orphaned runs. */
public function on_ajax_generation_status(): void {
check_ajax_referer( 'prautoblogger_generate_now', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
return;
}
$status = get_transient( self::STATUS_TRANSIENT );
// R2(b) + R3: transient is gone — check the lock before returning idle.
if ( ! is_array( $status ) ) {
$this->handle_missing_transient();
return;
}
if ( 'running' === $status['status'] ) {
$elapsed = time() - ( $status['started'] ?? 0 );
// Absolute timeout — give up after STATUS_TTL.
if ( $elapsed > self::STATUS_TTL ) {
$this->abort_orphaned_run( __( 'Generation timed out. Check the Activity Log.', 'prautoblogger' ) );
return;
}
// After 90s, check if a queued job died and needs re-scheduling.
if ( $elapsed > 90 && get_option( 'prautoblogger_article_queue' ) ) {
if ( ! wp_next_scheduled( PRAutoBlogger_Pipeline_Runner::CRON_ACTION ) ) {
wp_schedule_single_event( time(), PRAutoBlogger_Pipeline_Runner::CRON_ACTION );
spawn_cron();
}
}
// Stall = 5 min since last stage update (not since start).
$last_activity = $status['last_updated'] ?? $status['started'] ?? 0;
$idle_seconds = time() - $last_activity;
if ( $idle_seconds > 300 ) {
$this->abort_orphaned_run( __( 'Generation stalled. Check Activity Log.', 'prautoblogger' ) );
return;
}
}
wp_send_json_success( $status );
}
/**
* Handle the case where the status transient is absent.
*
* R3: if lock is HELD and within TTL → return status:running so the
* button reflects reality during long background runs.
* R2(b): if lock is held but has been held > STATUS_TTL → the background
* PHP process died without releasing it; mark the run failed and
* surface "infrastructure timeout" to the UI.
*
* @return void Sends JSON and exits.
*/
private function handle_missing_transient(): void {
$acquired_at = PRAutoBlogger_Generation_Lock::get_acquired_at();
if ( null === $acquired_at ) {
// Lock not held — genuinely idle.
wp_send_json_success( array( 'status' => 'idle' ) );
return;
}
$lock_age = time() - $acquired_at;
if ( $lock_age > self::STATUS_TTL ) {
// Lock held longer than STATUS_TTL: background process died.
// R2(b): mark the stuck run failed + surface a clear error.
PRAutoBlogger_Logger::instance()->warning(
sprintf(
'Status transient absent and lock held for %ds (> %ds TTL) — background process died; marking run failed.',
$lock_age,
self::STATUS_TTL
),
'pipeline'
);
$this->abort_orphaned_run(
__( 'Generation failed — infrastructure timeout. Check Activity Log for details.', 'prautoblogger' )
);
return;
}
// R3: lock is fresh (within TTL) but transient is gone — long run in
// progress (pipeline broadcast hasn't fired yet, or transient was
// evicted by object-cache pressure). Return running so the button
// does not silently reset to idle.
wp_send_json_success(
array(
'status' => 'running',
'stage' => __( 'Generation in progress (background). Check Activity Log for details.', 'prautoblogger' ),
'started' => $acquired_at,
'last_updated' => $acquired_at,
)
);
}
/** Clean up an orphaned generation run and report error. */
private function abort_orphaned_run( string $message ): void {
// Mark the stuck run as failed in the audit ledger.
$queue = get_option( 'prautoblogger_article_queue' );
if ( is_array( $queue ) && ! empty( $queue['run_id'] ) ) {
PRAutoBlogger_Run_State::mark_status( (string) $queue['run_id'], 'failed' );
}
// Also attempt to close the current active run from Run_Context.
$run_id = PRAutoBlogger_Run_Context::current_run_id();
if ( null !== $run_id ) {
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
}
delete_transient( self::STATUS_TRANSIENT );
delete_option( 'prautoblogger_article_queue' );
PRAutoBlogger_Generation_Lock::release();
wp_send_json_success(
array(
'status' => 'error',
'message' => $message,
)
);
}
/**
* Update the generation stage text for frontend polling.
* Renews the transient TTL at every call so a long run doesn't expire it.
*
* @param string $stage Human-readable stage description.
*/
public function update_generation_stage( string $stage ): void {
$current = get_transient( self::STATUS_TRANSIENT );
if ( is_array( $current ) ) {
$current['stage'] = $stage;
$current['last_updated'] = time();
// R2(a): renew the TTL on every stage update so the transient
// outlives any single LLM call (STATUS_TTL from last activity).
set_transient( self::STATUS_TRANSIENT, $current, self::STATUS_TTL );
}
}
}

View File

@@ -4,12 +4,14 @@ declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Creates / updates the Pipeline v2 substrate tables via `dbDelta` (db 1.2.0).
* Creates / updates the Pipeline v2 substrate tables via `dbDelta` (db 1.3.0).
*
* What: Owns the five v0.18.0 substrate tables — the versioned prompt
* What: Owns the six pipeline substrate tables — the versioned prompt
* registry (`prompts`), the audit child tables (`run_sources`,
* `run_decisions`), and the run ledger / state machine tables
* (`runs`, `run_stages`). Kept separate from the original
* (`runs`, `run_stages`), and the immutable stage-input version
* store (`stage_inputs`, v0.20.0 / db 1.3.0 — edit + re-run forks
* and idea seeds). Kept separate from the original
* PRAutoBlogger_Schema_Installer so both classes stay under the
* 300-line cap and the v1.1.0 schema remains byte-stable.
* Who triggers it: PRAutoBlogger_Activator::activate() — on activation and
@@ -33,7 +35,7 @@ class PRAutoBlogger_Pipeline_Schema_Installer {
* plugin's forward-only schema policy. Safe to re-run on every
* version mismatch.
*
* Side effects: up to five CREATE TABLE / ALTER TABLE statements.
* Side effects: up to six CREATE TABLE / ALTER TABLE statements.
*
* @return void
*/
@@ -91,6 +93,7 @@ class PRAutoBlogger_Pipeline_Schema_Installer {
verdict VARCHAR(50) NOT NULL,
rationale TEXT DEFAULT NULL,
citation_score FLOAT DEFAULT NULL,
human_modified TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY run_id (run_id)
@@ -112,7 +115,8 @@ class PRAutoBlogger_Pipeline_Schema_Installer {
updated_at DATETIME NOT NULL,
finished_at DATETIME DEFAULT NULL,
PRIMARY KEY (run_id),
KEY status (status)
KEY status (status),
KEY started_at (started_at)
) {$charset_collate};";
// Per-run per-stage state rows. The idempotency key is
@@ -130,6 +134,8 @@ class PRAutoBlogger_Pipeline_Schema_Installer {
attempt SMALLINT UNSIGNED NOT NULL DEFAULT 1,
cost_usd DECIMAL(10,6) NOT NULL DEFAULT 0,
meta_json LONGTEXT DEFAULT NULL,
human_modified TINYINT(1) NOT NULL DEFAULT 0,
stale TINYINT(1) NOT NULL DEFAULT 0,
started_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
finished_at DATETIME DEFAULT NULL,
@@ -138,10 +144,37 @@ class PRAutoBlogger_Pipeline_Schema_Installer {
KEY status_updated (status, updated_at)
) {$charset_collate};";
// Immutable stage-input version store (v0.20.0 / db 1.3.0, M3
// edit + re-run). Rows are INSERT-only: a human edit creates the
// next (scope, version) row -- the original input (the
// generation_log.request_json of the executed call) is never
// overwritten (CPO guardrail 1). source='seed' rows persist the
// Article_Idea payload at worker start so re-run-from-here can
// reconstruct the exact idea (item_key hash stability); 'human'
// rows are edit forks. Human fork bodies are retention-pruned
// like all heavy payloads; seed rows (~1KB) are structural and
// kept for the life of the run.
$sql_stage_inputs = "CREATE TABLE {$prefix}stage_inputs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
run_id VARCHAR(36) NOT NULL,
stage VARCHAR(50) NOT NULL DEFAULT '',
agent_role VARCHAR(50) NOT NULL DEFAULT '',
item_key VARCHAR(64) NOT NULL DEFAULT '',
version INT UNSIGNED NOT NULL DEFAULT 1,
source VARCHAR(20) NOT NULL DEFAULT 'human',
request_json LONGTEXT DEFAULT NULL,
author VARCHAR(100) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY scope_version (run_id, stage, agent_role, item_key, version),
KEY run_id (run_id)
) {$charset_collate};";
dbDelta( $sql_prompts );
dbDelta( $sql_run_sources );
dbDelta( $sql_run_decisions );
dbDelta( $sql_runs );
dbDelta( $sql_run_stages );
dbDelta( $sql_stage_inputs );
}
}

View File

@@ -6,20 +6,12 @@ declare(strict_types=1);
*
* Main orchestrator for the PRAutoBlogger plugin.
*
* Registers all WordPress hooks (actions and filters) and wires up dependencies.
* This is the ONLY place hooks are registered — individual classes contain only
* business logic, not hook registration.
* Registers all WordPress hooks and wires up dependencies.
* DB/option migrations extracted to PRAutoBlogger_DB_Migrations (v0.19.2).
*
* Triggered by: prautoblogger() singleton in prautoblogger.php, called on `plugins_loaded`.
* Dependencies: All other plugin classes (loaded via autoloader), PRAutoBlogger_Executor.
*
* @see prautoblogger.php — Plugin bootstrap that instantiates this class.
* @see class-executor.php — Cron/AJAX handlers for generation, model registry.
* @see class-ajax-handlers.php — Non-generation AJAX endpoints (images, models, test).
* @see class-generation-lock.php — DB mutex for single-writer generation.
* @see core/class-pipeline-runner.php — Executes the generation pipeline.
* @see ARCHITECTURE.md — Data flow diagram showing how classes interact.
* @see CONVENTIONS.md — Hook naming conventions.
* @see prautoblogger.php -- Plugin bootstrap.
* @see class-db-migrations.php -- One-time DB/option migrations.
* @see ARCHITECTURE.md -- Data flow diagram.
*/
class PRAutoBlogger {
@@ -28,14 +20,12 @@ class PRAutoBlogger {
/** @var PRAutoBlogger_Executor Handles generation cron/AJAX, model registry. */
private PRAutoBlogger_Executor $executor;
/** @var PRAutoBlogger_Ajax_Handlers Handles non-generation AJAX (images, models, test). */
/** @var PRAutoBlogger_Ajax_Handlers Handles non-generation AJAX. */
private PRAutoBlogger_Ajax_Handlers $ajax_handlers;
/**
* Register all hooks and initialize the plugin.
* Called once on `plugins_loaded`. Idempotent.
*
* Side effects: registers WordPress actions and filters.
*/
public function run(): void {
if ( $this->initialized ) {
@@ -46,9 +36,7 @@ class PRAutoBlogger {
$this->ajax_handlers = new PRAutoBlogger_Ajax_Handlers( $this->executor->get_model_registry() );
add_action( 'admin_init', array( $this, 'on_check_db_version' ) );
// v0.18.0: cron requests must also self-heal the schema — after a
// deploy, scheduled generation can run before any admin page load,
// and new-column inserts would silently fail on the old schema.
// v0.18.0: cron requests must also self-heal the schema.
add_action( 'init', array( $this, 'on_check_db_version_for_cron' ) );
add_filter( 'cron_schedules', array( $this, 'filter_add_cron_schedules' ) );
@@ -65,13 +53,30 @@ class PRAutoBlogger {
do_action( 'prautoblogger_loaded' );
}
/** Register admin-only hooks (settings, notices, metabox, dashboard widget). */
/**
* Register admin-only hooks (settings, notices, metabox, board, dossier, widget).
*
* Menu-ordering rule (v0.19.1): parent menu must register at priority 10 before
* any submenu (priority 11+). WordPress resolves the submenu hookname via
* get_plugin_page_hookname() at add_submenu_page() time; if the parent slot in
* $admin_page_hooks is unset, WP falls back to admin_page_* -> 404 at request
* time. See ARCHITECTURE.md §Board for the full trace.
*/
private function register_admin_hooks(): void {
$admin_page = new PRAutoBlogger_Admin_Page();
add_action( 'admin_menu', array( $admin_page, 'on_register_menu' ) );
add_action( 'admin_menu', array( $admin_page, 'on_register_menu' ), 10 );
add_action( 'admin_init', array( $admin_page, 'on_register_settings' ) );
add_action( 'admin_enqueue_scripts', array( $admin_page, 'on_enqueue_assets' ) );
$board_page = new PRAutoBlogger_Board_Page();
add_action( 'admin_menu', array( $board_page, 'on_register_menu' ), 11 );
add_action( 'admin_enqueue_scripts', array( $board_page, 'on_enqueue_assets' ) );
// Dossier: link-accessed hidden submenu, priority 12 (after board).
$dossier_page = new PRAutoBlogger_Dossier_Page();
add_action( 'admin_menu', array( $dossier_page, 'on_register_menu' ), 12 );
add_action( 'admin_enqueue_scripts', array( $dossier_page, 'on_enqueue_assets' ) );
add_action( 'admin_notices', array( new PRAutoBlogger_Admin_Notices(), 'on_display_notices' ) );
add_action( 'add_meta_boxes', array( new PRAutoBlogger_Post_Metabox(), 'on_register_metabox' ) );
add_action( 'admin_menu', array( new PRAutoBlogger_Metrics_Page(), 'on_register_menu' ) );
@@ -86,7 +91,6 @@ class PRAutoBlogger {
( new PRAutoBlogger_Post_List_Columns() )->register();
// Block false update notifications — our slug collides with another plugin.
add_filter( 'site_transient_update_plugins', array( $this, 'filter_block_false_updates' ) );
}
@@ -106,13 +110,7 @@ class PRAutoBlogger {
private function register_cron_hooks(): void {
add_action( 'prautoblogger_daily_generation', array( $this->executor, 'on_daily_generation' ) );
add_action( 'prautoblogger_collect_metrics', array( $this->executor, 'on_collect_metrics' ) );
// Manual "Generate Now" runs as a one-shot cron event to avoid
// Hostinger's 120-second web-server connection timeout.
add_action( 'prautoblogger_manual_generation', array( $this->executor, 'on_manual_generation' ) );
// Chained article generation — each queued article fires as its own
// cron event so it gets a fresh PHP process and execution time budget.
add_action(
PRAutoBlogger_Pipeline_Runner::CRON_ACTION,
array( $this->executor, 'on_process_article_queue' )
@@ -120,59 +118,48 @@ class PRAutoBlogger {
$registry = $this->executor->get_model_registry();
add_action( 'prautoblogger_refresh_model_registry', array( $registry, 'refresh' ) );
// v0.15.0: daily Runware model catalog sync to keep the live model list fresh.
add_action( 'prautoblogger_sync_runware_models', array( new PRAutoBlogger_Runware_Model_Catalog(), 'sync' ) );
// Opik observability: async trace/span dispatch.
add_action(
'prautoblogger_opik_dispatch',
static function (): void {
if ( ! defined( 'PRAUTOBLOGGER_OPIK_API_KEY' ) || empty( PRAUTOBLOGGER_OPIK_API_KEY ) ) {
return;
}
$client = new PRAutoBlogger_Opik_Client(
$client = new PRAutoBlogger_Opik_Client(
PRAUTOBLOGGER_OPIK_API_KEY,
PRAUTOBLOGGER_OPIK_WORKSPACE,
PRAUTOBLOGGER_OPIK_URL_OVERRIDE
);
$queue = new PRAutoBlogger_Opik_Span_Queue();
$dispatcher = new PRAutoBlogger_Opik_Dispatcher( $client, $queue );
$dispatcher = new PRAutoBlogger_Opik_Dispatcher( $client, new PRAutoBlogger_Opik_Span_Queue() );
$dispatcher->dispatch();
update_option( 'prautoblogger_opik_last_dispatch', time() );
}
);
// Opik settings registration.
if ( is_admin() ) {
new PRAutoBlogger_Opik_Settings();
}
// Single-idea generation from the Ideas browser page.
// v0.20.0 (M3): operator re-run jobs — chained-cron, never synchronous.
$rerun_executor = new PRAutoBlogger_Rerun_Executor();
add_action( PRAutoBlogger_Rerun_Executor::REPLAY_ACTION, array( $rerun_executor, 'on_replay_job' ), 10, 5 );
add_action( PRAutoBlogger_Rerun_Executor::REBUILD_ACTION, array( $rerun_executor, 'on_rebuild_job' ), 10, 4 );
// v0.21.0 (M4): chained-cron checkpoint ticks for new article generation.
add_action( PRAutoBlogger_Generation_Checkpoint_Runner::ORCHESTRATE_ACTION, array( 'PRAutoBlogger_Generation_Checkpoint_Runner', 'on_orchestrate_tick' ) );
add_action( PRAutoBlogger_Generation_Checkpoint_Runner::GENERATE_ACTION, array( 'PRAutoBlogger_Generation_Checkpoint_Runner', 'on_generate_tick' ) );
add_action( 'prautoblogger_generate_from_idea', array( 'PRAutoBlogger_Ideas_Browser', 'on_cron_generate_from_idea' ) );
// v0.8.1: daily reaper for orphan `llm_research` rows (when a
// pipeline dies before amortize_research_costs runs).
add_action( 'prautoblogger_reap_orphan_research_rows', array( 'PRAutoBlogger_Research_Reaper', 'on_cron' ) );
// v0.18.0: stuck-run sweep + audit-payload retention rides the SAME
// existing daily cron (no new schedule). See class-run-reaper.php.
add_action( 'prautoblogger_reap_orphan_research_rows', array( 'PRAutoBlogger_Run_Reaper', 'on_cron' ) );
// v0.8.1: WP-CLI manual trigger for the reaper. Only registers when
// WP-CLI is present; no-op in normal HTTP requests.
if ( defined( 'WP_CLI' ) && WP_CLI ) {
\WP_CLI::add_command(
'prautoblogger reap-research',
static function (): void {
$stats = PRAutoBlogger_Research_Reaper::reap();
\WP_CLI::success(
sprintf(
'Reaped %d, deleted %d (stale), skipped %d.',
(int) $stats['reaped'],
(int) $stats['deleted'],
(int) $stats['skipped']
)
);
\WP_CLI::success( sprintf( 'Reaped %d, deleted %d, skipped %d.', (int) $stats['reaped'], (int) $stats['deleted'], (int) $stats['skipped'] ) );
}
);
}
@@ -180,11 +167,9 @@ class PRAutoBlogger {
/** Register AJAX handlers for admin actions. */
private function register_ajax_hooks(): void {
// Generation AJAX (start + status polling) stays on executor.
add_action( 'wp_ajax_prautoblogger_generate_now', array( $this->executor, 'on_ajax_generate_now' ) );
add_action( 'wp_ajax_prautoblogger_generation_status', array( $this->executor, 'on_ajax_generation_status' ) );
// Non-generation AJAX (images, models, test) on dedicated handler.
add_action( 'wp_ajax_prautoblogger_generate_image', array( $this->ajax_handlers, 'on_ajax_generate_image' ) );
add_action( 'wp_ajax_prautoblogger_test_connection', array( $this->ajax_handlers, 'on_ajax_test_connection' ) );
add_action( 'wp_ajax_prautoblogger_get_models', array( $this->ajax_handlers, 'on_ajax_get_models' ) );
@@ -196,6 +181,14 @@ class PRAutoBlogger {
add_action( 'wp_ajax_prautoblogger_reject_post', array( $review_queue, 'on_ajax_reject_post' ) );
add_action( 'wp_ajax_prautoblogger_clear_logs', array( new PRAutoBlogger_Log_Viewer(), 'on_ajax_clear_logs' ) );
add_action( 'wp_ajax_' . PRAutoBlogger_Board_Page::AJAX_ACTION, array( new PRAutoBlogger_Board_Page(), 'on_ajax_board_status' ) );
// v0.20.0 (M3): dossier edit + re-run endpoints (validate + queue only).
$dossier_actions = new PRAutoBlogger_Dossier_Actions();
add_action( 'wp_ajax_prautoblogger_dossier_save_input', array( $dossier_actions, 'on_save_input' ) );
add_action( 'wp_ajax_prautoblogger_dossier_rerun_stage', array( $dossier_actions, 'on_rerun_stage' ) );
add_action( 'wp_ajax_prautoblogger_dossier_rerun_from', array( $dossier_actions, 'on_rerun_from' ) );
add_action( 'wp_ajax_prautoblogger_dossier_stage_status', array( $dossier_actions, 'on_stage_status' ) );
$ideas = new PRAutoBlogger_Ideas_Browser();
add_action( 'wp_ajax_prautoblogger_generate_from_idea', array( $ideas, 'on_ajax_generate_from_idea' ) );
@@ -203,121 +196,16 @@ class PRAutoBlogger {
}
/**
* Check if database needs migration after plugin update.
* Compares stored DB version with PRAUTOBLOGGER_DB_VERSION; re-runs activation if different.
*
* Side effects: may update database schema and db_version option.
* Check if database needs migration. Delegates to PRAutoBlogger_DB_Migrations::run().
* Hooked on `admin_init`.
*/
public function on_check_db_version(): void {
$stored_version = get_option( 'prautoblogger_db_version', '0' );
if ( version_compare( $stored_version, PRAUTOBLOGGER_DB_VERSION, '<' ) ) {
PRAutoBlogger_Activator::activate();
}
// One-time migration: switch to Gemini 2.5 Flash Lite for cost/speed.
if ( ! get_option( 'prautoblogger_migrated_gemini_flash_lite' ) ) {
update_option( 'prautoblogger_analysis_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL );
update_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
update_option( 'prautoblogger_editor_model', PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL );
update_option( 'prautoblogger_migrated_gemini_flash_lite', '1' );
}
// One-time migration (v0.8.0): the admin no longer has an independent
// Image Provider dropdown; provider is derived from the image model on
// save. Auto-heal any existing site where the saved provider doesn't
// match the saved model's provider (the root cause of posts 650/657
// silently missing their featured image on 2026-04-20). Runs once.
if ( ! get_option( 'prautoblogger_migrated_image_provider_v080' ) ) {
$saved_model = (string) get_option( 'prautoblogger_image_model', '' );
$provider = PRAutoBlogger_Image_Model_Registry::provider_for( $saved_model );
if ( '' !== $provider ) {
update_option( 'prautoblogger_image_provider', $provider );
}
update_option( 'prautoblogger_migrated_image_provider_v080', '1' );
}
// One-time migration (v0.8.2): reschedule the daily-generation cron in
// the site's configured timezone. Pre-v0.8.2 activator interpreted the
// admin "Generation Time" input as UTC; after v0.8.2 it honours the
// site timezone. Clears the stale UTC-scheduled event and re-queues
// using the timezone-aware helper. See class-activator.php.
PRAutoBlogger_Activator::reschedule_daily_in_site_timezone_v082();
// One-time migration (v3): switch to single-panel newspaper comic style.
// Replaces both the old infomercial pastiche and the short-lived premium
// photography style. Force-update unless the user has a truly custom value.
if ( ! get_option( 'prautoblogger_migrated_style_suffix_v3' ) ) {
$known_old_prefixes = array(
'Style: a screengrab from a 1995', // v1: infomercial.
'Style: premium scientific lifestyle', // v2: photography.
);
$current = get_option( 'prautoblogger_image_style_suffix', '' );
$is_old = ( '' === $current );
foreach ( $known_old_prefixes as $prefix ) {
if ( false !== strpos( $current, $prefix ) ) {
$is_old = true;
break;
}
}
if ( $is_old ) {
update_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
}
update_option( 'prautoblogger_migrated_style_suffix_v3', '1' );
}
// One-time migration (v4): remove caption-in-image instruction from style suffix.
// Captions are now inserted as HTML below the image, not baked into the image.
if ( ! get_option( 'prautoblogger_migrated_style_suffix_v4' ) ) {
$current = get_option( 'prautoblogger_image_style_suffix', '' );
// Detect v3 style suffix (contains "caption text" instruction).
if ( false !== strpos( $current, 'caption text in a clean sans-serif font below the panel' ) ) {
update_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
}
update_option( 'prautoblogger_migrated_style_suffix_v4', '1' );
}
// One-time migration (v0.16.0): editorial pivot. The comic Style Suffix
// is replaced by the editorial Style Template. Mirror the old suffix
// value into a deprecated-keyed option for one version cycle (so a
// customised value is recoverable / auditable) and seed the new
// template with its editorial default. The old option is intentionally
// NOT deleted this cycle. See docs/proposals/2026-05-29-image-pipeline-in-plugin-brief.md §8 Commit 1.
if ( ! get_option( 'prautoblogger_migrated_style_template_v0160' ) ) {
$old_suffix = get_option( 'prautoblogger_image_style_suffix', '' );
if ( '' !== $old_suffix && false === get_option( 'prautoblogger_image_style_suffix_deprecated', false ) ) {
update_option( 'prautoblogger_image_style_suffix_deprecated', $old_suffix );
}
// Only seed the new template if the admin has not already set one.
if ( '' === (string) get_option( 'prautoblogger_image_style_template', '' ) ) {
update_option( 'prautoblogger_image_style_template', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE );
}
update_option( 'prautoblogger_migrated_style_template_v0160', '1' );
}
// v0.9.0 — Runware as default image model. v0.10.0 — remove CF Workers AI.
PRAutoBlogger_Activator::migrate_default_image_v090();
PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
// One-time migration: re-wrap existing encrypted values with "enc:" prefix.
if ( ! get_option( 'prautoblogger_migrated_enc_prefix' ) ) {
$enc_options = array( 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json', 'prautoblogger_runware_api_key' );
foreach ( $enc_options as $opt ) {
$val = get_option( $opt, '' );
if ( '' !== $val && ! PRAutoBlogger_Encryption::is_encrypted( $val ) ) {
delete_option( $opt );
}
}
update_option( 'prautoblogger_migrated_enc_prefix', '1' );
}
PRAutoBlogger_DB_Migrations::run();
}
/**
* Run the db-version check on cron requests (admin_init never fires
* there). Gated to wp_doing_cron() so normal frontend requests pay
* nothing beyond one autoloaded option read.
*
* Side effects: may run the same migrations as on_check_db_version().
*
* @return void
* Run DB migrations during cron requests (hooked on `init`).
* Only fires when wp_doing_cron() is true.
*/
public function on_check_db_version_for_cron(): void {
if ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ) {
@@ -328,7 +216,7 @@ class PRAutoBlogger {
/**
* Add custom cron schedules (six-hourly for metrics collection).
*
* @param array<string, array{interval: int, display: string}> $schedules Existing schedules.
* @param array<string, array{interval: int, display: string}> $schedules Existing.
* @return array<string, array{interval: int, display: string}>
*/
public function filter_add_cron_schedules( array $schedules ): array {
@@ -346,7 +234,6 @@ class PRAutoBlogger {
/**
* Block false update notifications from wordpress.org.
* Our plugin slug may collide with a different plugin in the directory.
*
* @param object $transient The update_plugins transient data.
* @return object Modified transient.
@@ -363,7 +250,7 @@ class PRAutoBlogger {
return $this->executor;
}
// ── Backward-compatible proxies ──
// -- Backward-compatible proxies --
/** @see PRAutoBlogger_Executor::on_daily_generation() */
public function on_daily_generation(): void {

View File

@@ -71,7 +71,8 @@ class PRAutoBlogger_Schema_Installer {
metadata_json LONGTEXT DEFAULT NULL,
PRIMARY KEY (id),
KEY type_relevance (analysis_type, relevance_score),
KEY analyzed_at (analyzed_at)
KEY analyzed_at (analyzed_at),
KEY analyzed_type (analyzed_at, analysis_type)
) {$charset_collate};";
// Generation log — every API call with cost tracking. `run_id`
@@ -100,7 +101,9 @@ class PRAutoBlogger_Schema_Installer {
KEY post_id (post_id),
KEY run_id (run_id),
KEY created_at (created_at),
KEY stage (stage)
KEY stage (stage),
KEY run_id_post_id (run_id, post_id),
KEY created_status (created_at, response_status)
) {$charset_collate};";
// Content scores — post performance metrics for self-improvement.

View File

@@ -76,9 +76,9 @@ class PRAutoBlogger_Analysis_Prompts {
global $wpdb;
$scores_table = $wpdb->prefix . 'prautoblogger_content_scores';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$top_posts = $wpdb->get_results(
"SELECT cs.post_id, cs.composite_score, p.post_title // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT cs.post_id, cs.composite_score, p.post_title
FROM {$scores_table} cs
JOIN {$wpdb->posts} p ON p.ID = cs.post_id
WHERE cs.composite_score > 0

View File

@@ -58,6 +58,13 @@ class PRAutoBlogger_Article_Worker {
$run_id = (string) ( $this->cost_tracker->get_run_id() ?? '' );
$item = PRAutoBlogger_Run_Stage_State::item_key_for_idea( $idea );
// v0.20.0: persist the EXACT idea payload once per item so
// re-run-from-here can reconstruct it (rebuilding from post
// fields would risk an item_key hash mismatch -> duplicate post).
if ( '' !== $run_id ) {
PRAutoBlogger_Stage_Input_Store::save_seed( $run_id, $item, (string) wp_json_encode( $idea->to_array() ) );
}
$result = array(
'generated' => 0,
'published' => 0,
@@ -103,7 +110,7 @@ class PRAutoBlogger_Article_Worker {
$review = $this->review_with_state( $editor, $content, $idea, $run_id, $item );
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Saving and publishing…', 'prautoblogger' ) );
PRAutoBlogger_Run_Stage_State::start( $run_id, 'publish', '', $item );
PRAutoBlogger_Run_Stage_State::start( $run_id, 'publish', (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( 'publish' ), $item );
$this->publish_or_draft(
$content,
$idea,
@@ -174,7 +181,7 @@ class PRAutoBlogger_Article_Worker {
return new PRAutoBlogger_Editorial_Review( $data );
}
}
PRAutoBlogger_Run_Stage_State::start( $run_id, 'review', '', $item );
PRAutoBlogger_Run_Stage_State::start( $run_id, 'review', (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( 'review' ), $item );
}
$review = $editor->review( $content, $idea );

View File

@@ -125,6 +125,37 @@ class PRAutoBlogger_Audit_Writer {
}
}
/**
* Flag the run_decisions rows of given stages as human-modified
* (v0.20.0 / CPO guardrail 2). Used in two cases: the decision rows of
* a stage whose edited input enters execution, and decisions recorded
* during a re-run-from-here window on an item that carries a human
* edit (derived-from-edited-content). Self-healing no-op when the
* column/table is missing.
*
* @param string $run_id Run UUID.
* @param array<string> $stages Stage names to flag.
* @param string|null $since Optional MySQL datetime — only flag rows created at/after it.
* @return int Rows flagged.
*/
public static function flag_decisions_human_modified( string $run_id, array $stages, ?string $since = null ): int {
if ( '' === $run_id || empty( $stages ) || ! self::is_available() ) {
return 0;
}
global $wpdb;
$table = $wpdb->prefix . 'prautoblogger_run_decisions';
$placeholders = implode( ',', array_fill( 0, count( $stages ), '%s' ) );
$sql = "UPDATE {$table} SET human_modified = 1 WHERE run_id = %s AND stage IN ({$placeholders})";
$params = array_merge( array( $run_id ), array_values( $stages ) );
if ( null !== $since ) {
$sql .= ' AND created_at >= %s';
$params[] = $since;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared -- $placeholders is a fixed %s list.
$updated = $wpdb->query( $wpdb->prepare( $sql, $params ) );
return is_numeric( $updated ) ? (int) $updated : 0;
}
/** Reset per-request caches (tests). */
public static function flush_cache(): void {
self::$tables_ok = null;

View File

@@ -64,6 +64,7 @@ class PRAutoBlogger_Chief_Editor {
'temperature' => 0.3,
'max_tokens' => 5000,
'response_format' => array( 'type' => 'json_object' ),
'stage' => 'review',
)
);

View File

@@ -78,6 +78,7 @@ class PRAutoBlogger_Content_Analyzer {
'temperature' => 0.5,
'max_tokens' => 8000,
'response_format' => array( 'type' => 'json_object' ),
'stage' => 'analysis',
)
);

View File

@@ -194,9 +194,15 @@ class PRAutoBlogger_Content_Generator {
);
return $cached;
}
PRAutoBlogger_Run_Stage_State::start( $this->run_id, $stage, '', $this->item_key );
PRAutoBlogger_Run_Stage_State::start( $this->run_id, $stage, (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( $stage ), $this->item_key );
}
// v0.18.1: stage metadata for the provider's empty-completion
// guard — warning logs and failed-attempt audit rows carry the
// real stage + registry key (never sent upstream).
$options['stage'] = $stage;
$options['prompt_key'] = $prompt_key;
$ctx = PRAutoBlogger_Opik_Trace_Context::current();
$span_id = $ctx->start_span(
@@ -223,6 +229,21 @@ class PRAutoBlogger_Content_Generator {
$options
);
// v0.18.1 writer-path guard (belt over the provider seam, which
// already throws for OpenRouter): no empty article may ever leave
// a writing stage — it would flow to the editor, be rejected as
// "draft text is missing", and land as an empty draft post.
if ( '' === trim( (string) $response['content'] ) ) {
throw new \RuntimeException(
sprintf(
/* translators: 1: writing stage name, 2: model identifier. */
__( 'Writing stage "%1$s" produced empty content (model %2$s) — failing the stage instead of passing an empty article downstream.', 'prautoblogger' ),
$stage,
(string) $response['model']
)
);
}
$ctx->end_span(
$span_id,
array(

View File

@@ -36,8 +36,9 @@ class PRAutoBlogger_Cost_Reporter {
$first_of_month = gmdate( 'Y-m-01 00:00:00' );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$result = $wpdb->get_var(
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->prepare(
"SELECT COALESCE(SUM(estimated_cost), 0) FROM {$table} WHERE created_at >= %s AND response_status = 'success'",
$first_of_month
)
@@ -64,9 +65,10 @@ class PRAutoBlogger_Cost_Reporter {
$start_date = gmdate( 'Y-m-d', time() - ( $days * DAY_IN_SECONDS ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT DATE(created_at) as day, SUM(estimated_cost) as total_cost // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT DATE(created_at) as day, SUM(estimated_cost) as total_cost
FROM {$table}
WHERE created_at >= %s AND response_status = 'success'
GROUP BY DATE(created_at)
@@ -101,12 +103,13 @@ class PRAutoBlogger_Cost_Reporter {
$table = $wpdb->prefix . 'prautoblogger_generation_log';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT stage,
SUM(estimated_cost) as total_cost,
SUM(prompt_tokens + completion_tokens) as total_tokens,
COUNT(*) as call_count // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
COUNT(*) as call_count
FROM {$table}
WHERE created_at BETWEEN %s AND %s AND response_status = 'success'
GROUP BY stage",
@@ -140,6 +143,7 @@ class PRAutoBlogger_Cost_Reporter {
}
return ( $this->get_monthly_spend() / $budget ) * 100.0;
}
/**
* Get average input/output token counts for given stages over a time period.
*

View File

@@ -69,7 +69,9 @@ class PRAutoBlogger_Cost_Tracker {
/**
* Log an API call with its token usage and estimated cost.
*
* Side effects: database insert into prab_generation_log.
* Side effects: database insert into prab_generation_log. Consumes the
* Request_Recorder stash (v0.20.0 / B1) into the row's request_json so
* every chat call's input is persisted (retention-pruned after R days).
*
* @param int|null $post_id WordPress post ID (null during generation, set later).
* @param string $stage Pipeline stage (see PRAutoBlogger_Stage_Display_Map).
@@ -117,6 +119,7 @@ class PRAutoBlogger_Cost_Tracker {
'estimated_cost' => $cost,
'response_status' => $response_status,
'error_message' => $error_message,
'request_json' => PRAutoBlogger_Request_Recorder::consume(),
'agent_role' => $agent_role ?? PRAutoBlogger_Stage_Display_Map::default_agent_role( $stage ),
'prompt_version' => $this->resolve_prompt_version( $stage, $prompt_key ),
'created_at' => current_time( 'mysql' ),

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Private helpers for PRAutoBlogger_Generation_Checkpoint_Runner (v0.21.0, M4).
*
* Extracted to keep class-generation-checkpoint-runner.php under the 300-line
* cap (CONVENTIONS §1). Contains the three internal-only static methods:
* finalize() — write final transient, mark run done, release lock.
* cleanup_queue() — delete queue + run-id options on abort paths.
* fire_cron_now() — non-blocking loopback to fire cron events immediately.
*
* Only PRAutoBlogger_Generation_Checkpoint_Runner should call this class.
*
* @see core/class-generation-checkpoint-runner.php — Caller / orchestrator.
* @see ARCHITECTURE.md ss25 — Checkpoint design.
*/
class PRAutoBlogger_Generation_Checkpoint_Helpers {
/** Option key for the checkpoint queue (mirrors Runner constant). */
private const QUEUE_KEY = 'prautoblogger_article_queue';
/** Option key for the in-progress run UUID (mirrors Runner constant). */
private const RUN_ID_KEY = 'prautoblogger_checkpoint_run_id';
/**
* Finalize a completed run: write final transient, mark run done, release lock.
*
* @param array $results Generation counters (generated/published/rejected/cost).
* @param string $run_id Run UUID (read from option if blank).
* @return void
*/
public static function finalize( array $results, string $run_id = '' ): void {
if ( '' === $run_id ) {
$run_id = (string) get_option( self::RUN_ID_KEY, '' );
}
if ( '' !== $run_id ) {
PRAutoBlogger_Run_State::mark_status( $run_id, 'done' );
}
PRAutoBlogger_Pipeline_Status::write_final( $results );
PRAutoBlogger_Pipeline_Status::log_summary( $results );
PRAutoBlogger_Generation_Lock::release();
delete_option( self::RUN_ID_KEY );
}
/** Remove the persistent queue and run-id option on abort paths. */
public static function cleanup_queue(): void {
delete_option( self::QUEUE_KEY );
delete_option( self::RUN_ID_KEY );
}
/**
* Non-blocking loopback to fire cron events immediately.
* Mirrors Pipeline_Runner::schedule_next() exactly.
*
* @return void
*/
public static function fire_cron_now(): void {
spawn_cron();
wp_remote_post(
site_url( 'wp-cron.php?doing_wp_cron=' . sprintf( '%.22F', microtime( true ) ) ),
array(
'timeout' => 0.01,
'blocking' => false,
'sslverify' => false,
)
);
}
}

View File

@@ -0,0 +1,295 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Chained-cron checkpoint runner for in-admin manual article generation (v0.21.0, M4).
*
* What: Replaces the monolithic on_manual_generation() path with a two-tick
* checkpoint machine so Hostinger's background-PHP-process kill cannot
* abort a mid-pipeline run:
* Tick 1 (ORCHESTRATE): collect sources -> analyze patterns -> score ideas ->
* persist idea queue to option -> schedule Tick 2.
* Tick 2..N (GENERATE): pop one idea from queue -> Article_Worker ->
* update transient -> schedule next tick or finalize.
*
* Why separate from Rerun_Executor: Rerun_Executor targets existing run_stages
* rows (replay/rebuild). This class creates a brand-new run from scratch.
*
* SSH retirement (v0.21.0): the former `wp eval 'do_action("prautoblogger_manual_generation")'`
* workaround fired on_manual_generation() synchronously in one PHP process --
* bypassing the nonce and running the full pipeline without checkpoints.
* That path is retired: Executor::on_manual_generation() now delegates here,
* and `wp prautoblogger generate` queues via kick_off() (see ARCHITECTURE.md ss25).
*
* Cost governor: Run_State::ceiling_setting() is applied per-run; a halted run
* aborts all remaining ticks (same guarantee as Pipeline_Runner's queued-article path).
*
* Who triggers it: PRAutoBlogger_Executor::on_manual_generation() (cron),
* PRAutoBlogger_WP_CLI_Commands (wp prautoblogger generate).
* Dependencies: Pipeline_Runner (orchestration), Run_State (run ledger),
* Pipeline_Status (transient), Generation_Lock, Cost_Tracker, Logger.
*
* @see includes/class-executor.php -- Cron/AJAX entry points.
* @see core/class-pipeline-runner.php -- Orchestration + article worker.
* @see core/class-run-state.php -- Per-run ledger (cost governor).
* @see core/class-pipeline-status.php -- Transient broadcast helpers.
* @see core/class-generation-checkpoint-helpers.php -- finalize/cleanup/fire helpers.
* @see ARCHITECTURE.md ss25 -- Checkpoint generation path design.
*/
class PRAutoBlogger_Generation_Checkpoint_Runner {
/** Option key for the checkpoint queue (shared with Pipeline_Runner article queue). */
private const QUEUE_KEY = 'prautoblogger_article_queue';
/** Option key for the in-progress run UUID (generation tick). */
private const RUN_ID_KEY = 'prautoblogger_checkpoint_run_id';
/** WP-Cron action for the orchestration tick. */
public const ORCHESTRATE_ACTION = 'prautoblogger_gen_orchestrate';
/** WP-Cron action for each article-generation tick. */
public const GENERATE_ACTION = 'prautoblogger_gen_tick';
/**
* When true, on_generate_tick() skips wp-cron rescheduling so the VPS
* orchestrator's external loop is the sole driver. Set via set_sync_mode().
* Never true on the wp-cron path; reset to false after the sync run.
*
* @var bool
*/
private static bool $sync_mode = false;
/**
* Enable or disable synchronous-driver mode.
* Called by WP_CLI_Commands::run_sync() before and after the tick loop.
*
* @param bool $enabled True to suppress internal cron reschedule.
* @return void
*/
public static function set_sync_mode( bool $enabled ): void {
self::$sync_mode = $enabled;
}
/**
* Schedule the orchestration tick and write the initial status transient.
* Called by on_ajax_generate_now() (via Executor) and by the WP-CLI command.
* Returns immediately -- ALL pipeline work happens in cron.
*
* Side effects: cron scheduling, transient write.
*
* @return void
*/
public static function kick_off(): void {
if ( wp_next_scheduled( self::ORCHESTRATE_ACTION ) ) {
return; // Idempotent: already queued.
}
wp_schedule_single_event( time(), self::ORCHESTRATE_ACTION );
PRAutoBlogger_Pipeline_Status::write_initial();
PRAutoBlogger_Generation_Checkpoint_Helpers::fire_cron_now();
}
/**
* Cron Tick 1: collect -> analyze -> score. Persists ideas, creates run row,
* schedules Tick 2 for the first article, then returns.
*
* Side effects: source/analysis DB writes, run row insert, option write,
* status transient update, cron scheduling.
*
* @return void
*/
public static function on_orchestrate_tick(): void {
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@ignore_user_abort( true );
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@set_time_limit( 300 );
if ( ! PRAutoBlogger_Generation_Lock::acquire() ) {
PRAutoBlogger_Pipeline_Status::write_error(
__( 'Could not acquire generation lock. Another run may be in progress.', 'prautoblogger' )
);
return;
}
$run_id = wp_generate_uuid4();
update_option( self::RUN_ID_KEY, $run_id, false );
PRAutoBlogger_Run_State::ensure_run( $run_id );
$cost_tracker = new PRAutoBlogger_Cost_Tracker();
$cost_tracker->set_run_id( $run_id );
try {
if ( $cost_tracker->is_budget_exceeded() ) {
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
PRAutoBlogger_Pipeline_Status::write_error(
__( 'Monthly API budget exceeded. Generation halted.', 'prautoblogger' )
);
PRAutoBlogger_Generation_Lock::release();
return;
}
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Collecting sources…', 'prautoblogger' ) );
$runner = ( new PRAutoBlogger_Pipeline_Runner() )->set_skip_dedup( true );
$ideas = $runner->orchestrate_only( $cost_tracker );
if ( empty( $ideas ) ) {
PRAutoBlogger_Run_State::mark_status( $run_id, 'done' );
PRAutoBlogger_Pipeline_Status::write_final(
array(
'generated' => 0,
'published' => 0,
'rejected' => 0,
'cost' => 0.0,
)
);
PRAutoBlogger_Generation_Lock::release();
delete_option( self::RUN_ID_KEY );
return;
}
$serialized = array_map(
static fn( PRAutoBlogger_Article_Idea $i ) => $i->to_array(),
$ideas
);
update_option(
self::QUEUE_KEY,
array(
'run_id' => $run_id,
'ideas' => $serialized,
'results' => array(
'generated' => 0,
'published' => 0,
'rejected' => 0,
'cost' => 0.0,
),
),
false
);
PRAutoBlogger_Pipeline_Status::broadcast(
sprintf(
/* translators: %d: number of ideas found */
__( 'Found %d idea(s) — starting generation…', 'prautoblogger' ),
count( $ideas )
)
);
// In sync mode the VPS tick loop is the external driver; skip
// wp-cron reschedule to prevent a competing background tick.
// on_generate_tick() has the same guard for subsequent reschedules.
if ( ! self::$sync_mode ) {
if ( ! wp_next_scheduled( self::GENERATE_ACTION ) ) {
wp_schedule_single_event( time(), self::GENERATE_ACTION );
}
PRAutoBlogger_Generation_Checkpoint_Helpers::fire_cron_now();
}
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->error(
sprintf( 'Orchestrate tick %s: %s', get_class( $e ), $e->getMessage() ),
'pipeline'
);
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
PRAutoBlogger_Pipeline_Status::write_error( $e->getMessage() );
PRAutoBlogger_Generation_Lock::release();
delete_option( self::RUN_ID_KEY );
}
}
/**
* Cron Tick 2..N: generate one article, persist totals, reschedule or finalize.
*
* Side effects: LLM API calls, DB writes, post creation, transient updates,
* cron scheduling.
*
* @return void
*/
public static function on_generate_tick(): void {
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@ignore_user_abort( true );
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@set_time_limit( 300 );
$queue = get_option( self::QUEUE_KEY );
if ( ! is_array( $queue ) || empty( $queue['ideas'] ) ) {
PRAutoBlogger_Generation_Checkpoint_Helpers::finalize(
array(
'generated' => 0,
'published' => 0,
'rejected' => 0,
'cost' => 0.0,
)
);
return;
}
$run_id = (string) ( $queue['run_id'] ?? '' );
$run_status = PRAutoBlogger_Run_State::get_status( $run_id );
if ( in_array( $run_status, array( 'halted', 'failed' ), true ) ) {
PRAutoBlogger_Logger::instance()->warning(
sprintf( 'Generate tick: run %s is %s -- aborting.', $run_id, $run_status ),
'pipeline'
);
PRAutoBlogger_Generation_Checkpoint_Helpers::cleanup_queue();
PRAutoBlogger_Generation_Lock::release();
return;
}
$idea_data = array_shift( $queue['ideas'] );
$idea = new PRAutoBlogger_Article_Idea( $idea_data );
$cost_tracker = new PRAutoBlogger_Cost_Tracker();
$cost_tracker->set_run_id( $run_id );
if ( $cost_tracker->is_budget_exceeded() ) {
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
PRAutoBlogger_Generation_Checkpoint_Helpers::cleanup_queue();
PRAutoBlogger_Generation_Lock::release();
PRAutoBlogger_Pipeline_Status::write_error(
__( 'Monthly API budget exceeded. Generation halted.', 'prautoblogger' )
);
return;
}
// Persist consumed queue BEFORE generating (prevents orphan-recovery duplication).
if ( ! empty( $queue['ideas'] ) ) {
update_option( self::QUEUE_KEY, $queue, false );
} else {
delete_option( self::QUEUE_KEY );
}
try {
$worker = new PRAutoBlogger_Article_Worker( $cost_tracker );
$result = $worker->generate( $idea );
$queue['results']['generated'] += $result['generated'];
$queue['results']['published'] += $result['published'];
$queue['results']['rejected'] += $result['rejected'];
$queue['results']['cost'] += $result['cost'];
if ( ! empty( $queue['ideas'] ) ) {
update_option( self::QUEUE_KEY, $queue, false );
PRAutoBlogger_Pipeline_Status::update_queue_progress( $queue );
// In sync mode the VPS tick loop is the external driver; skip
// wp-cron reschedule to prevent a competing background tick.
if ( ! self::$sync_mode ) {
if ( ! wp_next_scheduled( self::GENERATE_ACTION ) ) {
wp_schedule_single_event( time(), self::GENERATE_ACTION );
}
PRAutoBlogger_Generation_Checkpoint_Helpers::fire_cron_now();
}
} else {
PRAutoBlogger_Post_Assembler::amortize_research_costs( $run_id );
PRAutoBlogger_Generation_Checkpoint_Helpers::finalize( $queue['results'], $run_id );
}
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->error(
sprintf( 'Generate tick %s for run %s: %s', get_class( $e ), $run_id, $e->getMessage() ),
'pipeline'
);
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
PRAutoBlogger_Generation_Checkpoint_Helpers::cleanup_queue();
PRAutoBlogger_Generation_Lock::release();
PRAutoBlogger_Pipeline_Status::write_error( $e->getMessage() );
}
}
}

View File

@@ -11,9 +11,10 @@ declare(strict_types=1);
* API is unavailable.
*
* Triggered by: PRAutoBlogger_Pipeline_Runner (step 3).
* Dependencies: Semantic_Dedup, WP_Query.
* Dependencies: Semantic_Dedup, Keyword_Extractor, WP_Query.
*
* @see core/class-semantic-dedup.php — Embedding + keyword dedup engine.
* @see core/class-semantic-dedup.php — Embedding + keyword dedup engine.
* @see core/class-keyword-extractor.php — Keyword tokeniser (fallback dedup).
* @see core/class-content-analyzer.php — Produces the analysis results we consume.
* @see core/class-content-generator.php — Consumes the ranked ideas we produce.
* @see ARCHITECTURE.md — Data flow step 3.
@@ -67,7 +68,7 @@ class PRAutoBlogger_Idea_Scorer {
if ( ! $this->skip_dedup ) {
$topic = $result->get_topic();
$topic_keywords = $this->extract_meaningful_keywords( $topic );
$topic_keywords = PRAutoBlogger_Keyword_Extractor::extract( $topic );
if ( $dedup->is_batch_duplicate( $topic, $topic_keywords, $accepted_keyword_sets ) ) {
++$deduped_count;
@@ -165,133 +166,6 @@ class PRAutoBlogger_Idea_Scorer {
return false;
}
/**
* Extract meaningful keywords from a text string (for fallback dedup).
*
* @param string $text Input text.
* @return string[] Unique lowercase keywords.
*/
private function extract_meaningful_keywords( string $text ): array {
static $stopwords = array(
'a',
'an',
'the',
'and',
'or',
'but',
'is',
'are',
'was',
'were',
'in',
'on',
'at',
'to',
'for',
'of',
'with',
'by',
'from',
'as',
'it',
'its',
'this',
'that',
'your',
'you',
'how',
'what',
'when',
'why',
'where',
'which',
'who',
'do',
'does',
'did',
'not',
'no',
'can',
'will',
'should',
'could',
'would',
'may',
'might',
'have',
'has',
'had',
'be',
'been',
'being',
'about',
'into',
'through',
'during',
'before',
'after',
'above',
'below',
'between',
'same',
'up',
'down',
'out',
'off',
'over',
'under',
'again',
'then',
'here',
'there',
'all',
'each',
'every',
'both',
'few',
'more',
'most',
'other',
'some',
'such',
'than',
'too',
'very',
'just',
'also',
'only',
'own',
'so',
'if',
'while',
'because',
'until',
'vs',
'versus',
'guide',
'complete',
'ultimate',
'best',
'top',
'new',
'first',
'need',
'know',
'everything',
);
$text = strtolower( $text );
$words = preg_split( '/[^a-z0-9-]+/', $text, -1, PREG_SPLIT_NO_EMPTY );
$words = array_filter(
$words,
static function ( string $w ) use ( $stopwords ): bool {
return strlen( $w ) >= 2 && ! in_array( $w, $stopwords, true );
}
);
return array_values( array_unique( $words ) );
}
/**
* Map analysis type to a reader-friendly article type.
*

View File

@@ -38,6 +38,10 @@ class PRAutoBlogger_Image_Composer_Layout {
* plugin; sizes are px. Values were tuned against rendered samples (see
* PR #thread 2026-06-image-composer assets).
*
* Note: 'caption_margin_right' (og) and 'caption_side_padding' (square) were removed
* in v0.19.4 — the Imagick renderer enforces right-edge geometry via caption_chars_per_line
* and centered x positioning respectively, so those keys had no consumer.
*
* @return array<string, array<string, mixed>> Keyed by role: featured, og, square.
*/
public static function defaults(): array {
@@ -60,7 +64,6 @@ class PRAutoBlogger_Image_Composer_Layout {
'caption_size' => 32,
'caption_color' => '#FFFFFF',
'caption_gap' => 24,
'caption_margin_right' => 40,
'caption_max_lines' => 2,
'caption_chars_per_line' => 52,
'caption_line_height' => 1.3,
@@ -79,7 +82,6 @@ class PRAutoBlogger_Image_Composer_Layout {
'caption_color' => self::COLOR_INK,
'caption_max_lines' => 3,
'caption_chars_per_line' => 36,
'caption_side_padding' => 64,
'caption_line_height' => 1.3,
),
);

View File

@@ -200,6 +200,7 @@ PROMPT;
array(
'temperature' => 0.7,
'max_tokens' => self::REWRITER_MAX_TOKENS,
'stage' => 'image_prompt_rewrite',
)
);

View File

@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Shared keyword-extraction utility for the scoring and dedup pipeline.
*
* Extracts meaningful lowercase tokens from free text by stripping stop-words.
* Used by both PRAutoBlogger_Idea_Scorer and PRAutoBlogger_Semantic_Dedup as the
* fallback path when embedding similarity is unavailable.
*
* What: Static utility; no state.
* Who triggers: PRAutoBlogger_Idea_Scorer, PRAutoBlogger_Semantic_Dedup.
* Dependencies: none.
*
* @see core/class-idea-scorer.php -- Primary consumer (scoring pipeline).
* @see core/class-semantic-dedup.php -- Secondary consumer (embedding fallback).
*/
class PRAutoBlogger_Keyword_Extractor {
/**
* Common English stop-words filtered out before keyword matching.
*
* @var string[]
*/
private const STOPWORDS = array(
'a',
'an',
'the',
'and',
'or',
'but',
'is',
'are',
'was',
'were',
'in',
'on',
'at',
'to',
'for',
'of',
'with',
'by',
'from',
'as',
'it',
'its',
'this',
'that',
'your',
'you',
'how',
'what',
'when',
'why',
'where',
'which',
'who',
'do',
'does',
'did',
'not',
'no',
'can',
'will',
'should',
'could',
'would',
'may',
'might',
'have',
'has',
'had',
'be',
'been',
'being',
'about',
'into',
'through',
'during',
'before',
'after',
'above',
'below',
'between',
'same',
'up',
'down',
'out',
'off',
'over',
'under',
'again',
'then',
'here',
'there',
'all',
'each',
'every',
'both',
'few',
'more',
'most',
'other',
'some',
'such',
'than',
'too',
'very',
'just',
'also',
'only',
'own',
'so',
'if',
'while',
'because',
'until',
'vs',
'versus',
'guide',
'complete',
'ultimate',
'best',
'top',
'new',
'first',
'need',
'know',
'everything',
);
/**
* Extract meaningful keywords from text.
*
* Lowercases input, splits on non-alphanumeric characters, filters
* stop-words and single-character tokens, and de-duplicates.
*
* @param string $text Input text.
* @return string[] Unique lowercase keyword tokens.
*/
public static function extract( string $text ): array {
$text = strtolower( $text );
$words = preg_split( '/[^a-z0-9-]+/', $text, -1, PREG_SPLIT_NO_EMPTY );
$words = array_filter(
$words,
static function ( string $w ): bool {
return strlen( $w ) >= 2 && ! in_array( $w, self::STOPWORDS, true );
}
);
return array_values( array_unique( $words ) );
}
}

View File

@@ -168,6 +168,19 @@ class PRAutoBlogger_Pipeline_Runner {
}
}
/**
* Public wrapper for orchestrate(): collect -> analyze -> score.
* Used by Generation_Checkpoint_Runner (Tick 1) so orchestration and
* article generation happen in separate cron ticks.
*
* @param PRAutoBlogger_Cost_Tracker $cost_tracker
* @return PRAutoBlogger_Article_Idea[]
*/
public function orchestrate_only( PRAutoBlogger_Cost_Tracker $cost_tracker ): array {
return $this->orchestrate( $cost_tracker );
}
// ── Private helpers ─────────────────────────────────────────────────
/**
@@ -185,14 +198,14 @@ class PRAutoBlogger_Pipeline_Runner {
$source_labels = is_array( $enabled ) ? implode( ', ', $enabled ) : 'reddit';
/* translators: %s is a comma-separated list of enabled source names, e.g. "reddit, llm_research". */
PRAutoBlogger_Pipeline_Status::broadcast( sprintf( __( 'Collecting sources from %s…', 'prautoblogger' ), $source_labels ) );
PRAutoBlogger_Run_Stage_State::start( $run_id, 'research' );
PRAutoBlogger_Run_Stage_State::start( $run_id, 'research', (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( 'research' ) );
( new PRAutoBlogger_Source_Collector() )
->set_cost_tracker( $cost_tracker )
->collect_from_all_sources();
PRAutoBlogger_Run_Stage_State::done( $run_id, 'research' );
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Analyzing topics and scoring…', 'prautoblogger' ) );
PRAutoBlogger_Run_Stage_State::start( $run_id, 'analysis' );
PRAutoBlogger_Run_Stage_State::start( $run_id, 'analysis', (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( 'analysis' ) );
$llm = new PRAutoBlogger_OpenRouter_Provider();
$analyzer = new PRAutoBlogger_Content_Analyzer( $llm, $cost_tracker );
$analysis = $analyzer->analyze_recent_data( max( $target * 2, 6 ) );

View File

@@ -103,4 +103,41 @@ class PRAutoBlogger_Pipeline_Status {
'pipeline'
);
}
/**
* Write an initial "running" status before the first tick fires.
* Mirrors the shape on_ajax_generate_now() already writes so the poller
* sees a consistent "running" state whether triggered from board or settings page.
*
* @return void
*/
public static function write_initial(): void {
set_transient(
self::KEY,
array(
'status' => 'running',
'stage' => __( 'Starting generation…', 'prautoblogger' ),
'started' => time(),
'last_updated' => time(),
),
self::TTL
);
}
/**
* Write a terminal "error" status.
*
* @param string $message Operator-facing error description.
* @return void
*/
public static function write_error( string $message ): void {
set_transient(
self::KEY,
array(
'status' => 'error',
'message' => $message,
),
self::TTL
);
}
}

View File

@@ -91,7 +91,9 @@ class PRAutoBlogger_Post_Assembler {
* are generated. Its cost is logged with the run_id but no post_id. After all
* articles finish, this method divides the research cost evenly and inserts
* one 'llm_research' row per article so the cost breakdown popover includes
* the amortized research overhead.
* the amortized research overhead. The audit fields prompt_version and
* agent_role are carried from the original research row (v0.19.4 fix;
* prior builds left them NULL/empty — see phase-1 thread seq 49).
*
* Side effects: database SELECT, INSERT, DELETE on generation_log.
*
@@ -108,8 +110,8 @@ class PRAutoBlogger_Post_Assembler {
// Find the unlinked research cost row for this run.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$research_row = $wpdb->get_row(
$wpdb->prepare(
"SELECT id, estimated_cost, provider, model, prompt_tokens, completion_tokens // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT id, estimated_cost, provider, model, prompt_tokens, completion_tokens, prompt_version, agent_role
FROM {$table}
WHERE run_id = %s AND stage = 'llm_research' AND post_id IS NULL
LIMIT 1",
@@ -158,6 +160,8 @@ class PRAutoBlogger_Post_Assembler {
'estimated_cost' => $amortized_cost,
'response_status' => 'success',
'error_message' => '',
'prompt_version' => $research_row->prompt_version,
'agent_role' => $research_row->agent_role ?? '',
'created_at' => current_time( 'mysql' ),
)
);

View File

@@ -82,6 +82,23 @@ class PRAutoBlogger_Publisher {
?string $run_id = null,
?PRAutoBlogger_Cost_Tracker $cost_tracker = null
): int {
// v0.18.1 belt-and-braces: never create a post whose content is
// empty once tags and whitespace are stripped — an empty "draft
// for human review" (2026-06-11 incident, post 921) reviews
// nothing and hides the underlying generation failure. The
// provider/writer guards make this unreachable on OpenRouter
// paths; this protects every other path into post creation.
if ( '' === trim( wp_strip_all_tags( $content ) ) ) {
throw new \RuntimeException(
sprintf(
/* translators: 1: intended post status, 2: article title. */
__( 'Refusing to create %1$s post "%2$s": generated content is empty.', 'prautoblogger' ),
$post_status,
$idea->get_suggested_title()
)
);
}
// v0.18.0 idempotency: post creation is keyed by run + idea
// (check-before-insert) so a retried/resumed run cannot create a
// duplicate post. One run_id spans all N articles of a batch, so
@@ -90,11 +107,11 @@ class PRAutoBlogger_Publisher {
if ( null !== $run_id && '' !== $run_id ) {
$existing_id = $this->find_existing_post( $run_id, $idea_hash );
if ( $existing_id > 0 ) {
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Post for run %s / idea %s already exists (ID=%d) — skipping duplicate insert.', $run_id, $idea_hash, $existing_id ),
'publisher'
);
return $existing_id;
// v0.20.0: a re-run's regenerated content must land on the
// existing UNPUBLISHED post (otherwise re-runs silently
// discard their output). Published posts stay frozen
// (CPO guardrail 5) — refresh skips them untouched.
return $this->refresh_unpublished_post( $existing_id, $content, $idea, $review );
}
}
@@ -140,6 +157,60 @@ class PRAutoBlogger_Publisher {
return $post_id;
}
/**
* Refresh an existing post from re-run output — unpublished only.
*
* Idempotent-resume calls land here too (same content in = same
* content out, so behavior is unchanged for plain resumes). The post
* keeps its CURRENT status: a re-run never publishes an unpublished
* post — publication stays an explicit Review Queue / WP editor
* action. Published (and scheduled/private) posts are returned
* untouched: the run is frozen (CPO guardrail 5).
*
* Side effects: wp_update_post + post meta updates (unpublished only).
*
* @param int $post_id Existing post ID.
* @param string $content Regenerated HTML content.
* @param PRAutoBlogger_Article_Idea $idea The idea.
* @param PRAutoBlogger_Editorial_Review $review The fresh review.
* @return int The post ID.
*/
private function refresh_unpublished_post( int $post_id, string $content, PRAutoBlogger_Article_Idea $idea, PRAutoBlogger_Editorial_Review $review ): int {
$post = get_post( $post_id );
if ( PRAutoBlogger_Rerun_Eligibility::post_frozen( $post ) ) {
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Post %d already exists and is published — frozen, skipping content refresh.', $post_id ),
'publisher'
);
return $post_id;
}
// Same sanitize chain as create_post().
$clean_content = PRAutoBlogger_Post_Assembler::sanitize_llm_content( $content );
$linked_content = PRAutoBlogger_Peptide_Linker::inject_links( $clean_content );
wp_update_post(
array(
'ID' => $post_id,
'post_title' => sanitize_text_field( $idea->get_suggested_title() ),
'post_content' => wp_kses_post( $linked_content ),
)
);
update_post_meta( $post_id, '_prautoblogger_editor_verdict', $review->get_verdict() );
update_post_meta( $post_id, '_prautoblogger_editor_notes', $review->get_notes() );
update_post_meta( $post_id, '_prautoblogger_quality_score', $review->get_quality_score() );
update_post_meta( $post_id, '_prautoblogger_seo_score', $review->get_seo_score() );
update_post_meta( $post_id, '_prautoblogger_generated_at', gmdate( 'c' ) );
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Post %d refreshed from re-run output (status preserved: %s).', $post_id, (string) ( $post->post_status ?? '' ) ),
'publisher'
);
return $post_id;
}
/**
* Build post_meta array for generation metadata.
*

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Per-PHP-process stash for the most recent outgoing LLM request body.
*
* What: A process-scoped static slot recording the JSON request body the
* OpenRouter provider is about to dispatch, so Cost_Tracker::
* log_api_call() can persist it to `generation_log.request_json`
* (v0.20.0 / B1) without threading the body through every call
* site. Mirrors the Run_Context precedent (v0.18.0).
*
* SECURITY GUARANTEE: only the request BODY is ever recorded.
* Authorization headers are built separately
* (Request_Builder::build_headers()) and can never reach this
* class; build_body() copies only whitelisted option keys.
*
* Consume-once semantics: consume() clears the slot, and record()
* overwrites it at every dispatch — so a stale body from an
* unlogged failed call can never attach to a later, unrelated
* log row.
* Who triggers it: OpenRouter_Provider::send_chat_completion() (record,
* pre-dispatch so error rows carry the request too);
* Cost_Tracker::log_api_call() (consume).
* Dependencies: none.
*
* @see providers/class-open-router-provider.php — Records before dispatch.
* @see core/class-cost-tracker.php — Consumes into the log row.
* @see core/class-run-context.php — The pattern this mirrors.
* @see ARCHITECTURE.md #24 — Edit + re-run substrate (B1).
*/
class PRAutoBlogger_Request_Recorder {
/** @var string|null JSON-encoded request body of the in-flight LLM call. */
private static ?string $request_json = null;
/**
* Record the outgoing request body for the next log row.
*
* Overwrites any previous stash (one in-flight chat call per PHP
* process). Bodies that fail JSON encoding are skipped silently —
* the log row's request_json stays NULL, never blocking the call.
*
* @param array<string, mixed> $body Request body from Request_Builder::build_body().
* @return void
*/
public static function record( array $body ): void {
$encoded = wp_json_encode( $body );
self::$request_json = is_string( $encoded ) ? $encoded : null;
}
/**
* Return the stashed request body and clear the slot (consume-once).
*
* @return string|null JSON request body, or null when nothing is stashed.
*/
public static function consume(): ?string {
$json = self::$request_json;
self::$request_json = null;
return $json;
}
/**
* Clear the stash (tests / defensive teardown).
*
* @return void
*/
public static function clear(): void {
self::$request_json = null;
}
}

View File

@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Policy gates for edit + single-step re-run (v0.20.0, M3).
*
* What: Single source of truth for WHO may re-run WHAT:
* - Published posts are frozen (CPO guardrail 5): publish/future/
* private statuses lock the run; edits go through the WP editor.
* - Runs that are actively executing (status pending/running or
* the global generation lock held) cannot be re-run — "in
* progress" in the CPO sense means in the WORKFLOW (unpublished),
* not mid-execution.
* - Editable stages (input fork + replay) are the item-scoped
* writer chat stages of the CURRENT pipeline vocabulary:
* outline/draft/polish (single-pass uses draft only). review's
* input is fully derived from content (re-run it from here
* instead); analysis/research are run-level idea-selection
* stages; image stages are not chat-replayable (Phase 2b).
* - The canonical per-item chain orders downstream computation
* for stale-marking and rebuild sets.
* Who triggers it: Dossier_Actions (UI affordances + request validation),
* Rerun_Executor (re-validation under the lock before mutating).
* Dependencies: get_post(), Run_State, Generation_Lock, Stage_Input_Store.
*
* @see admin/class-dossier-actions.php — AJAX validation.
* @see core/class-rerun-executor.php — Cron-side re-validation.
* @see ARCHITECTURE.md #24 — Edit + re-run design.
*/
class PRAutoBlogger_Rerun_Eligibility {
/** Post statuses that freeze a run (CPO guardrail 5). */
public const FROZEN_STATUSES = array( 'publish', 'future', 'private' );
/** Stages whose input may be edited and replayed (chat writer stages). */
public const EDITABLE_STAGES = array( 'outline', 'draft', 'polish' );
/** Canonical per-item stage chain (current pipeline vocabulary). */
public const ITEM_STAGE_CHAIN = array( 'outline', 'draft', 'polish', 'review', 'publish' );
/**
* Whether a stage's input may be edited + replayed.
*
* @param string $stage Stage name.
* @return bool
*/
public static function is_editable_stage( string $stage ): bool {
return in_array( $stage, self::EDITABLE_STAGES, true );
}
/**
* Whether re-run-from-here may target this stage (any chain stage).
*
* @param string $stage Stage name.
* @return bool
*/
public static function is_chain_stage( string $stage ): bool {
return in_array( $stage, self::ITEM_STAGE_CHAIN, true );
}
/**
* Stages strictly AFTER a stage in the canonical chain — the set that
* goes stale when the stage is re-run. Stages absent from an item
* (e.g. outline/polish on single-pass runs) simply match no rows.
*
* @param string $stage Stage name.
* @return array<string> Downstream stage names ([] for non-chain stages).
*/
public static function downstream_of( string $stage ): array {
$idx = array_search( $stage, self::ITEM_STAGE_CHAIN, true );
if ( false === $idx ) {
return array();
}
return array_slice( self::ITEM_STAGE_CHAIN, $idx + 1 );
}
/**
* The demotion set for re-run-from-here: the stage itself plus all
* downstream chain stages.
*
* @param string $stage Stage name.
* @return array<string>
*/
public static function rebuild_set( string $stage ): array {
$idx = array_search( $stage, self::ITEM_STAGE_CHAIN, true );
if ( false === $idx ) {
return array();
}
return array_slice( self::ITEM_STAGE_CHAIN, $idx );
}
/**
* Whether a post is frozen for pipeline re-runs (guardrail 5).
*
* @param WP_Post|null $post The post, or null when none exists yet.
* @return bool
*/
public static function post_frozen( ?WP_Post $post ): bool {
return ( $post instanceof WP_Post )
&& in_array( (string) $post->post_status, self::FROZEN_STATUSES, true );
}
/**
* Full re-run eligibility check for a (run, post) pair.
*
* Returns ok=false with an operator-readable reason for: missing/
* frozen post, missing run row, run actively executing, or the
* global generation lock being held. Called from the AJAX layer for
* UI affordances AND re-validated by the cron handler under the lock
* (state can change between click and pickup).
*
* @param string $run_id Run UUID.
* @param int $post_id Post ID (0 = run has no post yet).
* @param bool $require_idle_lock Whether a held generation lock blocks (true for actions).
* @return array{ok: bool, reason: string} Verdict + reason ('' when ok).
*/
public static function check( string $run_id, int $post_id, bool $require_idle_lock = true ): array {
if ( '' === $run_id ) {
return self::no( __( 'This article has no recorded generation run.', 'prautoblogger' ) );
}
$post = $post_id > 0 ? get_post( $post_id ) : null;
if ( self::post_frozen( $post ) ) {
return self::no( __( 'This article is published — its run is frozen. Edit the article in the WordPress editor instead.', 'prautoblogger' ) );
}
$run = PRAutoBlogger_Run_State::get_run( $run_id );
if ( null === $run ) {
return self::no( __( 'No run ledger row exists for this article (pre-substrate run).', 'prautoblogger' ) );
}
if ( in_array( (string) $run['status'], array( 'pending', 'running' ), true ) ) {
return self::no( __( 'This run is currently executing. Wait for it to finish (or for the reaper to mark it failed).', 'prautoblogger' ) );
}
if ( $require_idle_lock && null !== PRAutoBlogger_Generation_Lock::get_acquired_at() ) {
return self::no( __( 'Another generation is in progress (lock held). Try again when it completes.', 'prautoblogger' ) );
}
return array(
'ok' => true,
'reason' => '',
);
}
/**
* Replay-specific check: base eligibility + editable stage + a saved
* input fork whose body has not been retention-pruned.
*
* @param string $run_id Run UUID.
* @param int $post_id Post ID.
* @param string $stage Stage name.
* @param string $agent_role Stage row agent role.
* @param string $item_key Stage row item key.
* @param bool $require_idle_lock False when the caller already owns the lock (cron handler).
* @return array{ok: bool, reason: string}
*/
public static function check_replay( string $run_id, int $post_id, string $stage, string $agent_role, string $item_key, bool $require_idle_lock = true ): array {
if ( ! self::is_editable_stage( $stage ) ) {
return self::no( __( 'This stage cannot be replayed with an edited input.', 'prautoblogger' ) );
}
$base = self::check( $run_id, $post_id, $require_idle_lock );
if ( ! $base['ok'] ) {
return $base;
}
$fork = PRAutoBlogger_Stage_Input_Store::latest_fork( $run_id, $stage, $agent_role, $item_key );
if ( null === $fork ) {
return self::no( __( 'Save an edited input for this stage first — re-run executes your latest saved version.', 'prautoblogger' ) );
}
if ( empty( $fork['request_json'] ) ) {
return self::no( __( 'The saved input for this stage was pruned by the retention cron and can no longer be replayed.', 'prautoblogger' ) );
}
return $base;
}
/**
* Rebuild-specific check: base eligibility + chain stage + idea seed
* available (runs started before v0.20.0 have no seed).
*
* @param string $run_id Run UUID.
* @param int $post_id Post ID.
* @param string $stage Target stage.
* @param string $item_key Item key.
* @param bool $require_idle_lock False when the caller already owns the lock (cron handler).
* @return array{ok: bool, reason: string}
*/
public static function check_rebuild( string $run_id, int $post_id, string $stage, string $item_key, bool $require_idle_lock = true ): array {
if ( ! self::is_chain_stage( $stage ) ) {
return self::no( __( 'Re-run from here is only available on article pipeline stages.', 'prautoblogger' ) );
}
$base = self::check( $run_id, $post_id, $require_idle_lock );
if ( ! $base['ok'] ) {
return $base;
}
if ( null === PRAutoBlogger_Stage_Input_Store::get_seed( $run_id, $item_key ) ) {
return self::no( __( 'No idea seed is stored for this run (recorded from v0.20.0 onward) — the pipeline cannot rebuild it safely.', 'prautoblogger' ) );
}
return $base;
}
/**
* Shorthand for a negative verdict.
*
* @param string $reason Operator-readable reason.
* @return array{ok: bool, reason: string}
*/
private static function no( string $reason ): array {
return array(
'ok' => false,
'reason' => $reason,
);
}
}

View File

@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Queues and executes operator re-runs as chained cron jobs (v0.20.0, M3).
*
* What: The chained-cron seam for the dossier's two re-run actions —
* NEVER synchronous (CPO hard constraint): the AJAX layer only
* schedules; ALL state mutations happen in the cron handler after
* re-validating eligibility under the global generation lock.
* - Replay job: single-stage re-run of the latest edited input
* fork (Stage_Replay). Demotes the one stage row to running
* (human_modified=1), stale-marks downstream, executes, restores
* a terminal run status.
* - Rebuild job ("re-run from here"): demotes the target + all
* downstream chain stages to pending, then re-enters
* Article_Worker with the idea reconstructed from the stored
* seed — upstream done stages are reused (never re-charged),
* demoted stages re-run with prompts rebuilt from CURRENT
* upstream snapshots, and the publisher refreshes the
* unpublished post.
* Both jobs: monthly budget check, Run_State::reopen() (ceiling
* re-snapshotted from the current setting — a deliberate new-spend
* decision), M1 status transient for board/dossier pickup
* visibility, lock released in finally. Failures restore/record a
* terminal run status so the run-reaper's stuck-running sweep
* stays meaningful.
* Who triggers it: Dossier_Actions (queue_*), WP-Cron (on_* handlers via
* class-prautoblogger.php registration).
* Dependencies: Rerun_Job_Support (lock/budget/transient plumbing),
* Run_State, Run_Stage_Rerun_State, Stage_Input_Store,
* Stage_Replay, Article_Worker, Rerun_Eligibility.
*
* @see admin/class-dossier-actions.php — The AJAX layer (queues via Job_Support).
* @see core/class-rerun-job-support.php — Shared queue/lock/status plumbing.
* @see core/class-stage-replay.php — The governed replay call.
* @see ARCHITECTURE.md #24 — Edit + re-run design.
*/
class PRAutoBlogger_Rerun_Executor {
/** Cron action for single-stage replay jobs. */
public const REPLAY_ACTION = 'prautoblogger_rerun_stage_replay';
/** Cron action for re-run-from-here rebuild jobs. */
public const REBUILD_ACTION = 'prautoblogger_rerun_from_stage';
/**
* Cron handler: replay one stage from its latest edited input fork.
*
* Side effects: run_stages/runs mutations, one governed LLM call,
* generation_log row, status transient, lock acquire/release.
*
* @param string $run_id Run UUID.
* @param int $post_id Post the dossier acted on.
* @param string $stage Stage name.
* @param string $agent_role Stage row agent role.
* @param string $item_key Stage row item key.
* @return void
*/
public function on_replay_job( string $run_id, int $post_id, string $stage, string $agent_role, string $item_key ): void {
PRAutoBlogger_Rerun_Job_Support::harden_process();
if ( ! PRAutoBlogger_Rerun_Job_Support::acquire_lock_or_report() ) {
return;
}
$previous_status = null;
try {
// Re-validate under the lock — state may have changed between
// click and pickup (e.g. the post was published).
// require_idle_lock=false: THIS handler owns the lock.
$check = PRAutoBlogger_Rerun_Eligibility::check_replay( $run_id, $post_id, $stage, $agent_role, $item_key, false );
if ( ! $check['ok'] ) {
PRAutoBlogger_Rerun_Job_Support::finish_error( $check['reason'] );
return;
}
if ( PRAutoBlogger_Rerun_Job_Support::monthly_budget_exceeded() ) {
PRAutoBlogger_Rerun_Job_Support::finish_error( __( 'Monthly API budget exceeded. Re-run not started.', 'prautoblogger' ) );
return;
}
$fork = PRAutoBlogger_Stage_Input_Store::latest_fork( $run_id, $stage, $agent_role, $item_key );
$body = null !== $fork ? PRAutoBlogger_Stage_Replay::decode_body( (string) $fork['request_json'] ) : null;
if ( null === $body ) {
PRAutoBlogger_Rerun_Job_Support::finish_error( __( 'The saved input could not be decoded as a replayable request.', 'prautoblogger' ) );
return;
}
$previous_status = PRAutoBlogger_Run_State::get_status( $run_id );
if ( ! PRAutoBlogger_Run_State::reopen( $run_id ) ) {
PRAutoBlogger_Rerun_Job_Support::finish_error( __( 'The run could not be reopened for new spend.', 'prautoblogger' ) );
return;
}
if ( ! PRAutoBlogger_Run_Stage_Rerun_State::restart( $run_id, $stage, $agent_role, $item_key, true ) ) {
PRAutoBlogger_Rerun_Job_Support::restore_terminal( $run_id, $previous_status );
PRAutoBlogger_Rerun_Job_Support::finish_error( __( 'The stage row could not be demoted for replay (it may be executing).', 'prautoblogger' ) );
return;
}
// Guardrail 3: explicit, visible downstream invalidation.
PRAutoBlogger_Run_Stage_Rerun_State::mark_stale( $run_id, $item_key, PRAutoBlogger_Rerun_Eligibility::downstream_of( $stage ) );
PRAutoBlogger_Rerun_Job_Support::broadcast(
sprintf(
/* translators: %s: stage display label. */
__( 'Re-running %s with the edited input…', 'prautoblogger' ),
PRAutoBlogger_Stage_Display_Map::label( $stage )
)
);
$result = PRAutoBlogger_Stage_Replay::run( $run_id, $stage, $agent_role, $item_key, $body );
PRAutoBlogger_Run_State::mark_status( $run_id, 'done' );
PRAutoBlogger_Rerun_Job_Support::finish_complete( $result['cost'] );
} catch ( PRAutoBlogger_Cost_Ceiling_Exception $e ) {
// Governor already halted the run + recorded the overage.
PRAutoBlogger_Run_Stage_State::fail( $run_id, $stage, $agent_role, $item_key );
PRAutoBlogger_Rerun_Job_Support::finish_error( $e->getMessage() );
} catch ( \Throwable $e ) {
PRAutoBlogger_Run_Stage_State::fail( $run_id, $stage, $agent_role, $item_key );
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
PRAutoBlogger_Logger::instance()->error(
sprintf( 'Stage replay %s for run %s/%s: %s', get_class( $e ), $run_id, $stage, $e->getMessage() ),
'rerun'
);
PRAutoBlogger_Rerun_Job_Support::finish_error( $e->getMessage() );
} finally {
PRAutoBlogger_Generation_Lock::release();
}
}
/**
* Cron handler: re-run from a stage — demote it + downstream, then
* re-enter the worker so demoted stages rebuild from current
* upstream snapshots.
*
* Side effects: run_stages/runs mutations, worker LLM calls
* (governed), post refresh, status transient, lock acquire/release.
*
* @param string $run_id Run UUID.
* @param int $post_id Post the dossier acted on.
* @param string $stage Target stage (start of the rebuild set).
* @param string $item_key Item key.
* @return void
*/
public function on_rebuild_job( string $run_id, int $post_id, string $stage, string $item_key ): void {
PRAutoBlogger_Rerun_Job_Support::harden_process();
if ( ! PRAutoBlogger_Rerun_Job_Support::acquire_lock_or_report() ) {
return;
}
try {
// require_idle_lock=false: THIS handler owns the lock.
$check = PRAutoBlogger_Rerun_Eligibility::check_rebuild( $run_id, $post_id, $stage, $item_key, false );
if ( ! $check['ok'] ) {
PRAutoBlogger_Rerun_Job_Support::finish_error( $check['reason'] );
return;
}
if ( PRAutoBlogger_Rerun_Job_Support::monthly_budget_exceeded() ) {
PRAutoBlogger_Rerun_Job_Support::finish_error( __( 'Monthly API budget exceeded. Re-run not started.', 'prautoblogger' ) );
return;
}
$idea_json = PRAutoBlogger_Stage_Input_Store::get_seed( $run_id, $item_key );
$idea_data = null !== $idea_json ? json_decode( $idea_json, true ) : null;
if ( ! is_array( $idea_data ) ) {
PRAutoBlogger_Rerun_Job_Support::finish_error( __( 'The stored idea seed could not be decoded.', 'prautoblogger' ) );
return;
}
$idea = new PRAutoBlogger_Article_Idea( $idea_data );
$job_start = current_time( 'mysql' );
$demoted = PRAutoBlogger_Rerun_Eligibility::rebuild_set( $stage );
if ( ! PRAutoBlogger_Run_State::reopen( $run_id ) ) {
PRAutoBlogger_Rerun_Job_Support::finish_error( __( 'The run could not be reopened for new spend.', 'prautoblogger' ) );
return;
}
PRAutoBlogger_Run_Stage_Rerun_State::demote_to_pending( $run_id, $item_key, $demoted );
PRAutoBlogger_Rerun_Job_Support::broadcast(
sprintf(
/* translators: %s: stage display label. */
__( 'Re-running the pipeline from %s…', 'prautoblogger' ),
PRAutoBlogger_Stage_Display_Map::label( $stage )
)
);
$tracker = new PRAutoBlogger_Cost_Tracker();
$tracker->set_run_id( $run_id );
$result = ( new PRAutoBlogger_Article_Worker( $tracker ) )->generate( $idea );
// The worker traps its own failures (fail_open_for_item);
// publish-done is the success signal.
$succeeded = PRAutoBlogger_Run_Stage_State::is_done( $run_id, 'publish', '', $item_key );
// Decisions recorded while rebuilding a human-modified item
// derive from edited content — flag them (guardrail 2).
if ( PRAutoBlogger_Run_Stage_Rerun_State::item_has_human_modified( $run_id, $item_key ) ) {
PRAutoBlogger_Audit_Writer::flag_decisions_human_modified( $run_id, $demoted, $job_start );
}
PRAutoBlogger_Run_State::mark_status( $run_id, $succeeded ? 'done' : 'failed' );
if ( $succeeded ) {
PRAutoBlogger_Rerun_Job_Support::finish_complete( (float) $result['cost'] );
} else {
PRAutoBlogger_Rerun_Job_Support::finish_error( __( 'The re-run did not complete — check the Activity Log and the dossier stage states.', 'prautoblogger' ) );
}
} catch ( \Throwable $e ) {
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
PRAutoBlogger_Logger::instance()->error(
sprintf( 'Rebuild from %s %s for run %s: %s', $stage, get_class( $e ), $run_id, $e->getMessage() ),
'rerun'
);
PRAutoBlogger_Rerun_Job_Support::finish_error( $e->getMessage() );
} finally {
PRAutoBlogger_Generation_Lock::release();
}
}
}

View File

@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Shared plumbing for re-run cron jobs (v0.20.0, M3).
*
* What: The queue/lock/budget/status-transient helpers both
* Rerun_Executor handlers share — extracted so the executor stays
* under the 300-line cap. The status transient writes use the M1
* Generation_Status_Poller shape so the kanban board (and the
* dossier's poll) show queued → pickup → result without any new
* polling infrastructure; the poller's existing R2/R3 orphan
* recovery covers a dead re-run process unchanged.
* Who triggers it: PRAutoBlogger_Rerun_Executor (handlers) and
* PRAutoBlogger_Dossier_Actions (queue()).
* Dependencies: Generation_Lock, Generation_Status_Poller (constants),
* Cost_Tracker, Run_State, Logger, WP-Cron.
*
* @see core/class-rerun-executor.php — The job handlers.
* @see ARCHITECTURE.md #24 — Edit + re-run design.
*/
class PRAutoBlogger_Rerun_Job_Support {
/**
* Schedule a re-run job and fire the cron spawner (non-blocking).
*
* The uniq argument keeps wp_schedule_single_event()'s identical-
* args dedup from swallowing a legitimate second click after a
* failed attempt within the 10-minute window.
*
* Side effects: one single cron event, status transient write,
* non-blocking loopback request.
*
* @param string $action Cron action name.
* @param array $args Handler args.
* @param string $queued_message Operator-facing "queued" copy.
* @return void
*/
public static function queue( string $action, array $args, string $queued_message ): void {
$args[] = (string) microtime( true ); // Uniqueness token.
wp_schedule_single_event( time(), $action, $args );
self::broadcast( $queued_message );
spawn_cron();
wp_remote_post(
site_url( 'wp-cron.php?doing_wp_cron=' . sprintf( '%.22F', microtime( true ) ) ),
array(
'timeout' => 0.01,
'blocking' => false,
'sslverify' => false,
)
);
}
/** Raise process limits for a background LLM job (cron context). */
public static function harden_process(): void {
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@ignore_user_abort( true );
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@set_time_limit( 300 );
}
/**
* Acquire the global generation mutex or report the conflict.
*
* @return bool Whether the lock was acquired.
*/
public static function acquire_lock_or_report(): bool {
if ( PRAutoBlogger_Generation_Lock::acquire() ) {
return true;
}
self::finish_error( __( 'Could not acquire the generation lock — another run is in progress. Re-run again when it completes.', 'prautoblogger' ) );
return false;
}
/**
* Whether the monthly budget hard-stop blocks new spend.
*
* @return bool
*/
public static function monthly_budget_exceeded(): bool {
return ( new PRAutoBlogger_Cost_Tracker() )->is_budget_exceeded();
}
/**
* Restore a terminal run status after an aborted-but-reopened job.
*
* @param string $run_id Run UUID.
* @param string|null $status The pre-reopen status (done/failed/halted).
* @return void
*/
public static function restore_terminal( string $run_id, ?string $status ): void {
if ( null !== $status && in_array( $status, array( 'done', 'failed', 'halted' ), true ) ) {
PRAutoBlogger_Run_State::mark_status( $run_id, $status );
}
}
/**
* Update the live status transient (board + dossier polling).
*
* @param string $message Stage message.
* @return void
*/
public static function broadcast( string $message ): void {
set_transient(
PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
array(
'status' => 'running',
'stage' => $message,
'started' => time(),
'last_updated' => time(),
),
PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
);
}
/**
* Final transient: success (board-compatible shape).
*
* @param float $cost Job spend in USD.
* @return void
*/
public static function finish_complete( float $cost ): void {
set_transient(
PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
array(
'status' => 'complete',
'generated' => 0,
'published' => 0,
'rejected' => 0,
'cost' => $cost,
'message' => __( 'Re-run complete.', 'prautoblogger' ),
),
PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
);
}
/**
* Final transient: error (board-compatible shape) + log.
*
* @param string $message Operator-facing error.
* @return void
*/
public static function finish_error( string $message ): void {
PRAutoBlogger_Logger::instance()->warning( 'Re-run job: ' . $message, 'rerun' );
set_transient(
PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
array(
'status' => 'error',
'message' => $message,
),
PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
);
}
}

View File

@@ -16,8 +16,9 @@ declare(strict_types=1);
* ledger/state activity for 2× the expected run wall-clock are
* marked `failed` and their non-done stages reaped to `failed`;
* such runs render as "incomplete" via incomplete_runs().
* 3. Retention — `request_json` on generation_log and stage output
* 3. Retention — `request_json` on generation_log, stage output
* payloads (`meta_json` on run_stages) are NULLed after R days;
* payloads, and stage_inputs human-fork bodies (v0.20.0);
* R comes from the `prautoblogger_request_json_retention_days`
* SETTING (default constant 14; 0 = keep forever) — never a
* literal at the point of use.
@@ -132,7 +133,9 @@ class PRAutoBlogger_Run_Reaper {
* @param int $seconds Expected seconds (default 300).
*/
$expected = (int) apply_filters( 'prautoblogger_filter_stage_expected_seconds', self::EXPECTED_STAGE_SECONDS );
$cutoff = gmdate( 'Y-m-d H:i:s', time() - ( 2 * $expected ) );
// Use wp_date() so the cutoff is expressed in the site's local timezone,
// matching the updated_at values written by current_time('mysql') (v0.22.1 TZ fix).
$cutoff = wp_date( 'Y-m-d H:i:s', time() - ( 2 * $expected ) );
$now = current_time( 'mysql' );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
@@ -168,7 +171,8 @@ class PRAutoBlogger_Run_Reaper {
* @param int $seconds Expected seconds (default 1800).
*/
$expected = (int) apply_filters( 'prautoblogger_filter_expected_run_seconds', self::EXPECTED_RUN_SECONDS );
$cutoff = gmdate( 'Y-m-d H:i:s', time() - ( 2 * $expected ) );
// Use wp_date() so the cutoff matches updated_at's local-timezone storage (v0.22.1 TZ fix).
$cutoff = wp_date( 'Y-m-d H:i:s', time() - ( 2 * $expected ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$stuck = $wpdb->get_col(
@@ -265,6 +269,11 @@ class PRAutoBlogger_Run_Reaper {
$pruned += is_numeric( $result ) ? (int) $result : 0;
}
// v0.20.0: human edit-fork bodies ride the same retention (seed
// rows are kept — structural, ~1KB). A pruned fork can no longer
// be replayed; the dossier explains that state honestly.
$pruned += PRAutoBlogger_Stage_Input_Store::prune_human_bodies( $cutoff );
return $pruned;
}
}

View File

@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Operator-action state mutations for edit + re-run (v0.20.0, M3).
*
* What: The ONLY paths that may demote a 'done' run_stages row — and all
* of them are deliberate operator actions dispatched from the
* dossier (never reachable from pipeline resume code):
* - restart(): done/failed/halted/pending → running for a single-
* stage replay; bumps attempt, clears stale, sets human_modified
* when the replay uses an edited input fork (CPO guardrail 2).
* - mark_stale(): flags downstream 'done' stages as stale after an
* upstream edit enters execution. Stale is a COLUMN, not a
* status: the stage stays 'done', so resume-without-recharge
* logic can never silently re-run it (CPO guardrail 3 holds by
* construction). Only Run_Stage_Writes::done() clears it.
* - demote_to_pending(): re-run-from-here demotion; the worker
* re-entry then rebuilds those stages from current upstream
* snapshots. Audit columns (attempt, human_modified) survive;
* old meta_json survives until overwritten by the fresh done().
* Every method no-ops when the run_stages table is missing.
* Who triggers it: PRAutoBlogger_Rerun_Executor (cron handlers, after
* re-validating eligibility under the generation lock).
* Dependencies: Run_Stage_State (probe/table), $wpdb.
*
* @see core/class-rerun-executor.php — Sole caller.
* @see core/class-run-stage-writes.php — Pipeline-path writes (done clears stale).
* @see core/class-rerun-eligibility.php — Policy gates before any mutation.
* @see ARCHITECTURE.md #24 — Edit + re-run design.
*/
class PRAutoBlogger_Run_Stage_Rerun_State {
/**
* Demote one stage row to 'running' for an operator replay.
*
* Allowed from done/failed/halted/pending — never from 'running'
* (a concurrently executing stage cannot be restarted). Bumps the
* attempt counter, clears `stale` (the row is being refreshed) and
* sets `human_modified` when the replay executes an edited fork.
* `human_modified` is never cleared by any code path — it is sticky
* audit state for the life of the run.
*
* Side effects: one UPDATE on run_stages.
*
* @param string $run_id Run UUID.
* @param string $stage Stage name.
* @param string $agent_role Agent role (resolved by the caller from the actual row).
* @param string $item_key Item key.
* @param bool $human_modified Whether an edited input fork drives this replay.
* @return bool Whether a row was demoted.
*/
public static function restart( string $run_id, string $stage, string $agent_role, string $item_key, bool $human_modified ): bool {
if ( '' === $run_id || ! PRAutoBlogger_Run_Stage_State::is_available() ) {
return false;
}
global $wpdb;
$table = PRAutoBlogger_Run_Stage_State::table_name();
$now = current_time( 'mysql' );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$updated = $wpdb->query(
$wpdb->prepare(
"UPDATE {$table}
SET status = 'running', attempt = attempt + 1, stale = 0,
human_modified = IF(%d = 1, 1, human_modified),
updated_at = %s, finished_at = NULL
WHERE run_id = %s AND stage = %s AND agent_role = %s AND item_key = %s
AND status IN ('done','failed','halted','pending')",
$human_modified ? 1 : 0,
$now,
$run_id,
$stage,
$agent_role,
$item_key
)
);
return 1 === (int) $updated;
}
/**
* Flag a set of 'done' stages as stale (explicit downstream
* invalidation after an upstream edit/re-run — CPO guardrail 3).
*
* Nothing is demoted and nothing auto-re-runs: the rows stay 'done'
* and visible; the operator triggers each subsequent stage (or
* re-run-from-here) deliberately.
*
* @param string $run_id Run UUID.
* @param string $item_key Item key.
* @param array<string> $stages Stage names to flag.
* @return int Rows flagged.
*/
public static function mark_stale( string $run_id, string $item_key, array $stages ): int {
if ( '' === $run_id || empty( $stages ) || ! PRAutoBlogger_Run_Stage_State::is_available() ) {
return 0;
}
global $wpdb;
$table = PRAutoBlogger_Run_Stage_State::table_name();
$placeholders = implode( ',', array_fill( 0, count( $stages ), '%s' ) );
$params = array_merge( array( current_time( 'mysql' ), $run_id, $item_key ), array_values( $stages ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared -- $placeholders is a fixed %s list.
$updated = $wpdb->query(
$wpdb->prepare(
"UPDATE {$table} SET stale = 1, updated_at = %s
WHERE run_id = %s AND item_key = %s AND status = 'done' AND stage IN ({$placeholders})",
$params
)
);
return is_numeric( $updated ) ? (int) $updated : 0;
}
/**
* Demote a set of stages to 'pending' for re-run-from-here.
*
* The pipeline's resume machinery treats pending stages as not-done
* and re-executes them with prompts REBUILT from current upstream
* snapshots — that is how an upstream edit propagates downstream.
* Rows keep attempt/human_modified (audit) and their old meta_json
* until the fresh completion overwrites it; `stale` stays 1 until
* Run_Stage_Writes::done() clears it on fresh output. Never touches
* a 'running' row.
*
* @param string $run_id Run UUID.
* @param string $item_key Item key.
* @param array<string> $stages Stage names to demote.
* @return int Rows demoted.
*/
public static function demote_to_pending( string $run_id, string $item_key, array $stages ): int {
if ( '' === $run_id || empty( $stages ) || ! PRAutoBlogger_Run_Stage_State::is_available() ) {
return 0;
}
global $wpdb;
$table = PRAutoBlogger_Run_Stage_State::table_name();
$placeholders = implode( ',', array_fill( 0, count( $stages ), '%s' ) );
$params = array_merge( array( current_time( 'mysql' ), $run_id, $item_key ), array_values( $stages ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared -- $placeholders is a fixed %s list.
$updated = $wpdb->query(
$wpdb->prepare(
"UPDATE {$table} SET status = 'pending', stale = 1, updated_at = %s, finished_at = NULL
WHERE run_id = %s AND item_key = %s AND status != 'running' AND stage IN ({$placeholders})",
$params
)
);
return is_numeric( $updated ) ? (int) $updated : 0;
}
/**
* Whether any stage row of this item carries the human_modified flag
* (drives derived-decision flagging during rebuilds — guardrail 2).
*
* @param string $run_id Run UUID.
* @param string $item_key Item key.
* @return bool
*/
public static function item_has_human_modified( string $run_id, string $item_key ): bool {
if ( '' === $run_id || ! PRAutoBlogger_Run_Stage_State::is_available() ) {
return false;
}
global $wpdb;
$table = PRAutoBlogger_Run_Stage_State::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$found = $wpdb->get_var(
$wpdb->prepare(
"SELECT 1 FROM {$table} WHERE run_id = %s AND item_key = %s AND human_modified = 1 LIMIT 1",
$run_id,
$item_key
)
);
return null !== $found;
}
}

View File

@@ -6,40 +6,41 @@ declare(strict_types=1);
*
* Per-run per-stage state machine over `wp_prautoblogger_run_stages`.
*
* What: One row per (run_id, stage, agent_role, item_key) holding
* pending|running|done|failed|halted plus the stage's output
* snapshot (meta_json). This is what makes resume idempotent on the
* chained-cron architecture: a `done` stage is NEVER re-run or
* re-charged — re-entry reuses its stored output; `done` is sticky
* (an upsert cannot demote it). agent_role is the Phase-2 fan-out
* dimension (quorum members; the quorum logic itself is Phase 2);
* item_key scopes article-level stages because one run_id spans all
* N articles of a batch run ('' for run-level stages). Self-healing:
* a missing table degrades every method to a no-op/false.
* Who triggers it: Pipeline_Runner (run-level research/analysis),
* Article_Worker (per-article generate/review/publish),
* Content_Generator (per-LLM-stage outline/draft/polish),
* Run_Reaper (stuck-stage sweep + payload pruning).
* Dependencies: WordPress $wpdb, wp_json_encode.
* One row per (run_id, stage, agent_role, item_key) holding
* pending|running|done|failed|halted plus the output snapshot (meta_json).
* Done stages are sticky and never re-charged on resume. Self-healing:
* a missing table degrades every method to a no-op/false.
*
* @see core/class-run-state.php — Run-level lifecycle + ledger row.
* @see core/class-run-reaper.php — Sweeps stages stuck in 'running'.
* @see ARCHITECTURE.md #21 — Idempotency-key design.
* v0.18.2: role is resolved from Stage_Display_Map when caller passes ''
* so start/done/is_done/get_output are always symmetric. Explicit non-empty
* roles (Phase-2 fan-out) are honoured as-is. get() falls back to role=''
* on miss for mid-run-upgrade compat with v0.18.1 rows.
*
* v0.20.0: write bodies extracted to PRAutoBlogger_Run_Stage_Writes (the
* M2 split pattern — this class was at the 300-line cap); the public API
* here is unchanged and delegates. Operator-action mutations (replay
* restart, stale marking, re-run demotion) live in
* PRAutoBlogger_Run_Stage_Rerun_State only.
*
* @see core/class-run-stage-writes.php — Extracted write bodies.
* @see core/class-run-stage-rerun-state.php — M3 operator-action writes.
* @see core/class-run-state.php — Run-level lifecycle + ledger row.
* @see core/class-run-reaper.php — Sweeps stages stuck in 'running'.
* @see ARCHITECTURE.md #22 / #24 — Substrate + edit/re-run design.
*/
class PRAutoBlogger_Run_Stage_State {
/** @var bool|null Per-request "run_stages table exists" probe result. */
private static ?bool $table_ok = null;
/** Fully-qualified run_stages table name. */
/** @return string Fully-qualified run_stages table name. */
public static function table_name(): string {
global $wpdb;
return $wpdb->prefix . 'prautoblogger_run_stages';
}
/**
* Whether the run_stages table exists and is queryable. Cached per
* request; never throws.
* Whether the run_stages table exists and is queryable. Cached per request.
*
* @return bool
*/
@@ -49,7 +50,7 @@ class PRAutoBlogger_Run_Stage_State {
}
global $wpdb;
if ( null === $wpdb ) {
return false; // Not cached: $wpdb may appear later in boot.
return false;
}
$table = self::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
@@ -59,18 +60,17 @@ class PRAutoBlogger_Run_Stage_State {
}
/**
* Stable hash identifying one article idea within a run (post-creation
* idempotency + stage item scoping both key off this).
* Stable 16-hex hash for one article idea within a run.
*
* @param PRAutoBlogger_Article_Idea $idea The idea.
* @return string 16-hex-char hash.
* @return string
*/
public static function idea_hash( PRAutoBlogger_Article_Idea $idea ): string {
return substr( md5( $idea->get_suggested_title() . '|' . $idea->get_topic() ), 0, 16 );
}
/**
* Stage item_key for an article idea ('' is reserved for run-level stages).
* Stage item_key for an article idea ('' reserved for run-level stages).
*
* @param PRAutoBlogger_Article_Idea $idea The idea.
* @return string e.g. 'idea:1a2b3c4d5e6f7a8b'.
@@ -80,135 +80,62 @@ class PRAutoBlogger_Run_Stage_State {
}
/**
* Mark a stage as entered (running). Upsert: first entry inserts the
* row; re-entry bumps `attempt` — but a `done` stage is sticky and is
* neither demoted nor re-counted.
*
* Side effects: one INSERT … ON DUPLICATE KEY UPDATE.
* Mark a stage as running. Sticky: done stages are never demoted.
* Delegates to Run_Stage_Writes (v0.20.0 split — behavior unchanged).
*
* @param string $run_id Run UUID.
* @param string $stage Stage name (see Stage_Display_Map).
* @param string $agent_role Fan-out dimension ('' default in Phase 1).
* @param string $agent_role Fan-out dimension ('' = resolve from stage map).
* @param string $item_key Article scope ('' for run-level stages).
* @return void
*/
public static function start( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): void {
if ( '' === $run_id || ! self::is_available() ) {
return;
}
global $wpdb;
$table = self::table_name();
$now = current_time( 'mysql' );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->query(
$wpdb->prepare(
"INSERT INTO {$table}
(run_id, stage, agent_role, item_key, status, attempt, started_at, updated_at)
VALUES (%s, %s, %s, %s, 'running', 1, %s, %s)
ON DUPLICATE KEY UPDATE
attempt = IF(status = 'done', attempt, attempt + 1),
status = IF(status = 'done', 'done', 'running'),
updated_at = VALUES(updated_at)",
$run_id,
$stage,
$agent_role,
$item_key,
$now,
$now
)
);
PRAutoBlogger_Run_Stage_Writes::start( $run_id, $stage, $agent_role, $item_key );
}
/**
* Mark a stage done, persisting its output for resume-without-recharge.
*
* Side effects: one INSERT … ON DUPLICATE KEY UPDATE.
* Mark a stage done and persist its output for resume-without-recharge.
* Delegates to Run_Stage_Writes (v0.20.0 split; fresh completion also
* clears the `stale` flag there).
*
* @param string $run_id Run UUID.
* @param string $stage Stage name.
* @param string $agent_role Fan-out dimension.
* @param string $agent_role Fan-out dimension ('' = resolve from stage map).
* @param string $item_key Article scope.
* @param string|null $output Stage output to snapshot (pruned after R days).
* @param float $cost_usd Cost attributed to the stage.
* @return void
* @param string|null $output Stage output snapshot (pruned after R days).
* @param float $cost_usd Cost attributed to this stage.
*/
public static function done( string $run_id, string $stage, string $agent_role = '', string $item_key = '', ?string $output = null, float $cost_usd = 0.0 ): void {
if ( '' === $run_id || ! self::is_available() ) {
return;
}
global $wpdb;
$table = self::table_name();
$now = current_time( 'mysql' );
$meta = null !== $output ? wp_json_encode( array( 'output' => $output ) ) : null;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->query(
$wpdb->prepare(
"INSERT INTO {$table}
(run_id, stage, agent_role, item_key, status, attempt, cost_usd, meta_json, started_at, updated_at, finished_at)
VALUES (%s, %s, %s, %s, 'done', 1, %f, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
status = 'done',
cost_usd = VALUES(cost_usd),
meta_json = VALUES(meta_json),
updated_at = VALUES(updated_at),
finished_at = VALUES(finished_at)",
$run_id,
$stage,
$agent_role,
$item_key,
$cost_usd,
$meta,
$now,
$now,
$now
)
);
PRAutoBlogger_Run_Stage_Writes::done( $run_id, $stage, $agent_role, $item_key, $output, $cost_usd );
}
/**
* Mark a stage failed (does not demote a `done` stage).
* Mark a stage failed (does not demote a done stage). Delegates to
* Run_Stage_Writes (v0.20.0 split — behavior unchanged).
*
* @param string $run_id Run UUID.
* @param string $stage Stage name.
* @param string $agent_role Fan-out dimension.
* @param string $agent_role Fan-out dimension ('' = resolve from stage map).
* @param string $item_key Article scope.
* @return void
*/
public static function fail( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): void {
if ( '' === $run_id || ! self::is_available() ) {
return;
}
global $wpdb;
$table = self::table_name();
$now = current_time( 'mysql' );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->query(
$wpdb->prepare(
"UPDATE {$table} SET status = 'failed', updated_at = %s, finished_at = %s
WHERE run_id = %s AND stage = %s AND agent_role = %s AND item_key = %s AND status != 'done'",
$now,
$now,
$run_id,
$stage,
$agent_role,
$item_key
)
);
PRAutoBlogger_Run_Stage_Writes::fail( $run_id, $stage, $agent_role, $item_key );
}
/**
* Fetch one stage row.
* Fetch one stage row. Falls back to role='' on miss for mid-run-upgrade
* compat with v0.18.1 rows (one extra indexed query, only on miss).
*
* @param string $run_id Run UUID.
* @param string $stage Stage name.
* @param string $agent_role Fan-out dimension.
* @param string $agent_role Fan-out dimension ('' = resolve from stage map).
* @param string $item_key Article scope.
* @return array<string, mixed>|null Row or null.
* @return array<string, mixed>|null
*/
public static function get( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): ?array {
if ( '' === $run_id || ! self::is_available() ) {
return null;
}
$agent_role = PRAutoBlogger_Run_Stage_Writes::resolve_role( $stage, $agent_role );
global $wpdb;
$table = self::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
@@ -222,7 +149,25 @@ class PRAutoBlogger_Run_Stage_State {
),
ARRAY_A
);
return is_array( $row ) ? $row : null;
if ( is_array( $row ) ) {
return $row;
}
// v0.18.1 upgrade compat: rows started before this fix carry role=''.
// One extra indexed query, only on miss, only for known stages.
if ( '' !== $agent_role ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$legacy = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$table} WHERE run_id = %s AND stage = %s AND agent_role = '' AND item_key = %s",
$run_id,
$stage,
$item_key
),
ARRAY_A
);
return is_array( $legacy ) ? $legacy : null;
}
return null;
}
/**
@@ -230,7 +175,7 @@ class PRAutoBlogger_Run_Stage_State {
*
* @param string $run_id Run UUID.
* @param string $stage Stage name.
* @param string $agent_role Fan-out dimension.
* @param string $agent_role Fan-out dimension ('' = resolve from stage map).
* @param string $item_key Article scope.
* @return bool
*/
@@ -244,7 +189,7 @@ class PRAutoBlogger_Run_Stage_State {
*
* @param string $run_id Run UUID.
* @param string $stage Stage name.
* @param string $agent_role Fan-out dimension.
* @param string $agent_role Fan-out dimension ('' = resolve from stage map).
* @param string $item_key Article scope.
* @return string|null
*/
@@ -258,32 +203,65 @@ class PRAutoBlogger_Run_Stage_State {
}
/**
* Fail every still-open (pending/running) stage of one item — used by
* the worker's catch-all so a crashed article leaves no stage stuck
* in 'running' until the reaper. Done stages are untouched.
* Fail every still-open stage of one item. Done stages are untouched.
* Delegates to Run_Stage_Writes (v0.20.0 split — behavior unchanged).
*
* @param string $run_id Run UUID.
* @param string $item_key Stage item key ('' = run-level stages).
* @return void
*/
public static function fail_open_for_item( string $run_id, string $item_key ): void {
PRAutoBlogger_Run_Stage_Writes::fail_open_for_item( $run_id, $item_key );
}
/**
* All stage rows for one item of a run (plus run-level rows when
* $include_run_level), ordered by id. Read API for the rerun engine
* and the dossier's item-scoped assembly (v0.20.0).
*
* @param string $run_id Run UUID.
* @param string $item_key Item key ('idea:<hash>').
* @param bool $include_run_level Include item_key='' rows too.
* @return array<int, array<string, mixed>> Stage rows (ARRAY_A).
*/
public static function stages_for_item( string $run_id, string $item_key, bool $include_run_level = true ): array {
if ( '' === $run_id || ! self::is_available() ) {
return;
return array();
}
global $wpdb;
$table = self::table_name();
$now = current_time( 'mysql' );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->query(
$sql = $include_run_level
? "SELECT * FROM {$table} WHERE run_id = %s AND item_key IN (%s, '') ORDER BY id ASC"
: "SELECT * FROM {$table} WHERE run_id = %s AND item_key = %s ORDER BY id ASC";
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- assembled from two fixed literals above.
$rows = $wpdb->get_results( $wpdb->prepare( $sql, $run_id, $item_key ), ARRAY_A );
return is_array( $rows ) ? $rows : array();
}
/**
* Which of the given runs carry at least one stale stage (batched --
* feeds the Review Queue warning chips without N+1; CPO product AC:
* a stale flag is present at publish). Self-healing: missing table
* or column returns [].
*
* @param array<string> $run_ids Run UUIDs.
* @return array<string> Subset of $run_ids having stale stages.
*/
public static function runs_with_stale_stages( array $run_ids ): array {
$run_ids = array_values( array_filter( array_map( 'strval', $run_ids ) ) );
if ( empty( $run_ids ) || ! self::is_available() ) {
return array();
}
global $wpdb;
$table = self::table_name();
$placeholders = implode( ',', array_fill( 0, count( $run_ids ), '%s' ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- $placeholders is a fixed %s list.
$rows = $wpdb->get_col(
$wpdb->prepare(
"UPDATE {$table} SET status = 'failed', updated_at = %s, finished_at = %s
WHERE run_id = %s AND item_key = %s AND status IN ('pending','running')",
$now,
$now,
$run_id,
$item_key
"SELECT DISTINCT run_id FROM {$table} WHERE stale = 1 AND run_id IN ({$placeholders})",
$run_ids
)
);
return is_array( $rows ) ? array_map( 'strval', $rows ) : array();
}
/** Reset per-request caches (tests). */

View File

@@ -0,0 +1,222 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Pipeline-path write operations over `wp_prautoblogger_run_stages`.
*
* Extracted from PRAutoBlogger_Run_Stage_State in v0.20.0 so that class
* stays under the 300-line cap (the M2 class-split pattern): start/done/
* fail/fail_open_for_item bodies moved here verbatim; Run_Stage_State
* keeps thin delegating proxies, so every existing call site is
* unchanged. One M3 behavior delta, isolated in done(): a freshly
* completed stage clears its `stale` flag (fresh output is never stale),
* with a self-healing legacy retry for half-migrated schemas that lack
* the v0.20.0 columns (same retry pattern as Cost_Tracker v0.18.0).
*
* Operator-action mutations (restart/mark_stale/demote) live in
* PRAutoBlogger_Run_Stage_Rerun_State, not here.
*
* Triggered by: PRAutoBlogger_Run_Stage_State proxies (pipeline call sites).
* Dependencies: Run_Stage_State (table probe), Stage_Display_Map (role).
*
* @see core/class-run-stage-state.php — Public API + read methods.
* @see core/class-run-stage-rerun-state.php — M3 operator-action writes.
* @see ARCHITECTURE.md #22 / #24 — Substrate + edit/re-run design.
*/
class PRAutoBlogger_Run_Stage_Writes {
/**
* Mark a stage as running. Sticky: done stages are never demoted.
*
* @param string $run_id Run UUID.
* @param string $stage Stage name (see Stage_Display_Map).
* @param string $agent_role Fan-out dimension ('' = resolve from stage map).
* @param string $item_key Article scope ('' for run-level stages).
*/
public static function start( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): void {
if ( '' === $run_id || ! PRAutoBlogger_Run_Stage_State::is_available() ) {
return;
}
$agent_role = self::resolve_role( $stage, $agent_role );
global $wpdb;
$table = PRAutoBlogger_Run_Stage_State::table_name();
$now = current_time( 'mysql' );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->query(
$wpdb->prepare(
"INSERT INTO {$table}
(run_id, stage, agent_role, item_key, status, attempt, started_at, updated_at)
VALUES (%s, %s, %s, %s, 'running', 1, %s, %s)
ON DUPLICATE KEY UPDATE
attempt = IF(status = 'done', attempt, attempt + 1),
status = IF(status = 'done', 'done', 'running'),
updated_at = VALUES(updated_at)",
$run_id,
$stage,
$agent_role,
$item_key,
$now,
$now
)
);
}
/**
* Mark a stage done and persist its output for resume-without-recharge.
*
* v0.20.0: a fresh completion clears the `stale` flag (and never
* touches `human_modified` — that flag is sticky run audit). When the
* site's schema predates the v0.20.0 columns (cron fired before the
* admin_init migration pass), the write self-heals by retrying the
* pre-v0.20.0 statement instead of losing the checkpoint.
*
* @param string $run_id Run UUID.
* @param string $stage Stage name.
* @param string $agent_role Fan-out dimension ('' = resolve from stage map).
* @param string $item_key Article scope.
* @param string|null $output Stage output snapshot (pruned after R days).
* @param float $cost_usd Cost attributed to this stage.
*/
public static function done( string $run_id, string $stage, string $agent_role = '', string $item_key = '', ?string $output = null, float $cost_usd = 0.0 ): void {
if ( '' === $run_id || ! PRAutoBlogger_Run_Stage_State::is_available() ) {
return;
}
$agent_role = self::resolve_role( $stage, $agent_role );
global $wpdb;
$table = PRAutoBlogger_Run_Stage_State::table_name();
$now = current_time( 'mysql' );
$meta = null !== $output ? wp_json_encode( array( 'output' => $output ) ) : null;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$result = $wpdb->query(
$wpdb->prepare(
"INSERT INTO {$table}
(run_id, stage, agent_role, item_key, status, attempt, cost_usd, meta_json, stale, started_at, updated_at, finished_at)
VALUES (%s, %s, %s, %s, 'done', 1, %f, %s, 0, %s, %s, %s)
ON DUPLICATE KEY UPDATE
status = 'done',
cost_usd = VALUES(cost_usd),
meta_json = VALUES(meta_json),
stale = 0,
updated_at = VALUES(updated_at),
finished_at = VALUES(finished_at)",
$run_id,
$stage,
$agent_role,
$item_key,
$cost_usd,
$meta,
$now,
$now,
$now
)
);
// Self-healing on a half-migrated schema (no `stale` column yet):
// retry with the pre-v0.20.0 statement rather than losing the
// resume checkpoint. Same pattern as Cost_Tracker (v0.18.0).
if ( false === $result ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->query(
$wpdb->prepare(
"INSERT INTO {$table}
(run_id, stage, agent_role, item_key, status, attempt, cost_usd, meta_json, started_at, updated_at, finished_at)
VALUES (%s, %s, %s, %s, 'done', 1, %f, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
status = 'done',
cost_usd = VALUES(cost_usd),
meta_json = VALUES(meta_json),
updated_at = VALUES(updated_at),
finished_at = VALUES(finished_at)",
$run_id,
$stage,
$agent_role,
$item_key,
$cost_usd,
$meta,
$now,
$now,
$now
)
);
}
}
/**
* Mark a stage failed (does not demote a done stage).
*
* @param string $run_id Run UUID.
* @param string $stage Stage name.
* @param string $agent_role Fan-out dimension ('' = resolve from stage map).
* @param string $item_key Article scope.
*/
public static function fail( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): void {
if ( '' === $run_id || ! PRAutoBlogger_Run_Stage_State::is_available() ) {
return;
}
$agent_role = self::resolve_role( $stage, $agent_role );
global $wpdb;
$table = PRAutoBlogger_Run_Stage_State::table_name();
$now = current_time( 'mysql' );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->query(
$wpdb->prepare(
"UPDATE {$table} SET status = 'failed', updated_at = %s, finished_at = %s
WHERE run_id = %s AND stage = %s AND agent_role = %s AND item_key = %s AND status != 'done'",
$now,
$now,
$run_id,
$stage,
$agent_role,
$item_key
)
);
}
/**
* Fail every still-open stage of one item. Done stages are untouched.
*
* @param string $run_id Run UUID.
* @param string $item_key Stage item key ('' = run-level stages).
*/
public static function fail_open_for_item( string $run_id, string $item_key ): void {
if ( '' === $run_id || ! PRAutoBlogger_Run_Stage_State::is_available() ) {
return;
}
global $wpdb;
$table = PRAutoBlogger_Run_Stage_State::table_name();
$now = current_time( 'mysql' );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->query(
$wpdb->prepare(
"UPDATE {$table} SET status = 'failed', updated_at = %s, finished_at = %s
WHERE run_id = %s AND item_key = %s AND status IN ('pending','running')",
$now,
$now,
$run_id,
$item_key
)
);
}
/**
* Resolve the effective agent role for a stage.
*
* '' → derive from Stage_Display_Map (unknown stages map to '').
* Non-empty → returned as-is (Phase-2 explicit fan-out role).
*
* Public in v0.20.0 (was private on Run_Stage_State) so the read API
* and the rerun classes share one resolution source.
*
* @param string $stage Stage name.
* @param string $agent_role Caller-supplied role ('' = auto-resolve).
* @return string Effective agent role.
*/
public static function resolve_role( string $stage, string $agent_role ): string {
if ( '' !== $agent_role ) {
return $agent_role;
}
return (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( $stage );
}
}

View File

@@ -252,6 +252,41 @@ class PRAutoBlogger_Run_State {
return is_array( $rows ) ? $rows : array();
}
/**
* Reopen a terminal run for operator-deliberate new spend (M3
* re-runs). done/failed/halted -> running; finished_at cleared; the
* cost ceiling is RE-SNAPSHOTTED from the current setting — the
* deliberate re-run action adopts the operator's current per-run
* policy (can lower as well as raise), exactly as a new run would.
* Accumulated reserved/settled spend is kept: the governor treats
* re-runs as new spend on the SAME run (CPO guardrail 4).
*
* The conditional UPDATE is the atomic gate: an actively executing
* run (pending/running) can never be reopened.
*
* @param string $run_id Run UUID.
* @return bool Whether the run transitioned to running.
*/
public static function reopen( string $run_id ): bool {
if ( '' === $run_id || ! self::is_available() ) {
return false;
}
global $wpdb;
$table = self::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$updated = $wpdb->query(
$wpdb->prepare(
"UPDATE {$table}
SET status = 'running', ceiling_usd = %f, updated_at = %s, finished_at = NULL
WHERE run_id = %s AND status IN ('done','failed','halted')",
self::ceiling_setting(),
current_time( 'mysql' ),
$run_id
)
);
return 1 === (int) $updated;
}
/** Reset per-request caches (tests). */
public static function flush_cache(): void {
self::$table_ok = null;

View File

@@ -204,7 +204,7 @@ class PRAutoBlogger_Semantic_Dedup {
return false;
}
foreach ( $this->recent_titles as $title ) {
$title_keywords = $this->extract_meaningful_keywords( $title );
$title_keywords = PRAutoBlogger_Keyword_Extractor::extract( $title );
$overlap = count( array_intersect( $topic_keywords, $title_keywords ) );
if ( $overlap / count( $topic_keywords ) >= 0.6 ) {
return true;
@@ -241,131 +241,4 @@ class PRAutoBlogger_Semantic_Dedup {
}
return $titles;
}
/**
* Extract meaningful keywords from text (fallback dedup).
*
* @param string $text Input text.
* @return string[] Unique lowercase keywords.
*/
private function extract_meaningful_keywords( string $text ): array {
static $stopwords = array(
'a',
'an',
'the',
'and',
'or',
'but',
'is',
'are',
'was',
'were',
'in',
'on',
'at',
'to',
'for',
'of',
'with',
'by',
'from',
'as',
'it',
'its',
'this',
'that',
'your',
'you',
'how',
'what',
'when',
'why',
'where',
'which',
'who',
'do',
'does',
'did',
'not',
'no',
'can',
'will',
'should',
'could',
'would',
'may',
'might',
'have',
'has',
'had',
'be',
'been',
'being',
'about',
'into',
'through',
'during',
'before',
'after',
'above',
'below',
'between',
'same',
'up',
'down',
'out',
'off',
'over',
'under',
'again',
'then',
'here',
'there',
'all',
'each',
'every',
'both',
'few',
'more',
'most',
'other',
'some',
'such',
'than',
'too',
'very',
'just',
'also',
'only',
'own',
'so',
'if',
'while',
'because',
'until',
'vs',
'versus',
'guide',
'complete',
'ultimate',
'best',
'top',
'new',
'first',
'need',
'know',
'everything',
);
$text = strtolower( $text );
$words = preg_split( '/[^a-z0-9-]+/', $text, -1, PREG_SPLIT_NO_EMPTY );
$words = array_filter(
$words,
static function ( string $w ) use ( $stopwords ): bool {
return strlen( $w ) >= 2 && ! in_array( $w, $stopwords, true );
}
);
return array_values( array_unique( $words ) );
}
}

View File

@@ -76,8 +76,33 @@ class PRAutoBlogger_Source_Collector {
'collector'
);
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->error( sprintf( 'Collection %s from %s: %s', get_class( $e ), $source_type, $e->getMessage() ), 'collector' );
// Continue with other sources — one failure shouldn't stop everything.
// Structured failure meta: model slug, HTTP status (parsed from message),
// and first ~200 chars of the error body — enables post-incident diagnosis
// without needing to re-run the failing collection. Added v0.18.2.
$config_for_log = $this->get_config_for_source( $source_type );
$model_slug = (string) ( $config_for_log['model'] ?? '' );
$err_msg = $e->getMessage();
// Extract HTTP status from "OpenRouter API error (HTTP NNN): ..." pattern.
$http_status = 0;
if ( preg_match( '/HTTP (\d{3})/', $err_msg, $m ) ) {
$http_status = (int) $m[1];
}
PRAutoBlogger_Logger::instance()->error(
sprintf(
'Collection %s from %s: %s',
get_class( $e ),
$source_type,
$e->getMessage()
),
'collector',
array(
'source' => $source_type,
'model' => $model_slug,
'http_status' => $http_status,
'error_body' => mb_substr( $err_msg, 0, 200 ),
)
);
// Continue with other sources — one failure should not stop everything.
}
}

View File

@@ -0,0 +1,269 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* INSERT-only store over `wp_prautoblogger_stage_inputs` (v0.20.0, M3).
*
* What: Immutable input-version rows for the edit + re-run feature.
* Two row kinds, discriminated by `source`:
* - 'human': an operator's edited request body for one stage. Each
* save creates the NEXT version for the (run, stage, role, item)
* scope — there is deliberately no UPDATE path in this class, so
* originals (and earlier forks) can never be overwritten (CPO
* guardrail 1; the executed original lives untouched in
* generation_log.request_json).
* - 'seed': the serialized Article_Idea persisted at worker start
* (stage='', version=1). Re-run-from-here reconstructs the EXACT
* idea from it — rebuilding the idea from post fields would risk
* an item_key hash mismatch (post_title is sanitized at insert)
* and a duplicate post.
* Self-healing: every method no-ops (null/false/empty) when the
* table is missing (half-migrated schema).
* Retention: human fork bodies are NULLed by the run-reaper after
* R days (same setting as all heavy payloads); seed rows are ~1KB
* structural state and are kept.
* Who triggers it: Dossier_Actions (save fork), Rerun_Executor (read fork
* / seed), Article_Worker (save seed), Run_Reaper (prune).
* Dependencies: WordPress $wpdb.
*
* @see admin/class-dossier-actions.php — Fork save endpoint.
* @see core/class-rerun-executor.php — Fork/seed consumption.
* @see core/class-run-reaper.php — Retention prune.
* @see ARCHITECTURE.md #24 — Edit + re-run design.
*/
class PRAutoBlogger_Stage_Input_Store {
/** Source discriminator: operator edit fork. */
public const SOURCE_HUMAN = 'human';
/** Source discriminator: idea seed persisted at worker start. */
public const SOURCE_SEED = 'seed';
/** @var bool|null Per-request "stage_inputs table exists" probe result. */
private static ?bool $table_ok = null;
/** @return string Fully-qualified stage_inputs table name. */
public static function table_name(): string {
global $wpdb;
return $wpdb->prefix . 'prautoblogger_stage_inputs';
}
/**
* Whether the stage_inputs table exists. Cached per request.
*
* @return bool
*/
public static function is_available(): bool {
if ( null !== self::$table_ok ) {
return self::$table_ok;
}
global $wpdb;
if ( null === $wpdb ) {
return false; // Not cached: $wpdb may appear later in boot.
}
$table = self::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
self::$table_ok = ( $found === $table );
return self::$table_ok;
}
/**
* Save a human edit fork as the next version for its scope.
*
* INSERT-only by design. The version number is MAX(version)+1 for the
* scope; a concurrent-save collision on the UNIQUE key is retried
* once with a refreshed version.
*
* Side effects: one (rarely two) INSERTs into stage_inputs.
*
* @param string $run_id Run UUID.
* @param string $stage Stage name.
* @param string $agent_role Stage row's agent role.
* @param string $item_key Stage row's item key.
* @param string $request_json Edited request body (full JSON, no headers).
* @param string $author WP user login of the editor.
* @return int|null New version number, or null on failure/missing table.
*/
public static function save_fork( string $run_id, string $stage, string $agent_role, string $item_key, string $request_json, string $author ): ?int {
if ( '' === $run_id || '' === $stage || ! self::is_available() ) {
return null;
}
for ( $attempt = 0; $attempt < 2; $attempt++ ) {
$version = self::next_version( $run_id, $stage, $agent_role, $item_key );
$inserted = self::insert_row( $run_id, $stage, $agent_role, $item_key, $version, self::SOURCE_HUMAN, $request_json, $author );
if ( $inserted ) {
return $version;
}
}
return null;
}
/**
* Latest human fork row for a scope, or null when none exist.
*
* @param string $run_id Run UUID.
* @param string $stage Stage name.
* @param string $agent_role Agent role.
* @param string $item_key Item key.
* @return array<string, mixed>|null Row incl. version/request_json/author/created_at.
*/
public static function latest_fork( string $run_id, string $stage, string $agent_role, string $item_key ): ?array {
if ( '' === $run_id || ! self::is_available() ) {
return null;
}
global $wpdb;
$table = self::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$table}
WHERE run_id = %s AND stage = %s AND agent_role = %s AND item_key = %s AND source = %s
ORDER BY version DESC LIMIT 1",
$run_id,
$stage,
$agent_role,
$item_key,
self::SOURCE_HUMAN
),
ARRAY_A
);
return is_array( $row ) ? $row : null;
}
/**
* Persist the idea seed for one run item (INSERT-once; replays no-op).
*
* @param string $run_id Run UUID.
* @param string $item_key Item key from Run_Stage_State::item_key_for_idea().
* @param string $idea_json Serialized Article_Idea::to_array() payload.
* @return void
*/
public static function save_seed( string $run_id, string $item_key, string $idea_json ): void {
if ( '' === $run_id || '' === $item_key || ! self::is_available() ) {
return;
}
if ( null !== self::get_seed( $run_id, $item_key ) ) {
return; // Seed already persisted (idempotent resume).
}
self::insert_row( $run_id, '', '', $item_key, 1, self::SOURCE_SEED, $idea_json, 'pipeline' );
}
/**
* The idea seed JSON for one run item, or null when absent.
*
* @param string $run_id Run UUID.
* @param string $item_key Item key.
* @return string|null
*/
public static function get_seed( string $run_id, string $item_key ): ?string {
if ( '' === $run_id || ! self::is_available() ) {
return null;
}
global $wpdb;
$table = self::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$json = $wpdb->get_var(
$wpdb->prepare(
"SELECT request_json FROM {$table}
WHERE run_id = %s AND stage = '' AND item_key = %s AND source = %s LIMIT 1",
$run_id,
$item_key,
self::SOURCE_SEED
)
);
return ( is_string( $json ) && '' !== $json ) ? $json : null;
}
/**
* Retention: NULL human fork bodies older than the cutoff. Seed rows
* are kept (structural, ~1KB). Version/author/flag metadata survives
* as the permanent audit trail.
*
* @param string $cutoff MySQL datetime; rows created before it are pruned.
* @return int Rows pruned.
*/
public static function prune_human_bodies( string $cutoff ): int {
if ( ! self::is_available() ) {
return 0;
}
global $wpdb;
$table = self::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$result = $wpdb->query(
$wpdb->prepare(
"UPDATE {$table} SET request_json = NULL
WHERE source = %s AND request_json IS NOT NULL AND created_at < %s",
self::SOURCE_HUMAN,
$cutoff
)
);
return is_numeric( $result ) ? (int) $result : 0;
}
/** Reset per-request caches (tests). */
public static function flush_cache(): void {
self::$table_ok = null;
}
/**
* Next version number for a scope (1 when none exist).
*
* @param string $run_id Run UUID.
* @param string $stage Stage name.
* @param string $agent_role Agent role.
* @param string $item_key Item key.
* @return int
*/
private static function next_version( string $run_id, string $stage, string $agent_role, string $item_key ): int {
global $wpdb;
$table = self::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$max = $wpdb->get_var(
$wpdb->prepare(
"SELECT MAX(version) FROM {$table}
WHERE run_id = %s AND stage = %s AND agent_role = %s AND item_key = %s",
$run_id,
$stage,
$agent_role,
$item_key
)
);
return ( null !== $max ? (int) $max : 0 ) + 1;
}
/**
* Raw row INSERT (the only write path — INSERT-only by construction).
*
* @param string $run_id Run UUID.
* @param string $stage Stage name ('' for seeds).
* @param string $agent_role Agent role.
* @param string $item_key Item key.
* @param int $version Version number.
* @param string $source 'human' or 'seed'.
* @param string $request_json Payload JSON.
* @param string $author Author label.
* @return bool Whether the row was inserted.
*/
private static function insert_row( string $run_id, string $stage, string $agent_role, string $item_key, int $version, string $source, string $request_json, string $author ): bool {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$inserted = $wpdb->insert(
self::table_name(),
array(
'run_id' => $run_id,
'stage' => $stage,
'agent_role' => $agent_role,
'item_key' => $item_key,
'version' => $version,
'source' => $source,
'request_json' => $request_json,
'author' => $author,
'created_at' => current_time( 'mysql' ),
)
);
return false !== $inserted;
}
}

View File

@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Executes one stage replay from a saved input fork (v0.20.0, M3).
*
* What: Decodes an immutable stage_inputs fork body back into the
* (messages, model, options) triple and sends it through the
* normal provider seam — so the cost governor's reserve-before-
* call (on the SAME run ledger row), the retry/backoff loop, the
* empty-completion guard, and the B1 request recorder all apply
* to replays exactly as to pipeline calls (CPO guardrail 4). The
* fresh output lands in the stage's run_stages snapshot (the same
* place downstream rebuilds read from) and a generation_log row
* is written with the replayed request body.
*
* options_from_body() is the exact inverse of Request_Builder::
* build_body() + apply_reasoning_budget(): the v0.18.1 reasoning
* headroom is subtracted back out (the builder re-adds it), and
* reasoning is pinned explicitly — enabled:false when the original
* body had none — so a later change to the global reasoning
* setting can never alter the replay's shape.
* Who triggers it: PRAutoBlogger_Rerun_Executor::on_replay_job() only
* (after eligibility re-validation, lock, reopen, restart and
* stale-marking).
* Dependencies: OpenRouter_Provider, Cost_Tracker, Run_Stage_State,
* Audit_Writer, Stage_Display_Map.
*
* @see core/class-rerun-executor.php — Orchestration around this.
* @see providers/class-open-router-request-builder.php — The forward transform.
* @see ARCHITECTURE.md #24 — Edit + re-run design.
*/
class PRAutoBlogger_Stage_Replay {
/**
* Reconstruct provider options from a stored request body.
*
* Inverse of build_body()/apply_reasoning_budget(): copies
* temperature/response_format; recovers the caller's max_tokens by
* subtracting the reasoning cap the builder added as headroom; pins
* reasoning explicitly (the stored body is the contract — global
* setting drift must not leak in). Adds the caller-metadata keys
* (stage, prompt_key) used by the guard's logging — never sent
* upstream.
*
* @param array<string, mixed> $body Decoded request body.
* @param string $stage Stage being replayed.
* @return array<string, mixed> Options for send_chat_completion().
*/
public static function options_from_body( array $body, string $stage ): array {
$options = array();
if ( isset( $body['temperature'] ) ) {
$options['temperature'] = $body['temperature'];
}
if ( isset( $body['response_format'] ) ) {
$options['response_format'] = $body['response_format'];
}
$reasoning_active = isset( $body['reasoning'] ) && false !== ( $body['reasoning']['enabled'] ?? true );
if ( $reasoning_active ) {
$options['reasoning'] = $body['reasoning'];
if ( isset( $body['max_tokens'] ) ) {
$cap = isset( $body['reasoning']['max_tokens'] ) ? (int) $body['reasoning']['max_tokens'] : 0;
// The builder raised max_tokens by the cap (completion
// headroom); hand back the caller-level value so the
// builder's re-application is not compounded.
$options['max_tokens'] = max( 1, (int) $body['max_tokens'] - $cap );
}
} else {
// Pin reasoning OFF (same per-call override shape the
// empty-completion retry uses) so a globally-enabled
// reasoning setting cannot reshape the replay.
$options['reasoning'] = array( 'enabled' => false );
if ( isset( $body['max_tokens'] ) ) {
$options['max_tokens'] = (int) $body['max_tokens'];
}
}
$options['stage'] = $stage;
$options['prompt_key'] = PRAutoBlogger_Stage_Display_Map::default_prompt_key( $stage );
return $options;
}
/**
* Decode + validate a fork body. Returns null when the JSON is not a
* replayable chat body (model string + non-empty messages list of
* role/content pairs).
*
* @param string $request_json Stored fork body.
* @return array<string, mixed>|null Decoded body, or null when invalid.
*/
public static function decode_body( string $request_json ): ?array {
$body = json_decode( $request_json, true );
if ( ! is_array( $body ) || empty( $body['model'] ) || ! is_string( $body['model'] ) ) {
return null;
}
if ( empty( $body['messages'] ) || ! is_array( $body['messages'] ) ) {
return null;
}
foreach ( $body['messages'] as $message ) {
if ( ! is_array( $message ) || ! isset( $message['role'], $message['content'] )
|| ! is_string( $message['role'] ) || ! is_string( $message['content'] ) ) {
return null;
}
}
return $body;
}
/**
* Execute the replay call and persist its results.
*
* The caller (Rerun_Executor) has already: re-validated eligibility,
* taken the generation lock, reopened the run, demoted the stage row
* to running and stale-marked downstream. This method performs the
* governed LLM call and the success-path writes.
*
* Side effects: one OpenRouter call (governor-reserved), run_stages
* done-write, one generation_log row (with the replayed request via
* the B1 recorder), run_decisions human_modified flags.
*
* @param string $run_id Run UUID.
* @param string $stage Stage name.
* @param string $agent_role Stage row agent role.
* @param string $item_key Stage row item key.
* @param array<string, mixed> $body Decoded fork body (from decode_body()).
* @return array{content: string, cost: float, model: string} Replay result.
*
* @throws \RuntimeException On provider failure or empty output.
* @throws PRAutoBlogger_Cost_Ceiling_Exception When the governor blocks the call.
*/
public static function run( string $run_id, string $stage, string $agent_role, string $item_key, array $body ): array {
$tracker = new PRAutoBlogger_Cost_Tracker();
// Joins this process to the run: Run_Context (governor guard),
// ledger row, prompt pins — replay spend reserves against the
// SAME per-run ceiling as the original calls (guardrail 4).
$tracker->set_run_id( $run_id );
$llm = new PRAutoBlogger_OpenRouter_Provider();
$options = self::options_from_body( $body, $stage );
$response = $llm->send_chat_completion( $body['messages'], (string) $body['model'], $options );
// Same belt as the writer path: an empty replay output must fail
// the stage, never overwrite a good snapshot with nothing.
if ( '' === trim( (string) $response['content'] ) ) {
throw new \RuntimeException(
sprintf(
/* translators: 1: stage name, 2: model identifier. */
__( 'Replay of stage "%1$s" produced empty content (model %2$s) — failing the replay instead of storing an empty output.', 'prautoblogger' ),
$stage,
(string) $response['model']
)
);
}
$cost = $llm->estimate_cost(
(string) $response['model'],
(int) $response['prompt_tokens'],
(int) $response['completion_tokens']
);
PRAutoBlogger_Run_Stage_State::done( $run_id, $stage, $agent_role, $item_key, (string) $response['content'], $cost );
$tracker->log_api_call(
null,
$stage,
$llm->get_provider_name(),
(string) $response['model'],
(int) $response['prompt_tokens'],
(int) $response['completion_tokens'],
'success',
'',
'' !== $agent_role ? $agent_role : null,
null
);
// Guardrail 2: decision rows for the replayed stage carry the
// human_modified flag (no-op when the stage has none).
PRAutoBlogger_Audit_Writer::flag_decisions_human_modified( $run_id, array( $stage ) );
return array(
'content' => (string) $response['content'],
'cost' => $cost,
'model' => (string) $response['model'],
);
}
}

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Aspect-ratio snap utility for image generation providers.
*
* Converts pixel dimensions to the nearest supported W:H string for APIs
* that require an aspect-ratio parameter rather than raw pixel sizes.
*
* What: Static utility; no state.
* Who triggers: PRAutoBlogger_OpenRouter_Image_Provider, PRAutoBlogger_OpenRouter_Image_Batch,
* PRAutoBlogger_Runware_Image_Batch.
* Dependencies: none.
*
* @see providers/class-open-router-image-provider.php -- generate_image() path.
* @see providers/class-open-router-image-batch.php -- parallel batch path.
* @see providers/class-runware-image-batch.php -- Runware parallel batch.
*/
class PRAutoBlogger_Image_Aspect_Ratio {
/**
* Standard aspect ratios supported by OpenRouter / Runware image models.
*
* @var array<int, array{w: int, h: int, ratio: float}>
*/
private const STANDARD_ASPECTS = array(
array(
'w' => 1,
'h' => 1,
'ratio' => 1.0,
),
array(
'w' => 3,
'h' => 2,
'ratio' => 1.5,
),
array(
'w' => 2,
'h' => 3,
'ratio' => 0.6667,
),
array(
'w' => 4,
'h' => 3,
'ratio' => 1.3333,
),
array(
'w' => 3,
'h' => 4,
'ratio' => 0.75,
),
array(
'w' => 16,
'h' => 9,
'ratio' => 1.7778,
),
array(
'w' => 9,
'h' => 16,
'ratio' => 0.5625,
),
);
/**
* Snap pixel dimensions to the nearest supported aspect ratio string.
*
* @param int $width Width in pixels.
* @param int $height Height in pixels (must be > 0 to avoid division by zero).
* @return string Ratio string e.g. "16:9". Returns "1:1" when height is 0.
*/
public static function snap( int $width, int $height ): string {
$target = $height > 0 ? (float) $width / (float) $height : 1.0;
$best = self::STANDARD_ASPECTS[0];
$best_d = abs( $target - $best['ratio'] );
foreach ( self::STANDARD_ASPECTS as $candidate ) {
$d = abs( $target - $candidate['ratio'] );
if ( $d < $best_d ) {
$best = $candidate;
$best_d = $d;
}
}
return $best['w'] . ':' . $best['h'];
}
}

View File

@@ -70,6 +70,7 @@ class PRAutoBlogger_LLM_Research_Provider implements PRAutoBlogger_Source_Provid
'temperature' => 0.7,
'max_tokens' => 8000,
'response_format' => array( 'type' => 'json_object' ),
'stage' => 'llm_research',
)
);

View File

@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Empty-completion guard for OpenRouter chat responses (v0.18.1).
*
* What: Treats a chat completion whose visible content is empty/whitespace
* as a FAILURE, never a success — per the LLM-call discipline:
* schema-validate every output, retry/fall back, never silently
* pass. The incident this prevents (2026-06-11, prod run acf24029,
* draft post 921): a true reasoning model at a high reasoning effort
* spent the entire max_tokens completion budget on thinking,
* finished with finish_reason='length', and emitted zero visible
* content — which flowed writer → editor (reject: "draft text is
* missing") → an empty draft post in the Review Queue, with the
* cost booked as 'success'.
*
* Behavior on every parsed response:
* - finish_reason='length' is warning-logged (model, stage, prompt/
* completion/reasoning tokens) whether or not content survived.
* - Empty content + (finish_reason='length' OR reasoning active on
* the request) → ONE retry with reasoning disabled for that call
* (per-call `reasoning` override), then fail the stage.
* - Every failed/retried attempt lands in generation_log with its
* REAL response_status ('error') and token burn, and — inside a
* v0.18.0 run — a run_decisions row records the verdict.
* Who triggers it: PRAutoBlogger_OpenRouter_Provider::send_chat_completion()
* on every successfully parsed response.
* Dependencies: Logger, Cost_Tracker (failure rows), Run_Context (run
* attribution), Audit_Writer (run_decisions verdicts).
*
* @see class-open-router-provider.php — Integration point; supplies the retry callable.
* @see class-open-router-response-parser.php — Produces the parsed array assessed here.
* @see class-open-router-request-builder.php — Reasoning token cap (first line of defense).
* @see core/class-audit-writer.php — run_decisions verdict rows.
*/
class PRAutoBlogger_OpenRouter_Completion_Guard {
/** Verdict recorded when an empty attempt is retried with reasoning disabled. */
public const VERDICT_RETRY = 'retry_reasoning_disabled';
/** Verdict recorded when a call finally fails for empty content. */
public const VERDICT_FAIL = 'failed_empty_completion';
/**
* Assess a parsed completion: pass it through, retry once, or fail loudly.
*
* @param array<string, mixed> $parsed Parsed response from Response_Parser::parse_success().
* @param array<string, mixed> $options Original call options. Reads the caller-metadata keys
* 'stage' / 'prompt_key' (audit attribution) and
* 'empty_retry' (marks the retry attempt — never retried twice).
* @param bool $reasoning_active Whether the dispatched request had reasoning enabled.
* @param callable $retry Re-dispatches the call with the options it is given.
* Invoked at most once, with reasoning disabled.
*
* @return array<string, mixed> A completion whose visible content is non-empty.
*
* @throws \RuntimeException When content is empty and the retry budget is spent or a retry
* cannot plausibly help.
*/
public function finalize( array $parsed, array $options, bool $reasoning_active, callable $retry ): array {
$stage = (string) ( $options['stage'] ?? 'unknown' );
$finish_reason = (string) ( $parsed['finish_reason'] ?? 'unknown' );
if ( 'length' === $finish_reason ) {
PRAutoBlogger_Logger::instance()->warning(
sprintf(
'Completion hit its max_tokens ceiling (finish_reason=length): model=%s, stage=%s, prompt_tokens=%d, completion_tokens=%d, reasoning_tokens=%d.',
(string) $parsed['model'],
$stage,
(int) $parsed['prompt_tokens'],
(int) $parsed['completion_tokens'],
(int) $parsed['reasoning_tokens']
),
'openrouter'
);
}
if ( '' !== trim( (string) ( $parsed['content'] ?? '' ) ) ) {
return $parsed; // Healthy completion — visible content present.
}
$already_retried = ! empty( $options['empty_retry'] );
// A reasoning-disabled retry can plausibly help when the budget was
// exhausted (length) or when reasoning was eating the budget.
$retry_can_help = 'length' === $finish_reason || $reasoning_active;
if ( $retry_can_help && ! $already_retried ) {
$note = sprintf(
'Empty completion (finish_reason=%s, completion_tokens=%d, reasoning_tokens=%d) — retrying once with reasoning disabled.',
$finish_reason,
(int) $parsed['completion_tokens'],
(int) $parsed['reasoning_tokens']
);
$this->record_attempt( $parsed, $options, $stage, self::VERDICT_RETRY, $note );
PRAutoBlogger_Logger::instance()->warning(
sprintf( 'Stage %s: %s (model %s)', $stage, $note, (string) $parsed['model'] ),
'openrouter'
);
$retry_options = $options;
$retry_options['reasoning'] = array( 'enabled' => false );
$retry_options['empty_retry'] = true;
return $retry( $retry_options );
}
$note = sprintf(
'Empty completion (finish_reason=%s, completion_tokens=%d, reasoning_tokens=%d)%s — failing the call.',
$finish_reason,
(int) $parsed['completion_tokens'],
(int) $parsed['reasoning_tokens'],
$already_retried ? ' after a reasoning-disabled retry' : ''
);
$this->record_attempt( $parsed, $options, $stage, self::VERDICT_FAIL, $note );
throw new \RuntimeException(
sprintf(
/* translators: 1: pipeline stage, 2: model identifier, 3: finish_reason value, 4: retry note. */
__( 'LLM call for stage "%1$s" returned empty content (model %2$s, finish_reason=%3$s)%4$s.', 'prautoblogger' ),
$stage,
(string) $parsed['model'],
$finish_reason,
$already_retried ? __( ' after a reasoning-disabled retry', 'prautoblogger' ) : ''
)
);
}
/**
* Book a failed/retried attempt: a generation_log row with the REAL
* response_status and token burn (the empty call still cost money),
* plus a run_decisions verdict for new-style runs. Both writes are
* self-healing no-ops on a half-migrated schema; neither can throw.
*
* @param array<string, mixed> $parsed Parsed response of the failed attempt.
* @param array<string, mixed> $options Call options ('prompt_key' attribution).
* @param string $stage Stage label for the log row.
* @param string $verdict VERDICT_RETRY or VERDICT_FAIL.
* @param string $note Human-readable failure note.
* @return void
*/
private function record_attempt( array $parsed, array $options, string $stage, string $verdict, string $note ): void {
$run_id = PRAutoBlogger_Run_Context::current_run_id();
$tracker = new PRAutoBlogger_Cost_Tracker();
if ( null !== $run_id && '' !== $run_id ) {
// Attribute the row to the surrounding run. Idempotent: the
// run context, ledger row, and prompt pins already exist.
$tracker->set_run_id( $run_id );
}
$tracker->log_api_call(
null,
$stage,
'OpenRouter',
(string) $parsed['model'],
(int) $parsed['prompt_tokens'],
(int) $parsed['completion_tokens'],
'error',
$note,
null,
isset( $options['prompt_key'] ) ? (string) $options['prompt_key'] : null
);
// run_decisions verdict — no-op outside a run or pre-1.2.0 schema.
PRAutoBlogger_Audit_Writer::record_decision( (string) ( $run_id ?? '' ), $stage, $verdict, $note );
}
}

View File

@@ -262,56 +262,7 @@ class PRAutoBlogger_OpenRouter_Image_Batch {
* @return array<string, mixed>
*/
private function build_request_body( string $prompt, int $width, int $height, string $model ): array {
$target = $height > 0 ? (float) $width / (float) $height : 1.0;
// Snap to nearest standard aspect ratio.
$aspects = array(
array(
'w' => 1,
'h' => 1,
'r' => 1.0,
),
array(
'w' => 3,
'h' => 2,
'r' => 1.5,
),
array(
'w' => 2,
'h' => 3,
'r' => 0.6667,
),
array(
'w' => 4,
'h' => 3,
'r' => 1.3333,
),
array(
'w' => 3,
'h' => 4,
'r' => 0.75,
),
array(
'w' => 16,
'h' => 9,
'r' => 1.7778,
),
array(
'w' => 9,
'h' => 16,
'r' => 0.5625,
),
);
$best = $aspects[0];
$best_d = abs( $target - $best['r'] );
foreach ( $aspects as $c ) {
$d = abs( $target - $c['r'] );
if ( $d < $best_d ) {
$best = $c;
$best_d = $d;
}
}
$aspect = $best['w'] . ':' . $best['h'];
$aspect = PRAutoBlogger_Image_Aspect_Ratio::snap( $width, $height );
return array(
'model' => $model,

View File

@@ -233,51 +233,10 @@ class PRAutoBlogger_OpenRouter_Image_Provider implements PRAutoBlogger_Image_Pro
);
}
/**
* Supported aspect ratios for OpenRouter image models.
*
* @var array<int, array{w: int, h: int, ratio: float}>
*/
private const STANDARD_ASPECTS = array(
array(
'w' => 1,
'h' => 1,
'ratio' => 1.0,
),
array(
'w' => 3,
'h' => 2,
'ratio' => 1.5,
),
array(
'w' => 2,
'h' => 3,
'ratio' => 0.6667,
),
array(
'w' => 4,
'h' => 3,
'ratio' => 1.3333,
),
array(
'w' => 3,
'h' => 4,
'ratio' => 0.75,
),
array(
'w' => 16,
'h' => 9,
'ratio' => 1.7778,
),
array(
'w' => 9,
'h' => 16,
'ratio' => 0.5625,
),
);
private function build_request_body( string $prompt, int $width, int $height, string $model ): array {
$aspect = $this->snap_aspect_ratio( $width, $height );
$aspect = PRAutoBlogger_Image_Aspect_Ratio::snap( $width, $height );
return array(
'model' => $model,
@@ -296,29 +255,6 @@ class PRAutoBlogger_OpenRouter_Image_Provider implements PRAutoBlogger_Image_Pro
);
}
/**
* Snap pixel dimensions to the nearest supported aspect ratio string.
*
* @param int $width Width in pixels.
* @param int $height Height in pixels.
* @return string Aspect ratio string like "16:9".
*/
private function snap_aspect_ratio( int $width, int $height ): string {
$target = $height > 0 ? (float) $width / (float) $height : 1.0;
$best = self::STANDARD_ASPECTS[0];
$best_d = abs( $target - $best['ratio'] );
foreach ( self::STANDARD_ASPECTS as $candidate ) {
$d = abs( $target - $candidate['ratio'] );
if ( $d < $best_d ) {
$best = $candidate;
$best_d = $d;
}
}
return $best['w'] . ':' . $best['h'];
}
/**
* Enforce the interface's dimension contract.
*

View File

@@ -19,8 +19,9 @@ declare(strict_types=1);
* @see class-open-router-config.php — Config helpers (base URL, cache TTL).
* @see class-open-router-pricing.php — Pricing and model list lookups.
* @see class-open-router-validator.php — Credential validation (delegated).
* @see class-open-router-request-builder.php — Request header building + cURL filter.
* @see class-open-router-request-builder.php — Request body/header building + cURL filter.
* @see class-open-router-response-parser.php — Response parsing + error classification.
* @see class-open-router-completion-guard.php — Empty-completion guard + reasoning-off retry (v0.18.1).
* @see class-cost-tracker.php — Called after every request to log token usage.
* @see ARCHITECTURE.md — Data flow diagram showing where this fits.
*/
@@ -40,8 +41,15 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
* temperature?: float,
* max_tokens?: int,
* response_format?: array{type: string},
* reasoning?: array{enabled: bool, effort?: string},
* } $options Optional parameters. Pass 'reasoning' to override the global setting.
* reasoning?: array{enabled: bool, effort?: string, max_tokens?: int},
* stage?: string,
* prompt_key?: string,
* empty_retry?: bool,
* } $options Optional parameters. Pass 'reasoning' to override the global
* setting. 'stage'/'prompt_key' are caller metadata for the
* empty-completion guard's logging + audit rows (never sent
* upstream); 'empty_retry' is set internally by the guard's
* single reasoning-disabled retry.
*
* @return array{
* content: string,
@@ -52,46 +60,46 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
* finish_reason: string,
* }
*
* @throws \RuntimeException On API error after retries exhausted.
* @throws \RuntimeException On API error after retries exhausted, or when the
* completion's visible content is empty/whitespace
* (v0.18.1 guard — after at most one reasoning-disabled retry).
* @throws PRAutoBlogger_Cost_Ceiling_Exception When the per-run cost governor blocks
* the call (run already halted).
*/
public function send_chat_completion( array $messages, string $model, array $options = array() ): array {
$builder = new PRAutoBlogger_OpenRouter_Request_Builder();
// Fetch + format-validate the key (moved to Request_Builder in
// v0.18.0 for the 300-line cap; behavior unchanged — throws the
// same messages on missing/corrupted keys).
$api_key = ( new PRAutoBlogger_OpenRouter_Request_Builder() )->resolve_api_key();
$api_key = $builder->resolve_api_key();
$body = array(
'model' => $model,
'messages' => $messages,
);
// Body assembly — incl. global/per-call reasoning and the v0.18.1
// reasoning token cap + completion headroom — lives in the builder.
$body = $builder->build_body( $messages, $model, $options );
if ( isset( $options['temperature'] ) ) {
$body['temperature'] = $options['temperature'];
}
if ( isset( $options['max_tokens'] ) ) {
$body['max_tokens'] = $options['max_tokens'];
}
if ( isset( $options['response_format'] ) ) {
$body['response_format'] = $options['response_format'];
}
// v0.20.0 (B1): stash the outgoing request body so the next
// log_api_call() persists it to generation_log.request_json.
// Only the BODY is recorded -- Authorization lives exclusively in
// build_headers()' return and can never reach the recorder.
// Recorded BEFORE dispatch so error/timeout rows carry it too.
PRAutoBlogger_Request_Recorder::record( $body );
// Reasoning mode: explicit caller override takes priority, then global setting.
if ( isset( $options['reasoning'] ) ) {
$body['reasoning'] = $options['reasoning'];
} elseif ( '1' === get_option( 'prautoblogger_reasoning_enabled', '0' ) ) {
$body['reasoning'] = array(
'enabled' => true,
'effort' => get_option( 'prautoblogger_reasoning_effort', 'medium' ),
);
}
// Whether the outgoing request has reasoning enabled — drives the
// empty-completion retry decision after parse.
$reasoning_active = isset( $body['reasoning'] ) && false !== ( $body['reasoning']['enabled'] ?? true );
// v0.18.0 — per-run cost governor: reserve the worst-case estimate
// BEFORE any dispatch. Throws (and the run is already halted) when
// the reservation would breach the run ceiling; returns null when
// this process is not inside a governed run (historical behavior).
$reservation = PRAutoBlogger_Cost_Governor::open_chat_reservation( $model, $messages, $options );
// v0.18.1: the estimate uses the EFFECTIVE completion ceiling from
// the built body (incl. reasoning headroom), not the raw option.
$govern_options = $options;
if ( isset( $body['max_tokens'] ) ) {
$govern_options['max_tokens'] = $body['max_tokens'];
}
$reservation = PRAutoBlogger_Cost_Governor::open_chat_reservation( $model, $messages, $govern_options );
$last_error = '';
@@ -101,7 +109,6 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
$cache_ttl = $config->get_cache_ttl_seconds();
$via_gateway = $config->is_via_gateway();
$builder = new PRAutoBlogger_OpenRouter_Request_Builder();
$request_headers = $builder->build_headers( $api_key, $via_gateway, $cache_ttl );
$curl_auth_filter = $builder->register_curl_auth_filter( $request_headers, $base_host );
@@ -190,7 +197,21 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
$this->estimate_cost( $model, (int) $parsed['prompt_tokens'], (int) $parsed['completion_tokens'] )
);
$reservation = null;
return $parsed;
// v0.18.1 — empty-completion guard: empty visible content
// is a FAILURE, never a success. Warns on finish_reason=
// length, retries ONCE with reasoning disabled when that
// can plausibly help, books failed attempts with their
// real status, and otherwise throws. The recursive retry
// manages its own reservation and cURL filter.
return ( new PRAutoBlogger_OpenRouter_Completion_Guard() )->finalize(
$parsed,
$options,
$reasoning_active,
function ( array $retry_options ) use ( $messages, $model ): array {
return $this->send_chat_completion( $messages, $model, $retry_options );
}
);
}
// All retries exhausted.

View File

@@ -18,6 +18,100 @@ declare(strict_types=1);
*/
class PRAutoBlogger_OpenRouter_Request_Builder {
/**
* Build the JSON request body for a chat-completion call.
*
* Centralizes option handling (v0.18.1, moved from the provider for
* the 300-line cap): temperature, max_tokens, response_format, and
* the reasoning block — an explicit caller override takes priority
* over the global setting (that per-call override is how the
* empty-completion retry disables reasoning for its second attempt).
*
* Caller-metadata keys ('stage', 'prompt_key', 'empty_retry') are
* deliberately NOT copied into the HTTP body.
*
* @param array<int, array{role: string, content: string}> $messages Chat messages.
* @param string $model Model identifier.
* @param array<string, mixed> $options Call options.
*
* @return array<string, mixed> Request body ready for wp_json_encode().
*/
public function build_body( array $messages, string $model, array $options ): array {
$body = array(
'model' => $model,
'messages' => $messages,
);
if ( isset( $options['temperature'] ) ) {
$body['temperature'] = $options['temperature'];
}
if ( isset( $options['max_tokens'] ) ) {
$body['max_tokens'] = $options['max_tokens'];
}
if ( isset( $options['response_format'] ) ) {
$body['response_format'] = $options['response_format'];
}
// Reasoning mode: explicit caller override takes priority, then global setting.
if ( isset( $options['reasoning'] ) ) {
$body['reasoning'] = $options['reasoning'];
} elseif ( '1' === get_option( 'prautoblogger_reasoning_enabled', '0' ) ) {
$body['reasoning'] = array(
'enabled' => true,
'effort' => get_option( 'prautoblogger_reasoning_effort', 'medium' ),
);
}
return $this->apply_reasoning_budget( $body );
}
/**
* Reasoning budget sanity (v0.18.1): cap thinking, protect content.
*
* When the outgoing request enables reasoning and carries a completion
* ceiling, the thinking budget is capped via OpenRouter's
* `reasoning.max_tokens` and the completion ceiling is raised by the
* same amount — so no reasoning effort (incl. xhigh) can consume the
* entire completion budget and emit an empty message (the 2026-06-11
* empty-draft incident, prod run acf24029). The cap is SETTINGS-backed
* (`prautoblogger_reasoning_max_tokens`, default
* PRAUTOBLOGGER_DEFAULT_REASONING_MAX_TOKENS, 0 = pure effort mode:
* no cap, no headroom — the historical behavior). When the cap is
* active it replaces `effort` — OpenRouter treats the two as
* alternative budget controls and normalizes max_tokens per model.
*
* No-op when reasoning is absent/disabled on the request, when the
* request has no completion ceiling, or when the cap setting is 0.
*
* @param array<string, mixed> $body Assembled request body.
*
* @return array<string, mixed> Body with the reasoning budget applied.
*/
private function apply_reasoning_budget( array $body ): array {
if ( ! isset( $body['reasoning'] ) || false === ( $body['reasoning']['enabled'] ?? true ) ) {
return $body;
}
if ( ! isset( $body['max_tokens'] ) ) {
return $body; // No completion ceiling to protect.
}
if ( ! isset( $body['reasoning']['max_tokens'] ) ) {
$cap = absint( get_option( 'prautoblogger_reasoning_max_tokens', PRAUTOBLOGGER_DEFAULT_REASONING_MAX_TOKENS ) );
if ( $cap < 1 ) {
return $body; // Cap disabled — pure effort mode.
}
$body['reasoning']['max_tokens'] = $cap;
unset( $body['reasoning']['effort'] );
}
// Headroom: models that spend reasoning from the completion budget
// (e.g. deepseek/deepseek-v4-flash) must still have the caller's
// full max_tokens available for visible content.
$body['max_tokens'] = (int) $body['max_tokens'] + (int) $body['reasoning']['max_tokens'];
return $body;
}
/**
* Build request headers for OpenRouter API call.
*

View File

@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Runware modelSearch API fetcher — handles pagination and response normalization
* for the Runware model catalog sync.
*
* Extracted from PRAutoBlogger_Runware_Model_Catalog for the 300-line rule.
* Provides two public methods consumed by the catalog class:
* - fetch_all_text_to_image(): paginates modelSearch and returns raw results.
* - normalize_models(): converts raw results to Image_Model_Registry shape.
*
* API note (2026-06-12): Runware retired taskType:models. The replacement is
* taskType:modelSearch — requires auth+modelSearch tasks, returns paginated
* results with `air` identifiers and `capabilities[]`. Text-to-image filtering
* is client-side (no server-side capability filter is available). The catalog
* has 320k+ entries (mostly community checkpoints); we cap at PAGE_CAP pages
* of PAGE_SIZE results to bound request cost while covering curated t2i models.
*
* @see class-runware-model-catalog.php — Caller; owns caching, cooldown, and fallback.
* @see class-image-model-registry.php — Consumes the normalized model shape.
*/
class PRAutoBlogger_Runware_Catalog_Fetcher {
private const RUNWARE_API_URL = 'https://api.runware.ai/v1';
private const HTTP_TIMEOUT_SEC = 30;
/**
* Maximum pages to fetch per sync cycle.
* 5 pages × 100 results = 500 models inspected.
* Curated Runware-native text-to-image models number ~50 in the first 500.
*/
private const PAGE_CAP = 5;
private const PAGE_SIZE = 100;
/**
* Fetch all text-to-image model results via paginated modelSearch calls.
*
* Issues up to PAGE_CAP requests against the Runware /v1 endpoint, each
* sending an auth task + a modelSearch task with offset/limit. Stops early
* when all available results have been fetched. Returns the raw result
* objects (unfiltered — normalize_models() applies the capability filter).
*
* @param string $api_key Decrypted Runware API key.
* @return array<int, array<string, mixed>> Raw modelSearch result objects.
* @throws \RuntimeException On HTTP error or malformed response for any page.
*/
public function fetch_all_text_to_image( string $api_key ): array {
$all_results = array();
$offset = 0;
$pages_fetched = 0;
$total = PHP_INT_MAX; // Set after first page.
do {
$page = $this->fetch_page( $api_key, $offset, self::PAGE_SIZE );
if ( empty( $page['results'] ) ) {
break;
}
$all_results = array_merge( $all_results, $page['results'] );
$total = (int) $page['totalResults'];
$offset += self::PAGE_SIZE;
++$pages_fetched;
if ( count( $all_results ) >= $total ) {
break;
}
} while ( $pages_fetched < self::PAGE_CAP );
if ( $pages_fetched >= self::PAGE_CAP && count( $all_results ) < $total ) {
PRAutoBlogger_Logger::instance()->info(
sprintf(
'Runware catalog: reached page cap (%d pages, %d models fetched of %d total). '
. 'Increase PAGE_CAP if curated models are being missed.',
self::PAGE_CAP,
count( $all_results ),
$total
),
'runware-catalog'
);
}
return $all_results;
}
/**
* Normalize raw modelSearch result objects to the Image_Model_Registry shape.
*
* Filters to models with "io:text-to-image" in capabilities[].
* Uses the `air` field as the model id (e.g. "runware:100@1").
* Uses the `comment` field as the human-readable description.
* cost_per_image is always null here — merged by the catalog class.
*
* @param array<int, array<string, mixed>> $raw_models Raw modelSearch results.
* @return array<int, array<string, mixed>> Normalized models.
*/
public function normalize_models( array $raw_models ): array {
$normalized = array();
foreach ( $raw_models as $raw ) {
if ( ! is_array( $raw ) ) {
continue;
}
$capabilities = is_array( $raw['capabilities'] ?? null ) ? $raw['capabilities'] : array();
if ( ! in_array( 'io:text-to-image', $capabilities, true ) ) {
continue;
}
$air = (string) ( $raw['air'] ?? '' );
if ( '' === $air ) {
continue;
}
$normalized[] = array(
'id' => $air,
'name' => (string) ( $raw['name'] ?? $air ),
'provider' => 'runware',
'cost_per_image' => null,
'capabilities' => array( 'image_generation' ),
'description' => (string) ( $raw['comment'] ?? '' ),
);
}
return $normalized;
}
/**
* Fetch a single page of modelSearch results.
*
* Sends [authentication, modelSearch] task pair to /v1.
* A fresh auth task is required per request per Runware's WebSocket-based
* API contract (each HTTP POST is a stateless task array).
*
* @param string $api_key Decrypted Runware API key.
* @param int $offset Pagination offset (0-based).
* @param int $limit Page size.
* @return array{results: array<int, array<string, mixed>>, totalResults: int}
* @throws \RuntimeException On HTTP error or malformed response (non-200 exceptions
* include up to 200 chars of the response body for diagnosis).
*/
private function fetch_page( string $api_key, int $offset, int $limit ): array {
$payload = array(
array(
'taskType' => 'authentication',
'apiKey' => $api_key,
),
array(
'taskType' => 'modelSearch',
'taskUUID' => wp_generate_uuid4(),
'offset' => $offset,
'limit' => $limit,
),
);
$response = wp_remote_post(
self::RUNWARE_API_URL,
array(
'timeout' => self::HTTP_TIMEOUT_SEC,
'headers' => array( 'Content-Type' => 'application/json' ),
'body' => wp_json_encode( $payload ),
)
);
if ( is_wp_error( $response ) ) {
throw new \RuntimeException(
sprintf( 'Runware modelSearch unreachable: %s', $response->get_error_message() )
);
}
$status = (int) wp_remote_retrieve_response_code( $response );
if ( 200 !== $status ) {
$raw_body = (string) wp_remote_retrieve_body( $response );
$body_excerpt = '' !== $raw_body ? ' — body: ' . substr( $raw_body, 0, 200 ) : '';
throw new \RuntimeException(
sprintf( 'Runware modelSearch returned HTTP %d%s', $status, $body_excerpt )
);
}
$raw = (string) wp_remote_retrieve_body( $response );
$decoded = json_decode( $raw, true );
if ( ! is_array( $decoded ) ) {
throw new \RuntimeException( 'Runware modelSearch response is not valid JSON.' );
}
if ( isset( $decoded['errors'] ) && is_array( $decoded['errors'] ) && ! empty( $decoded['errors'] ) ) {
$first_msg = '';
if ( ! empty( $decoded['errors'][0]['message'] ) ) {
$first_msg = ': ' . (string) $decoded['errors'][0]['message'];
}
throw new \RuntimeException( 'Runware modelSearch API error' . $first_msg );
}
$data = $decoded['data'] ?? array();
if ( ! is_array( $data ) || empty( $data ) ) {
return array(
'results' => array(),
'totalResults' => 0,
);
}
$item = $data[0];
$results = is_array( $item['results'] ?? null ) ? $item['results'] : array();
$total = isset( $item['totalResults'] ) ? (int) $item['totalResults'] : 0;
return array(
'results' => $results,
'totalResults' => $total,
);
}
}

View File

@@ -2,19 +2,7 @@
// phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
declare(strict_types=1);
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_init
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_setopt
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_close
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_errno
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_error
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_getinfo
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_init
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_add_handle
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_exec
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_select
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_getcontent
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_remove_handle
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_close
// phpcs:disable WordPress.WP.AlternativeFunctions -- curl_multi required for parallel image generation; no WP HTTP equivalent.
/**
* Parallel image generation via curl_multi for Runware.

View File

@@ -178,72 +178,15 @@ class PRAutoBlogger_Runware_Image_Provider implements PRAutoBlogger_Image_Provid
/**
* {@inheritDoc}
*
* Runware has no dedicated "validate key" endpoint, so we send a minimal
* authentication task and look for `taskType=authentication` in the
* response data with no accompanying errors.
* Delegates to PRAutoBlogger_Runware_Validator (extracted for 300-line compliance).
*
* @see providers/class-runware-image-validator.php
*/
public function validate_credentials_detailed(): array {
try {
$api_key = $this->support->get_api_key();
} catch ( \RuntimeException $e ) {
return array(
'status' => 'error',
'message' => $e->getMessage(),
);
}
$body = array(
array(
'taskType' => 'authentication',
'apiKey' => $api_key,
),
);
$response = wp_remote_post(
PRAutoBlogger_Runware_Image_Support::API_URL,
array(
'timeout' => 15,
'headers' => array( 'Content-Type' => 'application/json' ),
'body' => wp_json_encode( $body ),
)
);
if ( is_wp_error( $response ) ) {
return array(
'status' => 'error',
'message' => __( 'Could not reach Runware API.', 'prautoblogger' ),
'debug' => $response->get_error_message(),
);
}
$status = (int) wp_remote_retrieve_response_code( $response );
$raw = (string) wp_remote_retrieve_body( $response );
if ( 200 !== $status ) {
return array(
'status' => 'error',
'message' => sprintf( __( 'Runware returned HTTP %d.', 'prautoblogger' ), $status ),
'debug' => substr( $raw, 0, 200 ),
);
}
$decoded = json_decode( $raw, true );
if ( is_array( $decoded ) && isset( $decoded['errors'] ) && ! empty( $decoded['errors'] ) ) {
$first = $decoded['errors'][0] ?? array();
$msg = is_array( $first ) ? (string) ( $first['message'] ?? '' ) : (string) $first;
return array(
'status' => 'error',
'message' => __( 'Runware rejected the API key.', 'prautoblogger' ),
'debug' => substr( $msg, 0, 200 ),
);
}
return array(
'status' => 'ok',
'message' => __( 'Runware API key valid.', 'prautoblogger' ),
);
return PRAutoBlogger_Runware_Validator::validate( $this->support );
}
/**
* Build the JSON task-array body for a single imageInference request.
*

View File

@@ -181,7 +181,8 @@ class PRAutoBlogger_Runware_Image_Support {
*/
public function normalize_seed( ?int $seed ): int {
if ( null === $seed || $seed < 1 ) {
$derived = (int) ( ( microtime( true ) * 1000 ) % 2147483647 );
// Cast to int before modulo — float % int is deprecated in PHP 8 (v0.18.2).
$derived = (int) round( microtime( true ) * 1000 ) % 2147483647;
return $derived > 0 ? $derived : 1;
}
return $seed;

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Credential validator for the Runware image provider.
*
* Extracted from PRAutoBlogger_Runware_Image_Provider for 300-line compliance.
* Sends a minimal authentication task to Runware's API to confirm the key is valid.
*
* What: Static utility; no state.
* Who triggers: PRAutoBlogger_Runware_Image_Provider::validate_credentials_detailed().
* Dependencies: PRAutoBlogger_Runware_Image_Support (API_URL constant, get_api_key()).
*
* @see providers/class-runware-image-provider.php -- Sole caller.
* @see providers/class-runware-image-support.php -- API key retrieval + URL.
*/
class PRAutoBlogger_Runware_Validator {
/**
* {@inheritDoc}
*
* Runware has no dedicated "validate key" endpoint, so we send a minimal
* authentication task and look for `taskType=authentication` in the
* response data with no accompanying errors.
*/
public static function validate( PRAutoBlogger_Runware_Image_Support $support ): array {
try {
$api_key = $support->get_api_key();
} catch ( \RuntimeException $e ) {
return array(
'status' => 'error',
'message' => $e->getMessage(),
);
}
$body = array(
array(
'taskType' => 'authentication',
'apiKey' => $api_key,
),
);
$response = wp_remote_post(
PRAutoBlogger_Runware_Image_Support::API_URL,
array(
'timeout' => 15,
'headers' => array( 'Content-Type' => 'application/json' ),
'body' => wp_json_encode( $body ),
)
);
if ( is_wp_error( $response ) ) {
return array(
'status' => 'error',
'message' => __( 'Could not reach Runware API.', 'prautoblogger' ),
'debug' => $response->get_error_message(),
);
}
$status = (int) wp_remote_retrieve_response_code( $response );
$raw = (string) wp_remote_retrieve_body( $response );
if ( 200 !== $status ) {
return array(
'status' => 'error',
'message' => sprintf( __( 'Runware returned HTTP %d.', 'prautoblogger' ), $status ),
'debug' => substr( $raw, 0, 200 ),
);
}
$decoded = json_decode( $raw, true );
if ( is_array( $decoded ) && isset( $decoded['errors'] ) && ! empty( $decoded['errors'] ) ) {
$first = $decoded['errors'][0] ?? array();
$msg = is_array( $first ) ? (string) ( $first['message'] ?? '' ) : (string) $first;
return array(
'status' => 'error',
'message' => __( 'Runware rejected the API key.', 'prautoblogger' ),
'debug' => substr( $msg, 0, 200 ),
);
}
return array(
'status' => 'ok',
'message' => __( 'Runware API key valid.', 'prautoblogger' ),
);
}
}

View File

@@ -4,18 +4,30 @@ declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Runware model catalog sync — fetches live model list via Runware's /v1 endpoint,
* filters to text-to-image tasks, caches normalized results, and falls back to
* hardcoded list on API failure.
* Runware model catalog sync — orchestrates periodic fetching, caching, failure
* cooldown, and fallback for the Runware model list.
*
* What: Syncs Runware's model catalog periodically (daily cron + on-demand AJAX),
* caches the results in WP options, and merges pricing data from the pricing
* class when Runware's API doesn't expose it.
* What: Syncs Runware's model catalog (daily cron + on-demand AJAX), caches
* normalized results in WP options (v2 schema key — see below), merges
* pricing, and enforces a per-failure cooldown to stop admin-page-load
* cascades when the API is broken or the key is misconfigured.
* Who triggers it: Daily cron (prautoblogger_sync_runware_models), AJAX "Sync now"
* button in PRAdmin, and lazy-load via PRAutoBlogger_Image_Model_Registry.
* Dependencies: PRAutoBlogger_Logger, PRAutoBlogger_Runware_Image_Pricing,
* PRAutoBlogger_Image_Model_Registry.
* Dependencies: PRAutoBlogger_Runware_Catalog_Fetcher, PRAutoBlogger_Logger,
* PRAutoBlogger_Runware_Image_Pricing, PRAutoBlogger_Image_Model_Registry.
*
* Cache schema note: option key is prautoblogger_runware_model_cache_v2 (bumped
* from v1) to cleanly invalidate cached rows that used the old taskType:models
* response shape (taskType/requiresImage fields). The v1 option is left in place
* and will be purged by the next WP options cleanup pass.
*
* Failure cooldown: prautoblogger_runware_catalog_last_failure_at records the
* last failure timestamp. is_in_failure_cooldown() checks it against
* prautoblogger_runware_catalog_failure_cooldown_seconds (default 3600s). This
* caps errors from admin page loads to ~24/day (24h / 1h cooldown) rather than
* 40+/day from every Settings/picker page load triggering a live sync.
*
* @see class-runware-catalog-fetcher.php — Pagination + normalize (extracted for 300-line rule).
* @see class-runware-image-pricing.php — Authoritative pricing source & fallback models.
* @see admin/class-image-model-registry.php — Caller; merges Runware + OpenRouter lists.
* @see class-prautoblogger.php — Registers daily cron + AJAX hook.
@@ -23,20 +35,27 @@ declare(strict_types=1);
*/
class PRAutoBlogger_Runware_Model_Catalog {
private const RUNWARE_API_URL = 'https://api.runware.ai/v1';
private const CACHE_TTL_SECONDS = 86400;
private const HTTP_TIMEOUT_SECONDS = 30;
/**
* Fetch the Runware model catalog, normalize to the Image_Model_Registry
* shape, and write to WP options cache. Returns true on success, false on
* API failure or parse error.
* Option key for the v2 normalized model cache (air-identifier schema).
* Bumped from v1 (prautoblogger_runware_model_cache) to force re-sync.
*/
private const CACHE_OPTION = 'prautoblogger_runware_model_cache_v2';
private const CACHE_TS_OPTION = 'prautoblogger_runware_model_cache_updated_at';
private const FAILURE_TS_OPTION = 'prautoblogger_runware_catalog_last_failure_at';
private const COOLDOWN_SETTING = 'prautoblogger_runware_catalog_failure_cooldown_seconds';
private const COOLDOWN_DEFAULT = 3600;
/**
* Fetch, normalize, merge pricing, and cache the Runware model catalog.
* On success: writes cache + clears failure timestamp. Returns true.
* On failure: writes failure timestamp for cooldown. Returns false.
*
* Side effects: makes HTTP request to Runware API, writes WP options.
* Error handling: logs via PRAutoBlogger_Logger, never throws, always
* returns bool to allow fallback to cached list.
* returns bool to allow fallback to cached/hardcoded list.
*
* @return bool True on successful fetch and cache update; false on API or parse error.
* @return bool True on successful fetch and cache update; false otherwise.
*/
public function sync(): bool {
try {
@@ -49,16 +68,19 @@ class PRAutoBlogger_Runware_Model_Catalog {
return false;
}
$raw_models = $this->fetch_models_from_api( $api_key );
$fetcher = new PRAutoBlogger_Runware_Catalog_Fetcher();
$raw_models = $fetcher->fetch_all_text_to_image( $api_key );
if ( empty( $raw_models ) ) {
PRAutoBlogger_Logger::instance()->warning(
'Runware model catalog fetch returned empty list. Using cached/fallback models.',
'runware-catalog'
);
$this->record_failure();
return false;
}
$normalized = $this->normalize_models( $raw_models );
$normalized = $fetcher->normalize_models( $raw_models );
$with_pricing = $this->merge_pricing( $normalized );
if ( empty( $with_pricing ) ) {
@@ -66,11 +88,13 @@ class PRAutoBlogger_Runware_Model_Catalog {
'Runware model catalog normalization resulted in empty list. Using cached/fallback models.',
'runware-catalog'
);
$this->record_failure();
return false;
}
update_option( 'prautoblogger_runware_model_cache', $with_pricing, false );
update_option( 'prautoblogger_runware_model_cache_updated_at', time(), false );
update_option( self::CACHE_OPTION, $with_pricing, false );
update_option( self::CACHE_TS_OPTION, time(), false );
delete_option( self::FAILURE_TS_OPTION );
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Runware model catalog synced: %d models cached.', count( $with_pricing ) ),
@@ -82,38 +106,37 @@ class PRAutoBlogger_Runware_Model_Catalog {
sprintf( 'Runware model catalog sync failed: %s (%s)', $e->getMessage(), get_class( $e ) ),
'runware-catalog'
);
$this->record_failure();
return false;
}
}
/**
* Get the normalized model list, with smart caching logic:
* - If cache is fresh (< 24h), return it.
* - If cache is stale or absent, trigger a sync.
* - If sync fails, return the last-known-good cache.
* - If no cache exists, return the hardcoded fallback (never empty).
* Return the normalized model list, using smart cache + fallback logic:
* fresh cache → cached; stale + not in cooldown → sync; stale cache fallback;
* no cache → hardcoded fallback (never empty).
*
* @return array<int, array<string, mixed>> Normalized model list.
*/
public function get_models(): array {
if ( ! $this->is_stale() ) {
$cached = get_option( 'prautoblogger_runware_model_cache', array() );
$cached = get_option( self::CACHE_OPTION, array() );
if ( is_array( $cached ) && ! empty( $cached ) ) {
return $cached;
}
}
if ( $this->sync() ) {
$cached = get_option( 'prautoblogger_runware_model_cache', array() );
if ( ! $this->is_in_failure_cooldown() && $this->sync() ) {
$cached = get_option( self::CACHE_OPTION, array() );
if ( is_array( $cached ) && ! empty( $cached ) ) {
return $cached;
}
}
$cached = get_option( 'prautoblogger_runware_model_cache', array() );
$cached = get_option( self::CACHE_OPTION, array() );
if ( is_array( $cached ) && ! empty( $cached ) ) {
PRAutoBlogger_Logger::instance()->info(
'Using stale Runware model cache (live sync failed).',
'Using stale Runware model cache (live sync failed or suppressed by cooldown).',
'runware-catalog'
);
return $cached;
@@ -131,12 +154,12 @@ class PRAutoBlogger_Runware_Model_Catalog {
}
/**
* Get the Unix timestamp of the last successful sync, or null if never synced.
* Unix timestamp of the last successful sync, or null if never synced.
*
* @return int|null Unix timestamp, or null if never synced.
* @return int|null
*/
public function get_last_synced_at(): ?int {
$ts = get_option( 'prautoblogger_runware_model_cache_updated_at', null );
$ts = get_option( self::CACHE_TS_OPTION, null );
if ( null === $ts ) {
return null;
}
@@ -145,9 +168,23 @@ class PRAutoBlogger_Runware_Model_Catalog {
}
/**
* Check if the cache is stale (older than 24h) or absent.
* Unix timestamp of the last sync failure, or null if no failure on record.
*
* @return bool True if cache should be refreshed; false if still fresh.
* @return int|null
*/
public function get_last_failure_at(): ?int {
$ts = get_option( self::FAILURE_TS_OPTION, null );
if ( null === $ts ) {
return null;
}
$ts = (int) $ts;
return $ts > 0 ? $ts : null;
}
/**
* Whether the cache is stale (older than 24h) or absent.
*
* @return bool True if a refresh is warranted; false if still fresh.
*/
public function is_stale(): bool {
$updated_at = $this->get_last_synced_at();
@@ -158,125 +195,53 @@ class PRAutoBlogger_Runware_Model_Catalog {
}
/**
* Fetch the raw model list from Runware's /v1 endpoint via a models task.
* Whether a sync attempt should be suppressed due to a recent failure.
*
* @param string $api_key Decrypted Runware API key.
* @return array Raw model list (may be empty).
* @throws \RuntimeException On HTTP error or malformed response.
* The cooldown window is read from prautoblogger_runware_catalog_failure_cooldown_seconds
* (default 3600s). This prevents admin-page-load cascades (40+ errors/day)
* when the API endpoint or key is broken. A successful sync clears it.
*
* @return bool True if a sync should be suppressed; false if retry is permitted.
*/
private function fetch_models_from_api( string $api_key ): array {
$body = wp_json_encode(
array(
array(
'taskType' => 'models',
'apiKey' => $api_key,
),
)
);
$response = wp_remote_post(
self::RUNWARE_API_URL,
array(
'timeout' => self::HTTP_TIMEOUT_SECONDS,
'headers' => array( 'Content-Type' => 'application/json' ),
'body' => $body,
)
);
if ( is_wp_error( $response ) ) {
throw new \RuntimeException(
sprintf( 'Runware models endpoint unreachable: %s', $response->get_error_message() )
);
public function is_in_failure_cooldown(): bool {
$last_failure = $this->get_last_failure_at();
if ( null === $last_failure ) {
return false;
}
$status = (int) wp_remote_retrieve_response_code( $response );
if ( 200 !== $status ) {
$raw = wp_remote_retrieve_body( $response );
throw new \RuntimeException(
sprintf( 'Runware models endpoint returned HTTP %d', $status )
);
$cooldown = (int) get_option( self::COOLDOWN_SETTING, self::COOLDOWN_DEFAULT );
if ( $cooldown <= 0 ) {
$cooldown = self::COOLDOWN_DEFAULT;
}
$raw = (string) wp_remote_retrieve_body( $response );
$decoded = json_decode( $raw, true );
if ( ! is_array( $decoded ) ) {
throw new \RuntimeException( 'Runware models response is not valid JSON.' );
}
if ( isset( $decoded['errors'] ) && is_array( $decoded['errors'] ) && ! empty( $decoded['errors'] ) ) {
throw new \RuntimeException( 'Runware API returned an error.' );
}
$data = $decoded['data'] ?? array();
return is_array( $data ) ? $data : array();
return ( time() - $last_failure ) < $cooldown;
}
/**
* Normalize raw Runware model objects to the PRAutoBlogger_Image_Model_Registry
* shape: id, name, provider, cost_per_image, capabilities, description.
*
* Filters to taskType='imageInference' (text-to-image only), excluding
* inpainting (imageInference + requiresImage=true), img2img, video, upscalers.
*
* @param array<int, array<string, mixed>> $raw_models Raw API response.
* @return array<int, array<string, mixed>> Normalized models.
*/
private function normalize_models( array $raw_models ): array {
$normalized = array();
foreach ( $raw_models as $raw ) {
if ( ! is_array( $raw ) ) {
continue;
}
$task_type = $raw['taskType'] ?? '';
if ( 'imageInference' !== $task_type ) {
continue;
}
if ( isset( $raw['requiresImage'] ) && true === $raw['requiresImage'] ) {
continue;
}
$model_id = (string) ( $raw['id'] ?? '' );
if ( '' === $model_id ) {
continue;
}
$normalized[] = array(
'id' => $model_id,
'name' => (string) ( $raw['name'] ?? $model_id ),
'provider' => 'runware',
'cost_per_image' => null,
'capabilities' => array( 'image_generation' ),
'description' => (string) ( $raw['description'] ?? '' ),
);
}
return $normalized;
}
/**
* Merge pricing data from PRAutoBlogger_Runware_Image_Pricing into the
* normalized catalog. Models without pricing data get cost_per_image=null.
* Merge pricing from PRAutoBlogger_Runware_Image_Pricing into normalized models.
* Models without a pricing entry get cost_per_image=null.
*
* @param array<int, array<string, mixed>> $normalized Normalized models.
* @return array<int, array<string, mixed>> Models with pricing merged.
*/
private function merge_pricing( array $normalized ): array {
$pricing_table = PRAutoBlogger_Runware_Image_Pricing::get_model_costs();
foreach ( $normalized as &$model ) {
$model_id = $model['id'] ?? '';
if ( isset( $pricing_table[ $model_id ] ) ) {
$model['cost_per_image'] = (float) $pricing_table[ $model_id ];
}
}
return $normalized;
}
/**
* Record the current time as the last sync failure timestamp.
*
* @return void
*/
private function record_failure(): void {
update_option( self::FAILURE_TS_OPTION, time(), false );
}
/**
* Get the decrypted Runware API key from settings.
*
@@ -287,12 +252,10 @@ class PRAutoBlogger_Runware_Model_Catalog {
if ( '' === $stored ) {
return '';
}
if ( PRAutoBlogger_Encryption::is_encrypted( $stored ) ) {
$decrypted = PRAutoBlogger_Encryption::decrypt( $stored );
return '' === $decrypted ? '' : $decrypted;
}
return $stored;
}
}

View File

@@ -82,6 +82,7 @@ class PRAutoBlogger_Opik_Eval_Scorer {
array(
'temperature' => 0.1,
'max_tokens' => 200,
'stage' => 'opik_eval_judge',
)
);

View File

@@ -9,7 +9,7 @@
* Plugin Name: PRAutoBlogger
* Plugin URI: https://peptiderepo.com/prautoblogger
* Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics.
* Version: 0.18.0
* Version: 0.22.1
* Requires at least: 6.0
* Requires PHP: 7.4
* Author: PeptideRepo
@@ -35,20 +35,26 @@ if ( ! defined( 'ABSPATH' ) ) {
| Defined here so every file in the plugin can reference paths, versions,
| and limits without magic strings.
*/
define( 'PRAUTOBLOGGER_VERSION', '0.18.0' );
define( 'PRAUTOBLOGGER_DB_VERSION', '1.2.0' );
define( 'PRAUTOBLOGGER_VERSION', '0.22.1' );
define( 'PRAUTOBLOGGER_DB_VERSION', '1.4.0' );
define( 'PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD', 0.50 );
define( 'PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS', 14 );
// Default max cards shown per board column and per ideas-browser page.
// Settings-backed (prautoblogger_board_column_limit, prautoblogger_ideas_per_page).
define( 'PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT', 20 );
define( 'PRAUTOBLOGGER_DEFAULT_IDEAS_PER_PAGE', 30 );
// v0.18.1: ceiling on "thinking" tokens per reasoning-enabled LLM call. The
// request's completion budget (max_tokens) is RAISED by this amount so
// reasoning can never consume the visible-content budget and return an empty
// message (the 2026-06-11 empty-draft incident). SETTINGS-backed
// (prautoblogger_reasoning_max_tokens); 0 disables the cap (pure effort mode).
define( 'PRAUTOBLOGGER_DEFAULT_REASONING_MAX_TOKENS', 2048 );
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'PRAUTOBLOGGER_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
define( 'PRAUTOBLOGGER_PLUGIN_BASENAME', plugin_basename( __FILE__ ) );
// API and retry limits — named constants instead of magic numbers.
define( 'PRAUTOBLOGGER_MAX_RETRIES', 3 );
// Pipeline v2 Phase 1 (v0.18.0) defaults. Both are SETTINGS-backed at every
// read site (prautoblogger_per_run_cost_ceiling_usd /
// prautoblogger_request_json_retention_days); these constants exist only as
// the named get_option() fallbacks — never inline the literals.
define( 'PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD', 0.50 );
define( 'PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS', 14 );
define( 'PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS', 2 );
define( 'PRAUTOBLOGGER_API_TIMEOUT_SECONDS', 180 );
define( 'PRAUTOBLOGGER_CACHE_TTL_SECONDS', 3600 );
@@ -113,6 +119,7 @@ PRAutoBlogger_Autoloader::register();
// The main orchestrator class is loaded explicitly because its CamelCase name
// (PRAutoBlogger) doesn't map cleanly to a filename via the autoloader's
// kebab-case convention (it would look for class-pr-auto-blogger.php).
require_once PRAUTOBLOGGER_PLUGIN_DIR . 'includes/class-db-migrations.php'; // Loaded explicitly: DB_Migrations -> class-d-b-migrations.php via autoloader
require_once PRAUTOBLOGGER_PLUGIN_DIR . 'includes/class-prautoblogger.php';
/*
|--------------------------------------------------------------------------

252
scripts/check-constants.php Normal file
View File

@@ -0,0 +1,252 @@
<?php
/**
* CI guard: every PRAUTOBLOGGER_* constant referenced in the shipped plugin PHP
* must be either (a) defined in prautoblogger.php, or (b) protected by a
* defined() guard at the reference site.
*
* Purpose: prevent the class of regression that caused the v0.19.0 admin-500
* incident — a constant deleted from prautoblogger.php while tests/bootstrap.php
* kept a fallback copy, masking the gap until prod boot.
*
* Usage (from repo root):
* php scripts/check-constants.php [--verbose] [--self-test]
*
* Exit 0 = all clear. Exit 1 = unguarded undefined constants found. Exit 2 = --self-test failed.
*
* Include/exclude summary (relative to repo root):
* Defined-set source prautoblogger.php only
* Scanned for refs prautoblogger.php, includes/, templates/, uninstall.php
* Excluded from refs tests/ vendor/ .github/ .git/ node_modules/
* Excluded from defs everything except prautoblogger.php
*
* Guard window: a bare constant use is considered guarded if a defined() call
* for the same constant appears within GUARD_WINDOW lines above it in the same
* file (covers if/elseif blocks and multi-line && chains).
*
* @package PRAutoBlogger
*/
declare(strict_types=1);
// How many lines above the bare use to look for a defined() guard.
const CONSTANTS_GUARD_WINDOW = 4; // 4 covers if/elseif blocks and multi-line && chains (wider risks false-positives).
// Source of truth for define()d constants -- test-bootstrap fallbacks excluded.
const CONSTANTS_BOOTSTRAP_FILE = 'prautoblogger.php';
// Directories skipped when walking the shipped tree for references.
const CONSTANTS_EXCLUDE_DIRS = array( 'tests', 'vendor', '.github', '.git', 'node_modules' );
// Entry points for the reference scan (files and dirs relative to repo root).
const CONSTANTS_SCAN_ROOTS = array(
'prautoblogger.php',
'uninstall.php',
'includes',
'templates',
);
$verbose = in_array( '--verbose', $argv ?? array(), true );
$self_test = in_array( '--self-test', $argv ?? array(), true );
// -------------------------------------------------------------------------
// 1. Build the defined set from prautoblogger.php only.
// -------------------------------------------------------------------------
if ( ! file_exists( CONSTANTS_BOOTSTRAP_FILE ) ) {
fwrite( STDERR, "ERROR: bootstrap file not found -- run from repo root.\n" );
fwrite( STDERR, ' Expected: ' . CONSTANTS_BOOTSTRAP_FILE . "\n" );
exit( 1 );
}
$bootstrap_src = (string) file_get_contents( CONSTANTS_BOOTSTRAP_FILE );
preg_match_all(
"/define\s*\(\s*'(PRAUTOBLOGGER_[A-Z0-9_]+)'/",
$bootstrap_src,
$def_matches
);
/** @var array<string, int> $defined_set constant name => 1 */
$defined_set = array_flip( $def_matches[1] ?? array() );
if ( $verbose ) {
echo 'Defined in ' . CONSTANTS_BOOTSTRAP_FILE . ' (' . count( $defined_set ) . "):\n";
foreach ( array_keys( $defined_set ) as $constant_name ) {
echo " {$constant_name}\n";
}
echo "\n";
}
// -------------------------------------------------------------------------
// 2. Collect shipped PHP files to scan.
// -------------------------------------------------------------------------
/**
* Recursively collect *.php paths under a directory, skipping excluded dirs.
*
* @param string $dir Directory to walk.
* @param string[] $files Accumulator (modified by reference).
* @return void
*/
function constants_collect_php_files( string $dir, array &$files ): void {
$dir_iter = new DirectoryIterator( $dir );
foreach ( $dir_iter as $entry ) {
if ( $entry->isDot() ) {
continue;
}
$basename = $entry->getBasename();
if ( $entry->isDir() ) {
if ( ! in_array( $basename, CONSTANTS_EXCLUDE_DIRS, true ) ) {
constants_collect_php_files( $entry->getPathname(), $files );
}
continue;
}
if ( $entry->isFile() && $entry->getExtension() === 'php' ) {
$files[] = $entry->getPathname();
}
}
}
/** @var string[] $php_files */
$php_files = array();
foreach ( CONSTANTS_SCAN_ROOTS as $root ) {
if ( ! file_exists( $root ) ) {
// Template dir may not exist in very early checkouts -- skip silently.
continue;
}
if ( is_file( $root ) ) {
$php_files[] = $root;
} else {
constants_collect_php_files( $root, $php_files );
}
}
sort( $php_files );
if ( $verbose ) {
echo 'Scanning ' . count( $php_files ) . " PHP files\n\n";
}
// -------------------------------------------------------------------------
// 3. Scan each file for bare PRAUTOBLOGGER_* references.
// -------------------------------------------------------------------------
/** @var array<int, array{string, int, string, string}> $errors */
$errors = array();
foreach ( $php_files as $php_path ) {
$raw = file_get_contents( $php_path );
if ( false === $raw ) {
fwrite( STDERR, "WARNING: cannot read {$php_path} -- skipping\n" );
continue;
}
$lines = explode( "\n", $raw );
foreach ( $lines as $idx => $raw_line ) {
// Fast pre-check: skip lines with no PRAUTOBLOGGER_ token.
if ( strpos( $raw_line, 'PRAUTOBLOGGER_' ) === false ) {
continue;
}
// Skip pure comment lines (docblock * lines, // comments, # lines).
$trimmed = ltrim( $raw_line );
if (
str_starts_with( $trimmed, '*' ) ||
str_starts_with( $trimmed, '//' ) ||
str_starts_with( $trimmed, '#' )
) {
continue;
}
// Extract all constant names from this line.
if ( ! preg_match_all( '/PRAUTOBLOGGER_[A-Z0-9_]+/', $raw_line, $match ) ) {
continue;
}
foreach ( $match[0] as $name ) {
// Skip if this line is itself a define() call for this name.
if ( preg_match( "/define\s*\(\s*'" . preg_quote( $name, '/' ) . "'/", $raw_line ) ) {
continue;
}
// Skip if the constant is production-defined in prautoblogger.php.
if ( isset( $defined_set[ $name ] ) ) {
continue;
}
// Check for a defined() guard in the preceding GUARD_WINDOW lines
// (inclusive of the current line to catch same-line chained &&).
$window_start = max( 0, $idx - CONSTANTS_GUARD_WINDOW );
$guarded = false;
for ( $w = $window_start; $w <= $idx; $w++ ) {
if ( preg_match( "/defined\s*\(\s*'" . preg_quote( $name, '/' ) . "'/", $lines[ $w ] ) ) {
$guarded = true;
break;
}
}
if ( ! $guarded ) {
$errors[] = array( $php_path, $idx + 1, $name, $raw_line );
}
}
}
}
// -------------------------------------------------------------------------
// 4. Report results.
// -------------------------------------------------------------------------
if ( array() !== $errors ) {
fwrite( STDERR, "\nFAIL -- Unguarded undefined PRAUTOBLOGGER_* constants:\n\n" );
foreach ( $errors as [ $php_path, $lineno, $name, $line_text ] ) {
fwrite( STDERR, " {$php_path}:{$lineno}: {$name}\n" );
fwrite( STDERR, ' ' . trim( $line_text ) . "\n" );
}
fwrite( STDERR, "\nTo fix: add define( '" . $errors[0][2] . "', ... ) to prautoblogger.php,\n" );
fwrite( STDERR, "or guard the reference with if ( defined( '" . $errors[0][2] . "' ) ) { ... }\n\n" );
exit( 1 );
}
// -------------------------------------------------------------------------
// 5. Self-test mode: synthesize a missing constant and assert non-zero exit.
// -------------------------------------------------------------------------
if ( $self_test ) {
// Temporarily inject a fake reference that must NOT be in prautoblogger.php.
// The scan above will flag PRAUTOBLOGGER_SELFTEST_SENTINEL as unguarded.
// We verify this script would have exited non-zero in that scenario.
$sentinel = 'PRAUTOBLOGGER_SELFTEST_SENTINEL';
$bootstrap_src = (string) file_get_contents( CONSTANTS_BOOTSTRAP_FILE );
if ( false !== strpos( $bootstrap_src, $sentinel ) ) {
fwrite( STDERR, "SELF-TEST FAIL: sentinel constant already defined in prautoblogger.php -- pick a unique name.\n" );
exit( 2 );
}
// If $errors is empty at this point, the real codebase is clean (good).
// Now simulate an unguarded reference by re-running the scan on a temp file.
$tmp_file = tempnam( sys_get_temp_dir(), 'prab_selftest_' ) . '.php';
file_put_contents( $tmp_file, "<?php\necho {$sentinel};\n" );
$tmp_errors = array();
$tmp_lines = explode( "\n", (string) file_get_contents( $tmp_file ) );
foreach ( $tmp_lines as $idx => $raw_line ) {
if ( preg_match_all( '/PRAUTOBLOGGER_[A-Z0-9_]+/', $raw_line, $m ) ) {
foreach ( $m[0] as $n ) {
if ( ! isset( $defined_set[ $n ] ) ) {
$tmp_errors[] = $n;
}
}
}
}
@unlink( $tmp_file );
if ( ! in_array( $sentinel, $tmp_errors, true ) ) {
fwrite( STDERR, "SELF-TEST FAIL: scanner did not detect unguarded sentinel constant.\n" );
exit( 2 );
}
echo "SELF-TEST OK -- scanner correctly detected the synthetic missing constant.\n";
exit( 0 );
}
echo "OK -- All PRAUTOBLOGGER_* constants are production-defined or defined()-guarded.\n";
exit( 0 );

View File

@@ -0,0 +1,169 @@
<?php
/**
* Kanban board template — primary landing screen for PRAutoBlogger.
*
* Renders the four-column board: Generating | In Review | Published | Failed.
* Cards are populated on first load by a synchronous PHP data call, then kept
* live by the board.js poller (AJAX, settings-backed interval).
*
* @see admin/class-board-page.php — Renders this template.
* @see admin/class-board-data-provider.php — Supplies card data.
* @see assets/js/board.js — Polling + DOM updates.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// Eager load on first render so the board is not blank on arrival.
$provider = new PRAutoBlogger_Board_Data_Provider();
$snapshot = $provider->get_board_snapshot();
/**
* Render a single board card as HTML.
*
* @param array<string, mixed> $card Card data from Board_Data_Provider.
* @param string $column_key Column identifier (generating|in_review|published|failed).
* @return void
*/
function prab_render_board_card( array $card, string $column_key ): void {
$title = ! empty( $card['title'] ) ? esc_html( $card['title'] ) : esc_html__( 'Untitled', 'prautoblogger' );
$cost = isset( $card['cost_total'] ) ? '$' . esc_html( number_format( (float) $card['cost_total'], 4 ) ) : '';
$click_url = '';
$link_text = '';
if ( 'review' === ( $card['click_action'] ?? '' ) ) {
$click_url = esc_url( $card['review_url'] ?? '' );
$link_text = esc_html__( 'Review Queue', 'prautoblogger' );
} elseif ( 'edit' === ( $card['click_action'] ?? '' ) ) {
$click_url = esc_url( $card['edit_url'] ?? '' );
$link_text = esc_html__( 'Edit Post', 'prautoblogger' );
} elseif ( 'logs' === ( $card['click_action'] ?? '' ) ) {
$click_url = esc_url( $card['log_url'] ?? '' );
$link_text = esc_html__( 'View Log', 'prautoblogger' );
}
$is_generating = ( 'generating' === $column_key );
$card_class = 'prab-board-card';
if ( $is_generating ) {
$card_class .= ' prab-board-card--generating';
}
?>
<div class="<?php echo esc_attr( $card_class ); ?>"
data-run-id="<?php echo esc_attr( (string) ( $card['run_id'] ?? '' ) ); ?>"
data-post-id="<?php echo esc_attr( (string) ( $card['post_id'] ?? 0 ) ); ?>">
<?php if ( $is_generating ) : ?>
<div class="prab-card-spinner" aria-label="<?php esc_attr_e( 'Generating in progress', 'prautoblogger' ); ?>"></div>
<?php endif; ?>
<div class="prab-card-title"><?php echo $title; // Already escaped above. ?></div>
<?php if ( ! empty( $cost ) ) : ?>
<div class="prab-card-meta">
<span class="prab-card-cost"><?php echo $cost; // Already escaped above. ?></span>
</div>
<?php endif; ?>
<?php if ( $is_generating && ! empty( $card['stage_current'] ) ) : ?>
<div class="prab-card-stage">
<?php
printf(
/* translators: %s: pipeline stage name */
esc_html__( 'Stage: %s', 'prautoblogger' ),
esc_html( ucfirst( (string) $card['stage_current'] ) )
);
?>
</div>
<?php endif; ?>
<?php if ( ! empty( $card['error_message'] ) ) : ?>
<div class="prab-card-error">
<?php echo esc_html( wp_trim_words( (string) $card['error_message'], 12, '…' ) ); ?>
</div>
<?php endif; ?>
<?php if ( ! empty( $click_url ) ) : ?>
<div class="prab-card-actions">
<a href="<?php echo $click_url; // Already escaped above. ?>" class="prab-card-link">
<?php echo $link_text; // Already escaped above. ?>
</a>
</div>
<?php endif; ?>
</div>
<?php
}
?>
<div class="wrap prab-board-wrap">
<h1 class="prab-board-heading">
<?php esc_html_e( 'PRAutoBlogger', 'prautoblogger' ); ?>
<span class="prab-board-heading-sub"><?php esc_html_e( 'Article Board', 'prautoblogger' ); ?></span>
<?php if ( current_user_can( 'manage_options' ) ) : ?>
<button type="button"
id="prab-board-generate-now"
class="prab-board-generate-btn button button-primary"
aria-label="<?php esc_attr_e( 'Generate a new article now', 'prautoblogger' ); ?>">
<?php esc_html_e( 'New Article', 'prautoblogger' ); ?>
</button>
<?php endif; ?>
</h1>
<div id="prab-board-gen-status" class="prab-board-gen-status" aria-live="polite" style="display:none;"></div>
<div id="prab-board" class="prab-board" aria-live="polite" aria-label="<?php esc_attr_e( 'Article generation board', 'prautoblogger' ); ?>">
<?php
$columns = array(
'generating' => array(
'label' => __( 'Generating', 'prautoblogger' ),
'pill' => 'prab-pill--generating',
'cards' => $snapshot['generating'],
),
'in_review' => array(
'label' => __( 'In Review', 'prautoblogger' ),
'pill' => 'prab-pill--review',
'cards' => $snapshot['in_review'],
),
'published' => array(
'label' => __( 'Published', 'prautoblogger' ),
'pill' => 'prab-pill--published',
'cards' => $snapshot['published'],
),
'failed' => array(
'label' => __( 'Failed', 'prautoblogger' ),
'pill' => 'prab-pill--failed',
'cards' => $snapshot['failed'],
),
);
foreach ( $columns as $col_key => $col ) :
$count = count( $col['cards'] );
?>
<div class="prab-board-column" data-column="<?php echo esc_attr( $col_key ); ?>">
<div class="prab-col-header">
<span class="prab-col-title"><?php echo esc_html( $col['label'] ); ?></span>
<span class="prab-col-count prab-pill <?php echo esc_attr( $col['pill'] ); ?>"><?php echo esc_html( (string) $count ); ?></span>
</div>
<div class="prab-col-cards" data-column-cards="<?php echo esc_attr( $col_key ); ?>">
<?php if ( empty( $col['cards'] ) ) : ?>
<p class="prab-col-empty"><?php esc_html_e( 'Nothing here yet.', 'prautoblogger' ); ?></p>
<?php else : ?>
<?php foreach ( $col['cards'] as $card ) : ?>
<?php prab_render_board_card( $card, $col_key ); ?>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div><!-- #prab-board -->
<p class="prab-board-footer">
<?php
$interval_secs = max( 3, (int) get_option( 'prautoblogger_board_poll_interval', 5 ) );
printf(
/* translators: %d: poll interval in seconds */
esc_html__( 'Board refreshes every %d seconds while an article is generating. Configure in Settings → Schedule & Budget.', 'prautoblogger' ),
$interval_secs
);
?>
</p>
</div><!-- .prab-board-wrap -->

View File

@@ -0,0 +1,109 @@
<?php
/**
* Partial: per-stage edit + replay panel (M3, v0.20.0).
*
* Rendered only for editable stages (writer chat stages with a
* persisted input). Included by dossier-stage-section.php; inherits its
* scope: $stage_name, $s_role, $section_id, $rerun (panel data),
* $dossier (spend/eligibility/post_id).
*
* UX contract (CPO guardrails): the copy states explicitly that saving
* forks a NEW version (original preserved), that re-running marks
* downstream stages stale, that execution is QUEUED (never implied
* synchronous), and what the run has already spent against its ceiling.
*
* Security: message contents via esc_textarea(); all attributes
* esc_attr(); spend numbers via number_format + esc_html.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$panel_prefill = is_array( $rerun['prefill'] ?? null ) ? $rerun['prefill'] : array(
'model' => '',
'messages' => array(),
);
$panel_fork = is_array( $rerun['fork'] ?? null ) ? $rerun['fork'] : null;
$panel_spend = is_array( $dossier['spend'] ?? null ) ? $dossier['spend'] : array(
'settled' => 0.0,
'reserved' => 0.0,
'ceiling' => 0.0,
'warn' => false,
);
$held_usd = (float) $panel_spend['settled'] + (float) $panel_spend['reserved'];
?>
<div class="prab-edit-panel" id="<?php echo $section_id; // Already escaped. ?>-edit" hidden
data-stage="<?php echo esc_attr( $stage_name ); ?>"
data-agent-role="<?php echo esc_attr( $s_role ); ?>">
<p class="prab-edit-copy">
<?php esc_html_e( 'You are editing a copy of this stage\'s input. Saving creates a new version — the original is preserved unchanged. Re-running this stage executes your latest saved version as a queued background job and marks all downstream stages stale; nothing downstream re-runs until you trigger it.', 'prautoblogger' ); ?>
</p>
<p class="prab-edit-spend<?php echo ! empty( $panel_spend['warn'] ) ? ' prab-edit-spend--warn' : ''; ?>">
<?php if ( (float) $panel_spend['ceiling'] > 0 ) : ?>
<?php
printf(
/* translators: 1: spent USD, 2: ceiling USD. */
esc_html__( 'Run spend so far: $%1$s of the $%2$s per-run ceiling. Re-runs are new spend on this run.', 'prautoblogger' ),
esc_html( number_format( $held_usd, 4 ) ),
esc_html( number_format( (float) $panel_spend['ceiling'], 2 ) )
);
if ( ! empty( $panel_spend['warn'] ) ) {
echo ' ';
esc_html_e( 'Warning: this run is approaching its ceiling — the re-run may halt. Raise the per-run ceiling setting first if needed.', 'prautoblogger' );
}
?>
<?php else : ?>
<?php esc_html_e( 'No per-run cost ceiling is configured for this run (monthly budget still applies).', 'prautoblogger' ); ?>
<?php endif; ?>
</p>
<p class="prab-edit-model">
<?php
printf(
/* translators: %s: model identifier. */
esc_html__( 'Model (fixed for replay): %s', 'prautoblogger' ),
esc_html( (string) $panel_prefill['model'] )
);
?>
</p>
<?php foreach ( $panel_prefill['messages'] as $i => $panel_message ) : ?>
<div class="prab-edit-message">
<label class="prab-edit-role" for="<?php echo $section_id; // Already escaped. ?>-msg-<?php echo (int) $i; ?>">
<?php echo esc_html( ucfirst( (string) ( $panel_message['role'] ?? '' ) ) ); ?>
</label>
<textarea class="prab-edit-textarea large-text"
id="<?php echo $section_id; // Already escaped. ?>-msg-<?php echo (int) $i; ?>"
rows="<?php echo 'system' === ( $panel_message['role'] ?? '' ) ? 4 : 10; ?>"
data-role="<?php echo esc_attr( (string) ( $panel_message['role'] ?? '' ) ); ?>"><?php echo esc_textarea( (string) ( $panel_message['content'] ?? '' ) ); ?></textarea>
</div>
<?php endforeach; ?>
<div class="prab-edit-actions">
<button type="button" class="button button-primary prab-edit-save">
<?php esc_html_e( 'Save as new version', 'prautoblogger' ); ?>
</button>
<button type="button" class="button prab-edit-rerun" <?php disabled( null === $panel_fork ); ?>>
<?php esc_html_e( 'Re-run this stage (queued)', 'prautoblogger' ); ?>
</button>
<span class="prab-edit-forkinfo" data-forkinfo>
<?php
if ( null !== $panel_fork ) {
printf(
/* translators: 1: version number, 2: author login, 3: datetime. */
esc_html__( 'Latest saved version: v%1$d by %2$s at %3$s', 'prautoblogger' ),
(int) $panel_fork['version'],
esc_html( (string) $panel_fork['author'] ),
esc_html( (string) $panel_fork['created_at'] )
);
} else {
esc_html_e( 'No edited version saved yet.', 'prautoblogger' );
}
?>
</span>
<span class="prab-edit-feedback" aria-live="polite" data-feedback></span>
</div>
</div>

View File

@@ -0,0 +1,118 @@
<?php
/**
* Partial: log-only stage section (M3/F3, v0.20.0).
*
* Renders stages that exist ONLY in generation_log — image_a/image_b,
* image_prompt_rewrite, llm_research, opik_eval_judge, ... — which the
* M2 dossier silently dropped because the image pipeline (and the
* research providers) never write run_stages rows (QA M2 F3). These
* sections show the call record (model, tokens, cost, status, raw
* trace) from their REAL data source; image stages additionally render
* the post's actual pipeline attachments ($dossier['images']).
*
* Variables injected by dossier-page.php:
* $stage_name (string) -- stage key
* $log_rows (array) -- generation_log rows for this stage
* $dossier (array) -- page view model (images)
*
* Security: identical escaping discipline to dossier-stage-section.php.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$lo_label = PRAutoBlogger_Stage_Display_Map::label( $stage_name );
$lo_is_image = in_array( $stage_name, array( 'image_a', 'image_b' ), true );
$lo_images = $lo_is_image && ! empty( $dossier['images'] ) ? (array) $dossier['images'] : array();
$lo_cost = 0.0;
$lo_status = '';
$lo_model = '—';
foreach ( $log_rows as $lo_row ) {
$lo_cost += (float) ( $lo_row['estimated_cost'] ?? 0 );
$lo_status = (string) ( $lo_row['response_status'] ?? '' );
if ( ! empty( $lo_row['model'] ) ) {
$lo_model = (string) $lo_row['model'];
}
}
$lo_section_id = 'prab-logstage-' . esc_attr( $stage_name );
?>
<div class="prab-dossier-section prab-dossier-section--stage prab-dossier-section--log-only"
data-stage="<?php echo esc_attr( $stage_name ); ?>"
id="<?php echo $lo_section_id; // Already escaped. ?>">
<div class="prab-stage-header">
<h2 class="prab-section-title"><?php echo esc_html( $lo_label ); ?></h2>
<div class="prab-stage-header-meta">
<?php if ( '' !== $lo_status ) : ?>
<span class="prab-trace-status prab-trace-status--<?php echo esc_attr( $lo_status ); ?>">
<?php echo esc_html( $lo_status ); ?>
</span>
<?php endif; ?>
<?php if ( $lo_cost > 0 ) : ?>
<span class="prab-stage-cost">$<?php echo esc_html( number_format( $lo_cost, 5 ) ); ?></span>
<?php endif; ?>
<span class="prab-stage-model"><?php echo esc_html( $lo_model ); ?></span>
</div>
</div>
<?php if ( ! empty( $lo_images ) ) : ?>
<div class="prab-image-grid">
<?php foreach ( $lo_images as $lo_image ) : ?>
<figure class="prab-image-card<?php echo ! empty( $lo_image['is_featured'] ) ? ' prab-image-card--chosen' : ''; ?>">
<img src="<?php echo esc_url( (string) $lo_image['url'] ); ?>" alt="<?php echo esc_attr( (string) $lo_image['role'] ); ?>" loading="lazy" />
<figcaption>
<?php echo esc_html( ucfirst( (string) $lo_image['role'] ) ); ?>
<?php if ( ! empty( $lo_image['is_featured'] ) ) : ?>
<span class="prab-chip prab-chip--chosen"><?php esc_html_e( 'Chosen (featured)', 'prautoblogger' ); ?></span>
<?php endif; ?>
</figcaption>
</figure>
<?php endforeach; ?>
</div>
<?php elseif ( $lo_is_image ) : ?>
<p class="prab-output-absent"><?php esc_html_e( 'No pipeline attachments recorded for this article (images attach on publish).', 'prautoblogger' ); ?></p>
<?php endif; ?>
<div class="prab-stage-trace">
<button type="button" class="prab-trace-toggle button-link"
aria-expanded="false"
aria-controls="<?php echo $lo_section_id; // Already escaped. ?>-trace">
<?php esc_html_e( 'Show raw trace', 'prautoblogger' ); ?>
</button>
<div id="<?php echo $lo_section_id; // Already escaped. ?>-trace" class="prab-trace-detail" hidden>
<?php foreach ( $log_rows as $log_row ) : ?>
<div class="prab-trace-entry">
<div class="prab-trace-meta">
<span><?php echo esc_html( (string) ( $log_row['model'] ?? '—' ) ); ?></span>
<span>
<?php
printf(
/* translators: 1: prompt tokens, 2: completion tokens. */
esc_html__( '%1$d prompt + %2$d completion tokens', 'prautoblogger' ),
(int) ( $log_row['prompt_tokens'] ?? 0 ),
(int) ( $log_row['completion_tokens'] ?? 0 )
);
?>
</span>
<span>$<?php echo esc_html( number_format( (float) ( $log_row['estimated_cost'] ?? 0 ), 6 ) ); ?></span>
<span class="prab-trace-status prab-trace-status--<?php echo esc_attr( (string) ( $log_row['response_status'] ?? '' ) ); ?>">
<?php echo esc_html( (string) ( $log_row['response_status'] ?? '' ) ); ?>
</span>
</div>
<?php if ( ! empty( $log_row['request_json'] ) ) : ?>
<details class="prab-trace-request">
<summary><?php esc_html_e( 'Request payload', 'prautoblogger' ); ?></summary>
<?php // SECURITY: esc_html() treats model output as untrusted HTML. ?>
<pre class="prab-trace-pre"><?php echo esc_html( (string) $log_row['request_json'] ); ?></pre>
</details>
<?php endif; ?>
<?php if ( ! empty( $log_row['error_message'] ) ) : ?>
<p class="prab-trace-error"><?php echo esc_html( (string) $log_row['error_message'] ); ?></p>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
</div><!-- .prab-dossier-section--log-only -->

View File

@@ -0,0 +1,243 @@
<?php
/**
* Article Dossier template (M2 — read-only).
*
* Design contract: Proposal C "Editorial Record" -- warm editorial,
* typographic hierarchy, verdict pills, receipt-style cost sidebar,
* per-stage I/O with expandable raw-trace toggle. Vanilla JS/CSS only.
*
* Variables injected by PRAutoBlogger_Dossier_Page::render_page():
* $post_id (int) -- WordPress post ID
* $dossier (array) -- View model from PRAutoBlogger_Dossier_Data_Assembler
* $assembler (obj) -- (not directly used here)
*
* @see admin/class-dossier-page.php -- Renders this template.
* @see admin/class-dossier-data-assembler.php -- Builds $dossier.
* @see assets/css/dossier.css -- Styling.
* @see assets/js/dossier.js -- Raw-trace toggle.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$has_run = ! empty( $dossier['has_run'] );
$run = $dossier['run'] ?? null;
$stages = $dossier['stages'] ?? array();
$decisions = $dossier['decisions'] ?? array();
$log_index = $dossier['gen_log_index'] ?? array();
$verdict = $dossier['verdict'] ?? '';
$tier = $dossier['tier'] ?? '';
$run_id = $dossier['run_id'] ?? '';
$log_query = new PRAutoBlogger_Dossier_Gen_Log_Query();
$stage_totals = $log_query->aggregate_per_stage( $log_index );
$total_cost = 0.0;
foreach ( $stage_totals as $st ) {
$total_cost += $st['cost'];
}
$verdict_labels = array(
'approved' => __( 'Approved', 'prautoblogger' ),
'revised' => __( 'Revised', 'prautoblogger' ),
'rejected' => __( 'Rejected', 'prautoblogger' ),
'halted' => __( 'Halted', 'prautoblogger' ),
'pending' => __( 'Pending', 'prautoblogger' ),
);
$back_url = admin_url( 'admin.php?page=prautoblogger-board' );
?>
<div class="wrap prab-dossier-wrap">
<?php // ── Back link ──────────────────────────────────────────────────────── ?>
<p class="prab-dossier-back">
<a href="<?php echo esc_url( $back_url ); ?>">
&larr; <?php esc_html_e( 'Back to Board', 'prautoblogger' ); ?>
</a>
</p>
<?php if ( ! $has_run ) : ?>
<?php // ── Graceful empty state (legacy post or invalid id) ───────────────── ?>
<div id="prab-dossier" class="prab-dossier prab-dossier--empty">
<div class="prab-dossier-empty-notice">
<h2><?php esc_html_e( 'No generation record', 'prautoblogger' ); ?></h2>
<?php if ( $post_id > 0 && ! empty( $dossier['post_title'] ) ) : ?>
<p>
<?php
printf(
/* translators: %s: article title */
esc_html__( 'The article "%s" was published before the generation substrate was introduced (v0.18.0). No dossier data is available.', 'prautoblogger' ),
esc_html( $dossier['post_title'] )
);
?>
</p>
<?php elseif ( $post_id <= 0 ) : ?>
<p><?php esc_html_e( 'No article was specified. Use a board card or post link to reach a dossier.', 'prautoblogger' ); ?></p>
<?php else : ?>
<p><?php esc_html_e( 'No run record was found for this article.', 'prautoblogger' ); ?></p>
<?php endif; ?>
</div>
</div>
<?php return; ?>
<?php endif; ?>
<?php // ── Full dossier ────────────────────────────────────────────────────── ?>
<div id="prab-dossier" class="prab-dossier">
<?php // ── Article header ──────────────────────────────────────────────── ?>
<div class="prab-dossier-header">
<div class="prab-dossier-header-main">
<h1 class="prab-dossier-title"><?php echo esc_html( $dossier['post_title'] ); ?></h1>
<div class="prab-dossier-header-meta">
<?php if ( '' !== $verdict ) : ?>
<span class="prab-verdict-pill prab-verdict-pill--<?php echo esc_attr( $verdict ); ?>">
<?php echo esc_html( $verdict_labels[ $verdict ] ?? ucfirst( $verdict ) ); ?>
</span>
<?php endif; ?>
<?php if ( '' !== $tier ) : ?>
<span class="prab-dossier-tier"><?php echo esc_html( ucfirst( $tier ) ); ?></span>
<?php endif; ?>
<span class="prab-dossier-status prab-dossier-status--<?php echo esc_attr( $dossier['post_status'] ?? '' ); ?>">
<?php echo esc_html( ucfirst( $dossier['post_status'] ?? '' ) ); ?>
</span>
<?php // M3: run-summary audit badges -- visible, never tooltip-buried (CPO guardrail 2). ?>
<span class="prab-chip prab-chip--human prab-chip--header" data-header-chip="human" <?php echo ! empty( $dossier['human_modified_any'] ) ? '' : 'hidden'; ?>>
<?php esc_html_e( 'Human-modified', 'prautoblogger' ); ?>
</span>
<span class="prab-chip prab-chip--stale prab-chip--header" data-header-chip="stale" <?php echo ! empty( $dossier['stale_any'] ) ? '' : 'hidden'; ?>>
<?php esc_html_e( 'Has stale stages', 'prautoblogger' ); ?>
</span>
</div>
</div>
<?php if ( $post_id > 0 ) : ?>
<div class="prab-dossier-header-actions">
<a href="<?php echo esc_url( get_edit_post_link( $post_id, 'raw' ) ?? '' ); ?>" class="button">
<?php esc_html_e( 'Edit Article', 'prautoblogger' ); ?>
</a>
</div>
<?php endif; ?>
</div>
<div class="prab-dossier-body">
<?php // ── Main column: stage sections ─────────────────────────────── ?>
<div class="prab-dossier-main">
<?php
$stage_order = array( 'analysis', 'llm_research', 'research', 'curate', 'outline', 'draft', 'polish', 'review', 'editorial', 'seo', 'publish', 'image_a', 'image_b' );
$rendered_stages = array();
// Render stages in canonical order first, then any extra rows.
foreach ( $stage_order as $stage_name ) {
foreach ( $stages as $role_key => $stage_row ) {
if ( ( $stage_row['stage'] ?? '' ) !== $stage_name ) {
continue;
}
$rendered_stages[] = $role_key;
$log_rows = $log_index[ $stage_name ] ?? array();
require PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/dossier-stage-section.php';
}
}
// Remaining unordered stages.
foreach ( $stages as $role_key => $stage_row ) {
if ( in_array( $role_key, $rendered_stages, true ) ) {
continue;
}
$stage_name = (string) ( $stage_row['stage'] ?? 'unknown' );
$log_rows = $log_index[ $stage_name ] ?? array();
require PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/dossier-stage-section.php';
}
// M3/F3: stages that exist only in generation_log (images,
// llm_research, ...) render from their real data source.
foreach ( ( $dossier['log_only_stages'] ?? array() ) as $stage_name ) {
$log_rows = $log_index[ $stage_name ] ?? array();
if ( empty( $log_rows ) ) {
continue;
}
require PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/dossier-log-stage-section.php';
}
if ( empty( $stages ) ) :
?>
<div class="prab-dossier-no-stages">
<p><?php esc_html_e( 'Stage data is not yet available for this run.', 'prautoblogger' ); ?></p>
</div>
<?php endif; ?>
<?php // ── Editorial decisions section ─────────────────────────────── ?>
<?php if ( ! empty( $decisions ) ) : ?>
<div class="prab-dossier-section prab-dossier-section--decisions">
<h2 class="prab-section-title"><?php esc_html_e( 'Editorial Decisions', 'prautoblogger' ); ?></h2>
<?php foreach ( $decisions as $d_stage => $d ) : ?>
<div class="prab-decision-row">
<span class="prab-decision-stage"><?php echo esc_html( PRAutoBlogger_Stage_Display_Map::label( $d_stage ) ); ?></span>
<span class="prab-verdict-pill prab-verdict-pill--<?php echo esc_attr( (string) ( $d['verdict'] ?? '' ) ); ?>">
<?php echo esc_html( ucfirst( (string) ( $d['verdict'] ?? '' ) ) ); ?>
</span>
<?php if ( ! empty( $d['rationale'] ) ) : ?>
<p class="prab-decision-rationale"><?php echo esc_html( (string) $d['rationale'] ); ?></p>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div><!-- .prab-dossier-main -->
<?php // ── Sidebar: metadata + cost receipt ───────────────────────── ?>
<div class="prab-dossier-sidebar">
<div class="prab-sidebar-card">
<h3 class="prab-sidebar-heading"><?php esc_html_e( 'Run Info', 'prautoblogger' ); ?></h3>
<?php if ( null !== $run ) : ?>
<dl class="prab-sidebar-dl">
<dt><?php esc_html_e( 'Status', 'prautoblogger' ); ?></dt>
<dd><span class="prab-run-status prab-run-status--<?php echo esc_attr( (string) ( $run['status'] ?? '' ) ); ?>"><?php echo esc_html( ucfirst( (string) ( $run['status'] ?? '' ) ) ); ?></span></dd>
<dt><?php esc_html_e( 'Started', 'prautoblogger' ); ?></dt>
<dd><?php echo esc_html( (string) ( $run['started_at'] ?? '—' ) ); ?></dd>
<?php if ( ! empty( $run['finished_at'] ) ) : ?>
<dt><?php esc_html_e( 'Finished', 'prautoblogger' ); ?></dt>
<dd><?php echo esc_html( (string) $run['finished_at'] ); ?></dd>
<?php endif; ?>
<dt><?php esc_html_e( 'Run ID', 'prautoblogger' ); ?></dt>
<dd class="prab-run-id"><?php echo esc_html( $run_id ); ?></dd>
</dl>
<?php endif; ?>
</div>
<?php require PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/dossier-sidebar-cards.php'; ?>
<div class="prab-sidebar-card prab-cost-receipt">
<h3 class="prab-sidebar-heading"><?php esc_html_e( 'Cost Receipt', 'prautoblogger' ); ?></h3>
<table class="prab-receipt-table">
<tbody>
<?php foreach ( $stage_totals as $st_name => $st ) : ?>
<?php
if ( $st['cost'] <= 0.0 && 0 === $st['calls'] ) :
continue;
endif;
?>
<tr>
<td class="prab-receipt-stage"><?php echo esc_html( PRAutoBlogger_Stage_Display_Map::label( $st_name ) ); ?></td>
<td class="prab-receipt-cost">$<?php echo esc_html( number_format( $st['cost'], 5 ) ); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr class="prab-receipt-total">
<td><?php esc_html_e( 'Total', 'prautoblogger' ); ?></td>
<td>$<?php echo esc_html( number_format( $total_cost, 5 ) ); ?></td>
</tr>
</tfoot>
</table>
</div>
</div><!-- .prab-dossier-sidebar -->
</div><!-- .prab-dossier-body -->
</div><!-- #prab-dossier -->
</div><!-- .prab-dossier-wrap -->

View File

@@ -0,0 +1,86 @@
<?php
/**
* Partial: M3 sidebar cards — run spend (guardrail 4 visibility) and
* the per-stage model/prompt-version consolidation (QA M2 F2).
*
* Included by dossier-page.php; inherits its scope: $dossier, $run,
* $log_index, $stages.
*
* Security: all db-sourced values esc_html(); numbers via number_format.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$sc_spend = is_array( $dossier['spend'] ?? null ) ? $dossier['spend'] : array(
'settled' => 0.0,
'reserved' => 0.0,
'ceiling' => 0.0,
'warn' => false,
);
$sc_held = (float) $sc_spend['settled'] + (float) $sc_spend['reserved'];
?>
<div class="prab-sidebar-card prab-run-spend<?php echo ! empty( $sc_spend['warn'] ) ? ' prab-run-spend--warn' : ''; ?>">
<h3 class="prab-sidebar-heading"><?php esc_html_e( 'Run Spend', 'prautoblogger' ); ?></h3>
<dl class="prab-sidebar-dl">
<dt><?php esc_html_e( 'Settled', 'prautoblogger' ); ?></dt>
<dd>$<?php echo esc_html( number_format( (float) $sc_spend['settled'], 4 ) ); ?></dd>
<?php if ( (float) $sc_spend['reserved'] > 0 ) : ?>
<dt><?php esc_html_e( 'Reserved', 'prautoblogger' ); ?></dt>
<dd>$<?php echo esc_html( number_format( (float) $sc_spend['reserved'], 4 ) ); ?></dd>
<?php endif; ?>
<dt><?php esc_html_e( 'Ceiling', 'prautoblogger' ); ?></dt>
<dd>
<?php if ( (float) $sc_spend['ceiling'] > 0 ) : ?>
$<?php echo esc_html( number_format( (float) $sc_spend['ceiling'], 2 ) ); ?>
<?php else : ?>
<?php esc_html_e( 'none configured', 'prautoblogger' ); ?>
<?php endif; ?>
</dd>
</dl>
<?php if ( ! empty( $sc_spend['warn'] ) ) : ?>
<p class="prab-spend-warning">
<?php
printf(
/* translators: 1: spent USD, 2: ceiling USD. */
esc_html__( 'This run has committed $%1$s of its $%2$s ceiling — further re-runs may halt.', 'prautoblogger' ),
esc_html( number_format( $sc_held, 4 ) ),
esc_html( number_format( (float) $sc_spend['ceiling'], 2 ) )
);
?>
</p>
<?php endif; ?>
</div>
<div class="prab-sidebar-card prab-models-card">
<h3 class="prab-sidebar-heading"><?php esc_html_e( 'Models & Prompts', 'prautoblogger' ); ?></h3>
<table class="prab-models-table">
<tbody>
<?php foreach ( $log_index as $sc_stage => $sc_rows ) : ?>
<?php
$sc_first = $sc_rows[0] ?? array();
$sc_model = ! empty( $sc_first['model'] ) ? (string) $sc_first['model'] : '—';
$sc_pv = ! empty( $sc_first['prompt_version'] ) ? (string) $sc_first['prompt_version'] : '—';
$sc_role = ! empty( $sc_first['agent_role'] ) ? (string) $sc_first['agent_role'] : '—';
?>
<tr>
<th scope="row"><?php echo esc_html( PRAutoBlogger_Stage_Display_Map::label( (string) $sc_stage ) ); ?></th>
<td>
<span class="prab-models-model"><?php echo esc_html( $sc_model ); ?></span>
<span class="prab-models-meta">
<?php
printf(
/* translators: 1: prompt version, 2: agent role. */
esc_html__( 'pv %1$s · %2$s', 'prautoblogger' ),
esc_html( $sc_pv ),
esc_html( $sc_role )
);
?>
</span>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>

View File

@@ -0,0 +1,173 @@
<?php
/**
* Partial: single stage section for the Article Dossier.
*
* Variables injected by dossier-page.php:
* $stage_row (array) -- run_stages row incl. display_output + ['rerun'] panel data (M3)
* $stage_name (string) -- stage key
* $log_rows (array) -- generation_log rows for this stage
* $dossier (array) -- page view model (spend/eligibility/post_id)
*
* Security: all model output escaped via esc_html() or wp_kses_post().
* request_json escaped via esc_html() -- treat as untrusted HTML.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$s_label = PRAutoBlogger_Stage_Display_Map::label( $stage_name );
$s_status = (string) ( $stage_row['status'] ?? '' );
$s_output = $stage_row['display_output'] ?? null;
$s_cost = (float) ( $stage_row['cost_usd'] ?? 0 );
$s_role = (string) ( $stage_row['agent_role'] ?? '' );
$s_started = (string) ( $stage_row['started_at'] ?? '' );
$s_finished = (string) ( $stage_row['finished_at'] ?? '' );
$output_pruned = ( ! empty( $stage_row['status'] ) && 'done' === $stage_row['status'] && null === $s_output );
// Determine prompt version from gen_log (first row for this stage).
$first_log = ! empty( $log_rows ) ? $log_rows[0] : null;
$s_model = ( null !== $first_log && ! empty( $first_log['model'] ) ) ? $first_log['model'] : '—';
$s_pv = ( null !== $first_log && ! empty( $first_log['prompt_version'] ) ) ? $first_log['prompt_version'] : '—';
$has_raw_trace = ! empty( $log_rows );
$section_id = 'prab-stage-' . esc_attr( $stage_name ) . '-' . esc_attr( $s_role );
$rerun = is_array( $stage_row['rerun'] ?? null ) ? $stage_row['rerun'] : array();
$is_stale = ! empty( $rerun['stale'] );
$is_human = ! empty( $rerun['human_modified'] );
$attempts = (int) ( $rerun['attempt'] ?? 1 );
?>
<div class="prab-dossier-section prab-dossier-section--stage prab-stage-status--<?php echo esc_attr( $s_status ); ?><?php echo $is_stale ? ' prab-stage--stale' : ''; ?>"
data-stage="<?php echo esc_attr( $stage_name ); ?>"
data-agent-role="<?php echo esc_attr( $s_role ); ?>"
id="<?php echo $section_id; // Already escaped. ?>">
<div class="prab-stage-header">
<h2 class="prab-section-title">
<?php echo esc_html( $s_label ); ?>
<?php if ( '' !== $s_role ) : ?>
<span class="prab-stage-role">(<?php echo esc_html( $s_role ); ?>)</span>
<?php endif; ?>
</h2>
<div class="prab-stage-header-meta">
<span class="prab-stage-status-pill prab-stage-status-pill--<?php echo esc_attr( $s_status ); ?>">
<?php echo esc_html( ucfirst( $s_status ) ); ?>
</span>
<?php // M3 audit chips -- visible, never tooltip-buried (CPO guardrails 2+3). ?>
<span class="prab-chip prab-chip--stale" data-chip="stale" <?php echo $is_stale ? '' : 'hidden'; ?>>
<?php esc_html_e( 'Stale — upstream changed', 'prautoblogger' ); ?>
</span>
<span class="prab-chip prab-chip--human" data-chip="human" <?php echo $is_human ? '' : 'hidden'; ?>>
<?php esc_html_e( 'Human-edited input', 'prautoblogger' ); ?>
</span>
<?php if ( $attempts > 1 ) : ?>
<span class="prab-chip prab-chip--attempt">
<?php
printf(
/* translators: %d: attempt count. */
esc_html__( 'Attempt %d', 'prautoblogger' ),
(int) $attempts
);
?>
</span>
<?php endif; ?>
<?php if ( $s_cost > 0 ) : ?>
<span class="prab-stage-cost">$<?php echo esc_html( number_format( $s_cost, 5 ) ); ?></span>
<?php endif; ?>
<span class="prab-stage-model"><?php echo esc_html( $s_model ); ?></span>
<?php if ( '—' !== $s_pv ) : ?>
<span class="prab-stage-pv">pv <?php echo esc_html( $s_pv ); ?></span>
<?php endif; ?>
</div>
</div>
<?php // ── Output ──────────────────────────────────────────────────────── ?>
<div class="prab-stage-output">
<?php if ( $output_pruned ) : ?>
<p class="prab-output-pruned">
<?php
$days = (int) get_option( 'prautoblogger_request_json_retention_days', 14 );
printf(
/* translators: %d: retention days */
esc_html__( 'Output retained for %d days — currently unavailable (pruned by retention cron).', 'prautoblogger' ),
$days
);
?>
</p>
<?php elseif ( null !== $s_output ) : ?>
<div class="prab-stage-output-rendered">
<?php echo wp_kses_post( $s_output ); ?>
</div>
<?php else : ?>
<p class="prab-output-absent"><?php esc_html_e( 'No output recorded for this stage.', 'prautoblogger' ); ?></p>
<?php endif; ?>
</div>
<?php // ── Raw trace toggle ─────────────────────────────────────────────── ?>
<?php if ( $has_raw_trace ) : ?>
<div class="prab-stage-trace">
<button type="button" class="prab-trace-toggle button-link"
aria-expanded="false"
aria-controls="<?php echo $section_id; ?>-trace">
<?php esc_html_e( 'Show raw trace', 'prautoblogger' ); ?>
</button>
<div id="<?php echo $section_id; ?>-trace" class="prab-trace-detail" hidden>
<?php foreach ( $log_rows as $log_row ) : ?>
<div class="prab-trace-entry">
<div class="prab-trace-meta">
<span><?php echo esc_html( (string) ( $log_row['model'] ?? '—' ) ); ?></span>
<span><?php printf( esc_html__( '%1$d prompt + %2$d completion tokens', 'prautoblogger' ), (int) ( $log_row['prompt_tokens'] ?? 0 ), (int) ( $log_row['completion_tokens'] ?? 0 ) ); ?></span>
<span>$<?php echo esc_html( number_format( (float) ( $log_row['estimated_cost'] ?? 0 ), 6 ) ); ?></span>
<span class="prab-trace-status prab-trace-status--<?php echo esc_attr( (string) ( $log_row['response_status'] ?? '' ) ); ?>">
<?php echo esc_html( (string) ( $log_row['response_status'] ?? '' ) ); ?>
</span>
</div>
<?php if ( ! empty( $log_row['request_json'] ) ) : ?>
<details class="prab-trace-request">
<summary><?php esc_html_e( 'Request payload', 'prautoblogger' ); ?></summary>
<?php // SECURITY: esc_html() treats model output as untrusted HTML. ?>
<pre class="prab-trace-pre"><?php echo esc_html( (string) $log_row['request_json'] ); ?></pre>
</details>
<?php endif; ?>
<?php if ( ! empty( $log_row['error_message'] ) ) : ?>
<p class="prab-trace-error"><?php echo esc_html( (string) $log_row['error_message'] ); ?></p>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php if ( '' !== $s_started ) : ?>
<div class="prab-stage-timestamps">
<span class="prab-ts"><?php echo esc_html( $s_started ); ?></span>
<?php if ( '' !== $s_finished ) : ?>
<span class="prab-ts-sep">→</span>
<span class="prab-ts"><?php echo esc_html( $s_finished ); ?></span>
<?php endif; ?>
</div>
<?php endif; ?>
<?php // ── M3: edit + re-run affordances (chained-cron; nothing is synchronous) ── ?>
<div class="prab-stage-rerun-footer">
<?php if ( ! empty( $rerun['editable'] ) ) : ?>
<button type="button" class="button prab-edit-toggle"
aria-expanded="false" aria-controls="<?php echo $section_id; // Already escaped. ?>-edit">
<?php esc_html_e( 'Edit input', 'prautoblogger' ); ?>
</button>
<?php elseif ( ! empty( $rerun['edit_reason'] ) ) : ?>
<span class="prab-edit-unavailable"><?php echo esc_html( (string) $rerun['edit_reason'] ); ?></span>
<?php endif; ?>
<?php if ( ! empty( $rerun['rerun_from'] ) ) : ?>
<button type="button" class="button prab-rerun-from<?php echo $is_stale ? ' prab-rerun-from--stale' : ''; ?>"
data-stage="<?php echo esc_attr( $stage_name ); ?>">
<?php esc_html_e( 'Re-run from here', 'prautoblogger' ); ?>
</button>
<?php endif; ?>
</div>
<?php
if ( ! empty( $rerun['editable'] ) ) {
require PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/dossier-edit-panel.php';
}
?>
</div><!-- .prab-dossier-section--stage -->

View File

@@ -72,10 +72,11 @@ $base_url = admin_url( 'admin.php?page=prautoblogger-ideas' );
<tbody>
<?php foreach ( $rows as $row ) : ?>
<?php
$meta = json_decode( $row['metadata_json'] ?? '{}', true ) ?: [];
$decoded = json_decode( $row['metadata_json'] ?? '{}', true );
$meta = is_array( $decoded ) ? $decoded : array();
$suggested = $meta['suggested_title'] ?? '';
$key_points = $meta['key_points'] ?? [];
$keywords = $meta['target_keywords'] ?? [];
$key_points = $meta['key_points'] ?? array();
$keywords = $meta['target_keywords'] ?? array();
$score_pct = round( (float) $row['relevance_score'] * 100 );
$type_label = ucfirst( $row['analysis_type'] ?? 'unknown' );
$type_class = 'prab-type-' . sanitize_html_class( $row['analysis_type'] );
@@ -164,14 +165,18 @@ $base_url = admin_url( 'admin.php?page=prautoblogger-ideas' );
<?php if ( $total_pages > 1 ) : ?>
<div class="prautoblogger-pagination" style="margin-top: 12px;">
<?php
echo wp_kses_post( paginate_links( [
'base' => add_query_arg( 'paged', '%#%' ),
'format' => '',
'current' => $paged,
'total' => $total_pages,
'prev_text' => '&laquo;',
'next_text' => '&raquo;',
] ) );
echo wp_kses_post(
paginate_links(
array(
'base' => add_query_arg( 'paged', '%#%' ),
'format' => '',
'current' => $paged,
'total' => $total_pages,
'prev_text' => '&laquo;',
'next_text' => '&raquo;',
)
)
);
?>
</div>
<?php endif; ?>

View File

@@ -18,13 +18,13 @@ if ( ! defined( 'ABSPATH' ) ) {
}
$base_url = admin_url( 'admin.php?page=prautoblogger-logs' );
$level_counts = [
$level_counts = array(
'all' => __( 'All', 'prautoblogger' ),
'error' => __( 'Errors', 'prautoblogger' ),
'warning' => __( 'Warnings', 'prautoblogger' ),
'info' => __( 'Info', 'prautoblogger' ),
'debug' => __( 'Debug', 'prautoblogger' ),
];
);
?>
<div class="wrap ab-wrap">
<div class="ab-header">
@@ -46,7 +46,20 @@ $level_counts = [
<div class="ab-log-filters">
<div class="ab-log-level-tabs">
<?php foreach ( $level_counts as $lv => $lv_label ) : ?>
<a href="<?php echo esc_url( add_query_arg( [ 'level' => $lv, 's' => $search, 'paged' => 1 ], $base_url ) ); ?>"
<a href="
<?php
echo esc_url(
add_query_arg(
array(
'level' => $lv,
's' => $search,
'paged' => 1,
),
$base_url
)
);
?>
"
class="ab-log-level-tab <?php echo $level === $lv ? 'ab-log-level-active' : ''; ?>">
<?php echo esc_html( $lv_label ); ?>
</a>
@@ -119,14 +132,18 @@ $level_counts = [
<?php if ( $total_pages > 1 ) : ?>
<div class="ab-log-pagination">
<?php
echo wp_kses_post( paginate_links( [
'base' => add_query_arg( 'paged', '%#%' ),
'format' => '',
'current' => $paged,
'total' => $total_pages,
'prev_text' => '&laquo;',
'next_text' => '&raquo;',
] ) );
echo wp_kses_post(
paginate_links(
array(
'base' => add_query_arg( 'paged', '%#%' ),
'format' => '',
'current' => $paged,
'total' => $total_pages,
'prev_text' => '&laquo;',
'next_text' => '&raquo;',
)
)
);
?>
</div>
<?php endif; ?>

View File

@@ -0,0 +1,41 @@
<?php
/**
* Post metabox template (slimmed, M2): "View generation dossier" link.
*
* The full generation record is available on the Article Dossier page.
* This metabox replaces the previous inline metadata display to avoid
* duplicating data that lives in the dossier.
*
* @see admin/class-post-metabox.php -- Renders this template.
* @see admin/class-dossier-page.php -- Dossier target page.
*
* @var WP_Post $post The current post.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$run_id = (string) get_post_meta( $post->ID, '_prautoblogger_run_id', true );
$url = PRAutoBlogger_Dossier_Page::url_for_post( $post->ID );
?>
<div class="prab-metabox-dossier">
<?php if ( '' !== $run_id ) : ?>
<p>
<a href="<?php echo esc_url( $url ); ?>" class="button button-secondary">
<?php esc_html_e( 'View generation dossier', 'prautoblogger' ); ?> &rarr;
</a>
</p>
<p class="description prab-run-id-label">
<?php
printf(
/* translators: %s: run UUID */
esc_html__( 'Run: %s', 'prautoblogger' ),
esc_html( $run_id )
);
?>
</p>
<?php else : ?>
<p class="description"><?php esc_html_e( 'No generation record — article was not created by PRAutoBlogger.', 'prautoblogger' ); ?></p>
<?php endif; ?>
</div>

Some files were not shown because too many files have changed in this diff Show More