Compare commits

..

2 Commits

Author SHA1 Message Date
peptiderepo
6f76df1ba5 fix(ci): yoda condition + un-shadow capability-cache test expectations
- class-image-composer.php: swap the fingerprint comparison operands
  (WordPress.PHP.YodaConditions flagged the parenthesized null-coalesce
  expression on the right-hand side).
- ImageComposerTest: the two capability-cache tests asserted
  update_option calls via Functions\expect(), which the setUp
  Functions\when() stub shadows (Mockery dispatches the call to the
  first matching expectation), so the with()-matcher saw 0 calls once
  the CI runner's real imagick made the probe path live. Capture calls
  with a when() alias instead (the suite's house pattern) and assert on
  the captured writes - including autoload=false and the rewritten
  fingerprint. The matching-fingerprint test now genuinely asserts
  zero writes instead of a trivially-satisfied never().

No composer behavior change beyond the operand swap.

Agent-Session: engineer-prautoblogger-image-composer-20260611
2026-06-11 12:45:43 +07:00
peptiderepo
28947a0caa feat: deterministic image composer — OG + square variants, branding, baked caption (v0.17.0)
Commit 2 (final scope) of the in-plugin editorial image pipeline
(ARCHITECTURE.md #21). A deterministic local composer now runs between
provider bytes and sideload: Image A yields a corner-marked featured
(1200x632) + branded OG (1200x630, teal band, logo, baked caption in
Poppins SemiBold) + square card (1080x1080, cream panel, Poppins Bold,
teal footer with the reverse lockup); Image B gets the corner mark only.

- Capability ladder: imagick (full compositing) -> wp_get_image_editor
  resize-only (unbranded, WARNING once) -> pass-through (WARNING once);
  probe cached in prautoblogger_image_compose_capability, fingerprinted
  by PHP version + imagick/gd presence so host flips auto-invalidate.
  compose() never throws; composition can never fail a publish.
- Variants sideloaded with a role filename suffix; attachment meta
  _prautoblogger_image_role + _prautoblogger_base_attachment_id; post
  meta _prautoblogger_og_image_id (frozen for the rebuilt SEO stage) +
  _prautoblogger_square_image_id. No cost-tracker rows ($0 local stage;
  duration logged on the image_composer channel).
- New Images settings: compose_enabled ('1'), compose_variants
  ('og,square'), featured_mark_enabled ('1') — read at point of use.
  Geometry ships as Layout defaults behind the
  prautoblogger_image_compose_layout filter.
- Vendored brand rasters (assets/brand) + bundled OFL Poppins faces
  (assets/fonts) for per-environment byte-stable output (stripImage +
  PNG date/time chunks excluded). Validated on Hostinger PHP 8.3.30 /
  ImageMagick 7.1.1-43: php -l clean on all touched files, double
  render byte-identical (sha256), exact variant geometry.
- Fix: featured/Image B alt text is the editorial caption (was the
  model name); model still persists in _prautoblogger_image_model.
- Image_Attacher extracted from Image_Pipeline (300-line compliance);
  sideloader gains an optional filename suffix param (back-compat '').
- Version 0.16.0 -> 0.17.0 + CHANGELOG + ARCHITECTURE.md #21 +
  CONVENTIONS.md composer guide in this commit. Unit tests: layout
  clamp boundaries, capability ladder, cache invalidation, attacher
  meta-linking, pipeline integration, imagick determinism (gated).

Agent-Session: engineer-prautoblogger-image-composer-20260611
2026-06-11 12:31:03 +07:00
94 changed files with 1340 additions and 11500 deletions

View File

@@ -451,7 +451,7 @@ jobs:
if rev.get("concerns"):
lines.append("**Concerns:**")
for c in rev["concerns"]:
sev = c.get("severity", "UNKNOWN").upper()
sev = c["severity"].upper()
f = f"`{c['file']}`" if c.get("file") else ""
lines.append(f"- **{sev}** {f} — {c['issue']}")
lines.append("")

297
ARCHITECTURE.md Normal file → Executable file
View File

@@ -119,22 +119,14 @@ 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)
│ │ └── 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)
│ └── posts-widget.js # React component for frontend post cards (wp.element)
│
├── includes/
│ ├── class-prautoblogger.php # Main orchestrator — registers all hooks, delegates execution (258 lines; 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 # v0.18.0 substrate tables (prompts, run_sources, run_decisions, runs, run_stages)
│ ├── class-migrate-prompt-seed-v0180.php # One-shot v0.18.0 prompt-registry seed migration
│ ├── 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-prautoblogger.php # Main orchestrator — registers all hooks, delegates execution
│ ├── class-executor.php # Cron handlers, generation AJAX (start + status), model registry
│ ├── 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
@@ -142,12 +134,6 @@ 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: hidden-submenu, priority 12, enqueue, nonce (v0.19.2)
│ │ ├── 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-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
@@ -185,19 +171,6 @@ prautoblogger/
│ │ ├── class-image-attacher.php # Sideload + cost log + variant meta-linking + caption prepend
│ │ ├── class-image-media-sideloader.php # Imports images into WordPress media library
│ │ ├── class-cost-tracker.php # Logs all API costs, enforces budget limits
│ │ ├── class-stage-display-map.php # Stage vocabulary: labels + default roles + prompt keys (old + new + image stages)
│ │ ├── class-prompt-registry.php # Versioned prompt registry, read side (render, pins, self-healing fallback)
│ │ ├── class-prompt-registry-writer.php # Registry write side (create version, activate, seed) — versions immutable
│ │ ├── class-prompt-defaults.php # Canonical v1 bodies: content.* + analysis.* (seed + fallback single source)
│ │ ├── class-prompt-defaults-editorial.php # Canonical v1 bodies: editor.* / research.system / image.* (composer seam)
│ │ ├── class-run-context.php # Per-process active run id (set by Cost_Tracker::set_run_id)
│ │ ├── 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-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)
│ │ ├── class-logger.php # Structured logging singleton (error/warning/info/debug)
│ │ ├── class-article-worker.php # Single-article generation (content + edit + publish)
│ │ ├── class-pipeline-runner.php # Orchestrates pipeline; chains per-article cron jobs
@@ -207,7 +180,6 @@ 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
@@ -220,13 +192,11 @@ 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 # Request body assembly + reasoning token cap/headroom (v0.18.1) + headers + Hostinger cURL auth workaround
│ │ ├── class-open-router-request-builder.php # Header building + 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/
@@ -250,15 +220,11 @@ 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 # 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 (superseded by metabox-dossier-link.php in v0.19.2)
│ └── metabox-generation-info.php# Post metabox template
│
├── languages/
│ └── prautoblogger.pot # i18n translation template
@@ -355,19 +321,11 @@ Hostinger's 120-second LiteSpeed connection timeout. The frontend polls for stat
└──────────────────────────────┘
```
**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.
**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.
---
@@ -421,7 +379,7 @@ All tables use `$wpdb->prefix` + `prautoblogger_` prefix.
| id | BIGINT UNSIGNED | Auto-increment PK |
| post_id | BIGINT UNSIGNED | WordPress post ID (nullable, set after publish)|
| run_id | VARCHAR(36) | UUID linking all entries from one pipeline run |
| stage | VARCHAR(50) | See "Stage vocabulary" below. Canonical edit-stage name is **'polish'** (this doc previously said 'edit'; the code always wrote 'polish') |
| stage | VARCHAR(50) | 'analysis', 'outline', 'draft', 'edit', 'review' |
| provider | VARCHAR(50) | 'openrouter' |
| model | VARCHAR(100) | Model identifier used |
| prompt_tokens | INT | Input tokens consumed |
@@ -430,8 +388,6 @@ All tables use `$wpdb->prefix` + `prautoblogger_` prefix.
| request_json | LONGTEXT | Full request payload (for debugging) |
| response_status | VARCHAR(20) | 'success', 'error', 'timeout' |
| error_message | TEXT | Error details if failed (nullable) |
| agent_role | VARCHAR(50) | v0.18.0, nullable. Role that made the call ('writer', 'editor', 'analyst', 'researcher', 'illustrator', …). Historical rows stay NULL |
| prompt_version | VARCHAR(20) | v0.18.0, nullable. Pinned prompt-registry version the call rendered with. Historical rows stay NULL |
| created_at | DATETIME | When the API call was made |
- INDEX: `post_id`
@@ -439,18 +395,6 @@ All tables use `$wpdb->prefix` + `prautoblogger_` prefix.
- INDEX: `created_at`
- INDEX: `stage`
**Stage vocabulary.** `stage` is a VARCHAR, not a SQL enum; the vocabulary lives in
`PRAutoBlogger_Stage_Display_Map` (PHP), which renders historical and new values coherently
and maps each stage to its default agent role + primary prompt-registry key:
- **Historical values (exist on prod):** `analysis`, `outline`, `draft`, `polish`, `review`,
`llm_research`, `image_a`, `image_b`, `image_prompt_rewrite`, `opik_eval_judge`.
Note: single-pass generation logs its one call as `draft` (no `outline` row — that absence
is how single-pass and multi-step runs are told apart in cost reporting).
- **Pipeline v2 values (new runs may write):** `research`, `curate`, `draft`, `editorial`,
`seo`, `publish`.
- Unknown values render via a humanizing fallback — the audit view never renders blank.
#### `prab_event_log` — Structured application logging
| Column | Type | Description |
@@ -483,97 +427,6 @@ and maps each stage to its default agent role + primary prompt-registry key:
- INDEX: `measured_at`
- INDEX: `composite_score`
#### Pipeline v2 substrate tables (v0.18.0, db 1.2.0)
Real table names use the standard prefix, e.g. `wp_prautoblogger_prompts` (the plan-of-record's
`prab_*` names are shorthand). Created by `PRAutoBlogger_Pipeline_Schema_Installer`; all are
dropped on uninstall.
##### `prautoblogger_prompts` — versioned prompt registry
| Column | Type | Description |
|-------------|-----------------|----------------------------------------------------------|
| id | BIGINT UNSIGNED | Auto-increment PK |
| prompt_key | VARCHAR(64) | Registry key, e.g. 'content.single_pass' (`key` is reserved SQL) |
| version | INT UNSIGNED | Monotonic per key; **rows are immutable** — edits create new versions |
| body | LONGTEXT | Prompt template; `{{ token }}` placeholders filled at render time |
| model | VARCHAR(100) | Optional model hint (informational in Phase 1) |
| params_json | LONGTEXT | Optional params snapshot (temperature, max_tokens, …) |
| author | VARCHAR(100) | Who created the version ('seed:v0.18.0', wp user login, agent) |
| created_at | DATETIME | Version creation time |
| active | TINYINT(1) | Exactly one active row per key |
- UNIQUE: `prompt_key, version` · INDEX: `prompt_key, active`
##### `prautoblogger_run_sources` — per-run source audit
| Column | Type | Description |
|---------------|--------------|----------------------------------------------|
| id | BIGINT UNSIGNED | Auto-increment PK |
| run_id | VARCHAR(36) | Pipeline run UUID |
| agent_role | VARCHAR(50) | Role that surfaced the source |
| source_url | VARCHAR(500) | Source URL (nullable) |
| doi | VARCHAR(255) | DOI when known (nullable) |
| kept | TINYINT(1) | Keep/discard decision |
| reason | TEXT | Why kept or discarded (nullable) |
| quality_score | FLOAT | Source quality weighting (nullable) |
| created_at | DATETIME | Row creation time |
- INDEX: `run_id` — consolidates the old `source_ids_json` + `_prautoblogger_research_sources`
scatter for **new** runs; historical runs are not backfilled.
##### `prautoblogger_run_decisions` — per-stage decision audit
| Column | Type | Description |
|----------------|-------------|-----------------------------------------------|
| id | BIGINT UNSIGNED | Auto-increment PK |
| run_id | VARCHAR(36) | Pipeline run UUID |
| stage | VARCHAR(50) | Stage that decided (see stage vocabulary) |
| 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 |
| created_at | DATETIME | Row creation time |
- INDEX: `run_id`
##### `prautoblogger_runs` — run ledger + lifecycle
| Column | Type | Description |
|---------------------|---------------|---------------------------------------------|
| run_id | VARCHAR(36) | PK — pipeline run UUID |
| status | VARCHAR(20) | pending / running / done / failed / halted |
| ceiling_usd | DECIMAL(10,6) | Per-run cost ceiling snapshotted at run start |
| reserved_usd | DECIMAL(10,6) | Outstanding reserve-before-call holds |
| settled_usd | DECIMAL(10,6) | Actual settled spend |
| overage_usd | DECIMAL(10,6) | Amount the breaching reservation exceeded the ceiling by |
| pinned_prompts_json | LONGTEXT | Map of prompt_key → version frozen at run start |
| started_at | DATETIME | Run start |
| updated_at | DATETIME | Last ledger/state write |
| finished_at | DATETIME | Set on done/failed/halted (nullable) |
- INDEX: `status` — the Cost_Governor's reserve is a single conditional UPDATE against this
row (same atomic discipline as the #10 generation lock), so concurrent `curl_multi`
writers cannot slip past the ceiling between check and call.
##### `prautoblogger_run_stages` — per-run per-stage state machine
| Column | Type | Description |
|-------------|-----------------|----------------------------------------------------|
| 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. 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) |
| 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`
### WordPress Options (`wp_options`)
All prefixed with `prautoblogger_`:
@@ -588,14 +441,11 @@ 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 |
| `prautoblogger_target_subreddits` | JSON array of subreddits to monitor |
| `prautoblogger_monthly_budget_usd` | Monthly API spend limit in USD |
| `prautoblogger_per_run_cost_ceiling_usd` | v0.18.0 — per-run hard cost ceiling (USD); reserve-before-call enforced; snapshotted onto the runs row at run start; 0 = disabled. Default `PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD` ($0.50) |
| `prautoblogger_request_json_retention_days` | v0.18.0 — days before heavy audit payloads (generation_log.request_json, run_stages.meta_json) are NULLed by the daily cleanup cron; 0 = keep forever. Default `PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS` (14) |
| `prautoblogger_tone` | Content tone (informational, conversational, etc.) |
| `prautoblogger_min_word_count` | Minimum article word count (default: 800) |
| `prautoblogger_max_word_count` | Maximum article word count (default: 2000) |
@@ -623,8 +473,6 @@ 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
@@ -643,7 +491,6 @@ Stored on every PRAutoBlogger-generated post:
| `_prautoblogger_editor_notes` | Chief editor's review notes |
| `_prautoblogger_generated_at` | ISO 8601 timestamp of generation |
| `_prautoblogger_research_sources` | JSON array of source URLs used |
| `_prautoblogger_idea_hash` | v0.18.0 — stable hash of the idea (title|topic); with `_prautoblogger_run_id` forms the post-creation idempotency key |
| `_prautoblogger_og_image_id` | Attachment ID of the composed 1200×630 OG variant (v0.17.0; the rebuilt SEO stage emits `og:image` from this — key name frozen) |
| `_prautoblogger_square_image_id` | Attachment ID of the composed 1080×1080 square variant (v0.17.0; stored now, no consumer yet) |
@@ -822,135 +669,11 @@ We already use Cloudflare for DNS/CDN on peptiderepo.com, so layering AI Gateway
---
### #22: Pipeline v2 Phase 1 substrate (v0.18.0, db 1.2.0, June 2026)
Phase 1 of the CEO-approved pipeline rebuild (plan of record:
`convo/cpo/threads/2026-06-prautoblogger-pipeline-redesign/03-cto-revised-brief.md`), built
**on the current pipeline with no behavior change** to the Economy (single-pass) and
multi-step publish paths. Four subsystems:
1. **Versioned prompt registry** (`prautoblogger_prompts`). All pipeline prompt copy
(content/analysis/editor/research + the illustration rewriter prompt and the image Style
Template) lives as immutable versioned rows; the hardcoded v0.16.0 texts are seeded as v1
and remain the in-code fallback, so a missing/empty table can never fatal — call sites
render through `PRAutoBlogger_Prompt_Registry` and fall back to the same constants the
seed used (byte-identical output). A run pins the active version of every key at start
(`runs.pinned_prompts_json`) and every generation_log row is stamped with the pinned
`prompt_version`. The image-composer PR **consumes** the `image.*` keys — it must not
grow its own prompt storage. No admin UI in Phase 1; the API supports list/activate/
create-version (never mutate) for the Phase-2 Prompts screen.
2. **Additive audit schema.** `agent_role` + `prompt_version` columns on generation_log;
`run_sources` + `run_decisions` child tables; `PRAutoBlogger_Stage_Display_Map` renders
historical + image + v2 stage values. `request_json` (and stage output payloads in
`run_stages.meta_json`) are nulled after R days — R from the
`prautoblogger_request_json_retention_days` setting (default 14), pruned on the existing
daily reaper cron.
3. **Per-run cost governor** (`PRAutoBlogger_Cost_Governor`). Reserve-before-call against the
per-run ledger row using a single conditional UPDATE (the #10 atomic-write discipline);
estimates come from the #18 pricing chain; `curl_multi` image batches reserve their summed
estimate before dispatch; reservations settle to actuals after each response. Breach →
run `halted`, overage recorded, un-dispatched work aborted, surfaced on the Review Queue.
Ceiling = `prautoblogger_per_run_cost_ceiling_usd` setting (default $0.50, 0 = disabled).
The monthly `Cost_Tracker` cap and the Cloudflare AI Gateway (#15) path are unchanged.
4. **Run state machine + idempotency** (`PRAutoBlogger_Run_State`). Per-run per-stage state
(pending/running/done/failed/halted) keyed `run_id + stage + agent_role + item_key`
(item_key scopes article-level stages because one run_id spans all N articles of a batch);
done stages persist output and are reused on re-entry, never re-charged; post creation is
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 (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)
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`): registered as a hidden submenu under
`prautoblogger-settings` at `admin_menu` priority **12**, one step after the board (priority
11), which is itself after the parent menu (priority 10). The page is immediately removed
from `$submenu` so it does not appear in the WP admin sidebar, but remains reachable by
direct URL. URL shape: `admin.php?page=prautoblogger-dossier&post_id=<int>`. Secured by
`manage_options` capability + nonce (`prautoblogger_dossier`). Static `url_for_post(int
$post_id): string` provides the canonical deep-link. Regression test:
`tests/unit/Admin/DossierMenuRegistrationTest.php`.
**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`).
## 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.

View File

@@ -5,97 +5,6 @@ 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.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
### Added
@@ -1010,7 +919,6 @@ 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
@@ -1047,6 +955,14 @@ 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

View File

@@ -204,18 +204,13 @@ the uninstall prefix-sweep effective), then cover it in the unit ladder tests.
## How To: Add a New Pipeline Stage
Writing stages are methods on `Content_Generator` that delegate to its `execute_stage()`
helper (Opik span + LLM dispatch + cost log + run-stage state in one place).
The writing pipeline is an ordered array of stages. Each stage is a class method on `Content_Generator`.
1. Add the stage method to `class-content-generator.php` and call
`$this->execute_stage( $stage, $span_name, $user_prompt, $request, $model, $options )`
2. Wire it into `generate_multi_step()` (or the relevant mode path)
3. Add the stage to `PRAutoBlogger_Stage_Display_Map` (label, default agent role, prompt key)
and — if it has registry-managed copy — a prompt key in the defaults classes
4. Cost logging and per-run stage state (idempotent resume, output snapshot) come free from
`execute_stage()`; the per-run cost governor guards the call inside the provider
5. If a stage fails after retries, the entire pipeline fails (no partial publishes); the
stage row is failed by the worker's catch-all and the run is swept by Run_Reaper
1. Add the stage method to `class-content-generator.php`: `protected function stage_{name}(PRAutoBlogger_Content_Request $request): string`
2. Register the stage in the `get_pipeline_stages()` method for the appropriate mode (single_pass or multi_step)
3. Each stage receives the accumulated context and returns its output
4. Each stage logs its cost via `Cost_Tracker::log_api_call()`
5. If a stage fails after retries, the entire pipeline fails (no partial publishes)
---
@@ -325,38 +320,6 @@ The field renderer, AJAX refresh, model picker JS, and capability filtering are
Phase 3 will add provider selection; v0.11.0 is OpenRouter-only.
## How To: Change a Pipeline Prompt (v0.18.0+)
Prompt copy is **versioned data**, not code. The bodies live in
`wp_prautoblogger_prompts` (one ACTIVE version per key); the hardcoded texts in
`class-prompt-defaults.php` / `class-prompt-defaults-editorial.php` are the canonical v1 seed
AND the fallback when the table is unavailable — never edit a stored version in place.
1. **Never `UPDATE` a body.** Create a new version and activate it:
```php
$v = PRAutoBlogger_Prompt_Registry_Writer::create_version(
'content.polish', $new_body, $model, $params, 'your-name', true
);
```
2. **Keys** are dot-namespaced: `content.system`, `content.single_pass`, `content.outline`,
`content.draft`, `content.polish`, `analysis.system`, `analysis.user`, `editor.system`,
`editor.review`, `research.system`, `image.rewriter_system`, `image.style_template`.
The two `image.*` keys are the image-composer seam — coordinate on the convo thread
before touching them.
3. **Tokens** use the `{{ name }}` syntax (exactly one space each side — same convention as
the Style Template's `{{ topic_summary }}`). Token VALUES are computed by code
(`Content_Prompts`, `Analysis_Prompts`, `Chief_Editor`); a new prompt version may reorder
or drop tokens but must not invent new ones without adding the value at the call site.
4. **Pinning.** A run pins the active version of every key at start
(`runs.pinned_prompts_json`, written by `Cost_Tracker::set_run_id()`); every
generation_log row is stamped with the pinned `prompt_version`. Activating a new version
affects the NEXT run, never a run in flight.
5. **Call sites** render via `PRAutoBlogger_Prompt_Registry::render( $key, $tokens )` and
must keep working when the table is absent (the registry falls back to the defaults
automatically — do not add your own fallback).
6. If you add a key: define the body in the appropriate defaults class, add it to `defs()`,
map its stage in `Stage_Display_Map`, and it will seed on the next migration pass.
## Git Workflow — PR-Gated, Soft-Enforced
This repo is private on GitHub's free plan, which does not support branch protection or rulesets. The review gate is enforced at the agent layer, not server-side. Every agent and human contributor follows these rules; the `.github/workflows/main-push-audit.yml` tripwire opens an audit issue on any direct push to `main` that did not come from a merged PR.

View File

@@ -1,249 +0,0 @@
/**
* 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;
}

View File

@@ -1,441 +0,0 @@
/**
* 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

@@ -1,241 +0,0 @@
/**
* 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>';
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 || [] );
}
/**
* 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 ) );
}
} );
}
/** Initialise the board poller on DOM ready. */
$( function () {
$board = $( '#prab-board' );
if ( ! $board.length ) {
return;
}
// Inject the error banner after the heading.
$errorBanner = $( '<div class="prab-board-error" role="alert">' + escHtml( cfg.strings.pollError || '' ) + '</div>' );
$board.before( $errorBanner );
// Check if any generating card is present in the initial server render.
var hasActive = ( $board.find( '[data-column="generating"] .prab-board-card' ).length > 0 );
backoffFactor = hasActive ? 1 : 1; // Always start at base on first load.
schedulePoll( nextDelay( hasActive ) );
} );
}( jQuery ) );

View File

@@ -1,41 +0,0 @@
/**
* 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' );
}
} );
} );
} );
}() );

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

@@ -62,7 +62,6 @@ 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',
@@ -324,7 +323,7 @@ class PRAutoBlogger_Admin_Page {
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' );
$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;
}

View File

@@ -1,188 +0,0 @@
<?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' => 20,
'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 $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' => 20,
'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 $cards;
}
}

View File

@@ -1,184 +0,0 @@
<?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 10",
$since
),
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 20",
$since
),
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

@@ -1,182 +0,0 @@
<?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 2× 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 — §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 §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
);
$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 ),
'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' ),
),
)
);
}
/**
* 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

@@ -1,191 +0,0 @@
<?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.
*
* 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(),
);
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;
}
$base['has_run'] = true;
$base['run'] = $run;
$base['stages'] = $this->get_stage_rows( $run_id );
$base['decisions'] = $this->get_decision_rows( $run_id );
$base['gen_log_index'] = $this->log_query->get_by_run( $run_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 all run_stages rows for a run, indexed by stage name.
*
* @param string $run_id Pipeline run UUID.
* @return array<string, array<string, mixed>> Stage rows keyed by stage name.
*/
private function get_stage_rows( string $run_id ): array {
if ( ! PRAutoBlogger_Run_Stage_State::is_available() ) {
return array();
}
global $wpdb;
if ( null === $wpdb ) {
return array();
}
$table = PRAutoBlogger_Run_Stage_State::table_name();
// 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;
}
/**
* 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

@@ -1,95 +0,0 @@
<?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

@@ -1,117 +0,0 @@
<?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 registers
* as a hidden submenu under 'prautoblogger-settings' at admin_menu priority 12
* (after board at 11) so WordPress resolves the hookname correctly. Board cards and
* post list columns deep-link here via ?page=prautoblogger-dossier&post_id=X.
*
* 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 -- §Dossier (M2).
*/
class PRAutoBlogger_Dossier_Page {
/** Admin page slug. */
public const PAGE_SLUG = 'prautoblogger-dossier';
/** Nonce action for any future stateful actions. */
public const NONCE_ACTION = 'prautoblogger_dossier';
/**
* Register the dossier as a hidden submenu page.
*
* Hidden = not visible in the menu, but reachable by URL.
* Priority 12 ensures add_menu_page() (priority 10) has populated
* $admin_page_hooks before this add_submenu_page() call. See ARCHITECTURE.md §Board.
*
* @return void
*/
public function on_register_menu(): void {
add_submenu_page(
'prautoblogger-settings',
__( 'Article Dossier', 'prautoblogger' ),
__( 'Dossier', 'prautoblogger' ),
'manage_options',
self::PAGE_SLUG,
array( $this, 'render_page' )
);
// Remove from visible submenu so it doesn't clutter the nav.
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- intentional hidden-submenu pattern.
global $submenu;
if ( isset( $submenu['prautoblogger-settings'] ) ) {
foreach ( $submenu['prautoblogger-settings'] as $idx => $item ) {
if ( isset( $item[2] ) && self::PAGE_SLUG === $item[2] ) {
unset( $submenu['prautoblogger-settings'][ $idx ] );
break;
}
}
}
}
/**
* 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.
*
* @param string $hook_suffix Current admin page hook suffix.
* @return void
*/
public function on_enqueue_assets( string $hook_suffix ): void {
$dossier_hook = 'prautoblogger_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
);
}
/**
* Build the URL to the dossier for a given post.
*
* @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

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

13
includes/admin/class-review-queue.php Normal file → Executable file
View File

@@ -51,22 +51,9 @@ class PRAutoBlogger_Review_Queue {
$paged = isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1;
$query = $this->get_pending_posts( $paged );
// v0.18.0 — runs halted by the per-run cost governor are routed
// here for human attention (they may have no draft post to list).
$halted_runs = $this->get_halted_runs();
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/review-queue.php';
}
/**
* Runs halted by the per-run cost governor, newest first.
*
* @return array<int, array<string, mixed>> Halted run rows (max 10).
*/
public function get_halted_runs(): array {
return PRAutoBlogger_Run_State::halted_runs( 10 );
}
/**
* Query generated draft posts awaiting review.
*

View File

@@ -81,28 +81,6 @@ class PRAutoBlogger_Settings_Fields_Extended {
'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',

12
includes/class-activator.php Normal file → Executable file
View File

@@ -31,22 +31,14 @@ class PRAutoBlogger_Activator {
self::set_default_options();
self::schedule_cron();
self::backpopulate_run_id_meta_v081();
PRAutoBlogger_Migrate_Prompt_Seed_V0180::run();
// Store the DB version so we can run migrations on future updates.
update_option( 'prautoblogger_db_version', PRAUTOBLOGGER_DB_VERSION, false );
}
/**
* Delegate to the schema installers — see those classes.
*
* `Schema_Installer` owns the v1.1.0 tables; `Pipeline_Schema_Installer`
* owns the v0.18.0 / db-1.2.0 Pipeline v2 substrate tables. Both are
* dbDelta-idempotent and safe to re-run on any version mismatch.
*/
/** Delegate to `PRAutoBlogger_Schema_Installer::install()` — see that class. */
private static function create_tables(): void {
PRAutoBlogger_Schema_Installer::install();
PRAutoBlogger_Pipeline_Schema_Installer::install();
}
/**
@@ -68,8 +60,6 @@ class PRAutoBlogger_Activator {
'prautoblogger_niche_description' => '',
'prautoblogger_target_subreddits' => '[]',
'prautoblogger_monthly_budget_usd' => 50.00,
'prautoblogger_per_run_cost_ceiling_usd' => PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD,
'prautoblogger_request_json_retention_days' => PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS,
'prautoblogger_tone' => 'informational',
'prautoblogger_min_word_count' => 800,
'prautoblogger_max_word_count' => 2000,

View File

@@ -1,129 +0,0 @@
<?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' );
}
}
}

View File

@@ -4,26 +4,25 @@ declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Cron handlers for content generation and metrics collection.
* Cron/AJAX handlers for content generation, metrics collection, model registry.
*
* 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-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.
* @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;
/** @var PRAutoBlogger_Generation_Status_Poller|null Lazy-loaded poller. */
private ?PRAutoBlogger_Generation_Status_Poller $poller = 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;
/**
* Handle the daily generation cron event.
@@ -54,7 +53,6 @@ class PRAutoBlogger_Executor {
sprintf( 'Daily generation %s: %s', get_class( $e ), $e->getMessage() ),
'scheduler'
);
$this->mark_current_run_failed();
PRAutoBlogger_Generation_Lock::release();
}
}
@@ -76,12 +74,79 @@ class PRAutoBlogger_Executor {
/**
* AJAX handler: kick off manual generation as a background cron job.
* Delegates to PRAutoBlogger_Generation_Status_Poller.
*
* 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.
*
* Side effects: schedules a WP-Cron event, writes a status transient.
*/
public function on_ajax_generate_now(): void {
$this->poller()->on_ajax_generate_now();
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' ),
)
);
}
/**
@@ -99,18 +164,18 @@ class PRAutoBlogger_Executor {
if ( ! PRAutoBlogger_Generation_Lock::acquire() ) {
set_transient(
PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
self::STATUS_TRANSIENT,
array(
'status' => 'error',
'message' => __( 'Could not acquire generation lock. Another run may be in progress.', 'prautoblogger' ),
),
PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
self::STATUS_TTL
);
return;
}
try {
$this->poller()->update_generation_stage( __( 'Collecting sources from Reddit...', 'prautoblogger' ) );
$this->update_generation_stage( __( 'Collecting sources from Reddit...', 'prautoblogger' ) );
$runner = ( new PRAutoBlogger_Pipeline_Runner() )->set_skip_dedup( true );
$results = $runner->run();
@@ -119,7 +184,7 @@ class PRAutoBlogger_Executor {
// When articles ARE queued, the pipeline handles status + lock release.
if ( ! get_option( 'prautoblogger_article_queue' ) ) {
set_transient(
PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
self::STATUS_TRANSIENT,
array(
'status' => 'complete',
'generated' => $results['generated'],
@@ -127,7 +192,7 @@ class PRAutoBlogger_Executor {
'rejected' => $results['rejected'],
'cost' => $results['cost'],
),
PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
self::STATUS_TTL
);
PRAutoBlogger_Generation_Lock::release();
}
@@ -137,14 +202,13 @@ class PRAutoBlogger_Executor {
'pipeline'
);
set_transient(
PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
self::STATUS_TRANSIENT,
array(
'status' => 'error',
'message' => $e->getMessage(),
),
PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
self::STATUS_TTL
);
$this->mark_current_run_failed();
PRAutoBlogger_Generation_Lock::release();
}
}
@@ -167,44 +231,83 @@ class PRAutoBlogger_Executor {
delete_option( 'prautoblogger_article_queue' );
PRAutoBlogger_Generation_Lock::release();
set_transient(
PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
self::STATUS_TRANSIENT,
array(
'status' => 'error',
'message' => $e->getMessage(),
),
PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
self::STATUS_TTL
);
}
}
/** AJAX: return generation status for frontend polling. Delegates to poller. */
/** AJAX: return generation status for frontend polling. Recovers orphaned runs. */
public function on_ajax_generation_status(): void {
$this->poller()->on_ajax_generation_status();
}
/**
* Lazy-load the generation status poller.
*
* @return PRAutoBlogger_Generation_Status_Poller
*/
private function poller(): PRAutoBlogger_Generation_Status_Poller {
if ( null === $this->poller ) {
$this->poller = new PRAutoBlogger_Generation_Status_Poller();
check_ajax_referer( 'prautoblogger_generate_now', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
return;
}
return $this->poller;
$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 {
delete_transient( self::STATUS_TRANSIENT );
delete_option( 'prautoblogger_article_queue' );
PRAutoBlogger_Generation_Lock::release();
wp_send_json_success(
array(
'status' => 'error',
'message' => $message,
)
);
}
/**
* 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).
* Helper: update the generation stage for frontend polling.
*
* @return void
* @param string $stage Human-readable stage description.
*/
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' );
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 );
}
}

View File

@@ -76,29 +76,6 @@ 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

@@ -1,260 +0,0 @@
<?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

@@ -1,59 +0,0 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* One-shot v0.18.0 migration — seed the versioned prompt registry.
*
* What: Writes the canonical hardcoded prompt texts (content, analysis,
* editor, research, illustration rewriter, image Style Template)
* into `wp_prautoblogger_prompts` as immutable version 1 and
* activates them. Idempotent twice over: gated by the
* `prautoblogger_migrated_prompt_seed_v0180` flag AND seed_v1()
* itself skips any key that already has rows. The flag is only set
* when the table was actually reachable, so a failed seed
* self-heals on the next activation / version-mismatch pass.
* Standalone class per the Migrate_Remove_Cloudflare_V0100
* precedent (keeps Activator under the 300-line cap).
* Who triggers it: PRAutoBlogger_Activator::activate() — on activation and
* on every db-version mismatch.
* Dependencies: PRAutoBlogger_Prompt_Registry(_Writer), get_option.
*
* @see class-activator.php — Sole caller.
* @see core/class-prompt-registry-writer.php — seed_v1() implementation.
* @see includes/class-migrate-remove-cloudflare-v0100.php — Pattern precedent.
*/
class PRAutoBlogger_Migrate_Prompt_Seed_V0180 {
/** Option flag marking the seed as complete. */
private const FLAG = 'prautoblogger_migrated_prompt_seed_v0180';
/**
* Run the seed once.
*
* Side effects: up to 12 INSERTs into the prompts table, one INFO log
* line, one option write.
*
* @return void
*/
public static function run(): void {
if ( get_option( self::FLAG ) ) {
return;
}
if ( ! PRAutoBlogger_Prompt_Registry::is_available() ) {
return; // Table missing — retry on the next migration pass.
}
$seeded = PRAutoBlogger_Prompt_Registry_Writer::seed_v1( 'seed:v0.18.0' );
if ( class_exists( 'PRAutoBlogger_Logger', false ) ) {
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Prompt registry seeded: %d key(s) written as v1.', $seeded ),
'activator'
);
}
update_option( self::FLAG, '1' );
}
}

View File

@@ -1,147 +0,0 @@
<?php
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).
*
* What: Owns the five v0.18.0 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
* 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
* on every `prautoblogger_db_version` mismatch (self-healing
* auto-migration via PRAutoBlogger::on_check_db_version()).
* Dependencies: WordPress $wpdb, dbDelta() from wp-admin/includes/upgrade.php.
*
* @see class-schema-installer.php — v1.1.0 tables (source data, logs, scores).
* @see class-activator.php — Sole caller.
* @see core/class-prompt-registry.php — Reads/writes the prompts table.
* @see core/class-run-state.php — Reads/writes runs + run_stages.
* @see ARCHITECTURE.md — Database schema section documents each table.
*/
class PRAutoBlogger_Pipeline_Schema_Installer {
/**
* Create (or upgrade) the Pipeline v2 substrate tables.
*
* `dbDelta` is idempotent: it creates missing tables, adds missing
* columns and indexes, and never drops anything — matching the
* plugin's forward-only schema policy. Safe to re-run on every
* version mismatch.
*
* Side effects: up to five CREATE TABLE / ALTER TABLE statements.
*
* @return void
*/
public static function install(): void {
global $wpdb;
$charset_collate = $wpdb->get_charset_collate();
$prefix = $wpdb->prefix . 'prautoblogger_';
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
// Versioned prompt registry. Versions are immutable: writes create
// new (prompt_key, version) rows; exactly one row per key has
// active = 1. `prompt_key` (not `key`) because KEY is reserved SQL.
$sql_prompts = "CREATE TABLE {$prefix}prompts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
prompt_key VARCHAR(64) NOT NULL,
version INT UNSIGNED NOT NULL DEFAULT 1,
body LONGTEXT NOT NULL,
model VARCHAR(100) DEFAULT NULL,
params_json LONGTEXT DEFAULT NULL,
author VARCHAR(100) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL,
active TINYINT(1) NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY key_version (prompt_key, version),
KEY key_active (prompt_key, active)
) {$charset_collate};";
// Research-source audit rows: one row per source a run considered,
// with the keep/discard decision. Consolidates the old
// source_ids_json + _prautoblogger_research_sources scatter for
// new runs (no backfill of historical runs).
$sql_run_sources = "CREATE TABLE {$prefix}run_sources (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
run_id VARCHAR(36) NOT NULL,
agent_role VARCHAR(50) NOT NULL DEFAULT '',
source_url VARCHAR(500) DEFAULT NULL,
doi VARCHAR(255) DEFAULT NULL,
kept TINYINT(1) NOT NULL DEFAULT 0,
reason TEXT DEFAULT NULL,
quality_score FLOAT DEFAULT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY run_id (run_id)
) {$charset_collate};";
// Stage-decision audit rows: verdict + rationale per pipeline
// stage. citation_score is nullable until the Phase-2 editorial
// loop computes it.
$sql_run_decisions = "CREATE TABLE {$prefix}run_decisions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
run_id VARCHAR(36) NOT NULL,
stage VARCHAR(50) NOT NULL,
verdict VARCHAR(50) NOT NULL,
rationale TEXT DEFAULT NULL,
citation_score FLOAT DEFAULT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY run_id (run_id)
) {$charset_collate};";
// Per-run ledger + lifecycle row. reserved/settled/ceiling drive the
// Cost_Governor's atomic reserve-before-call check; pinned_prompts_json
// freezes the prompt versions a run uses at start; status feeds the
// run state machine (pending|running|done|failed|halted).
$sql_runs = "CREATE TABLE {$prefix}runs (
run_id VARCHAR(36) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
ceiling_usd DECIMAL(10,6) NOT NULL DEFAULT 0,
reserved_usd DECIMAL(10,6) NOT NULL DEFAULT 0,
settled_usd DECIMAL(10,6) NOT NULL DEFAULT 0,
overage_usd DECIMAL(10,6) NOT NULL DEFAULT 0,
pinned_prompts_json LONGTEXT DEFAULT NULL,
started_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
finished_at DATETIME DEFAULT NULL,
PRIMARY KEY (run_id),
KEY status (status)
) {$charset_collate};";
// Per-run per-stage state rows. The idempotency key is
// (run_id, stage, agent_role, item_key): agent_role is the Phase-2
// fan-out dimension (quorum members), item_key scopes article-level
// stages within multi-article runs ('' for run-level stages).
// meta_json holds the stage output for resume-without-recharge.
$sql_run_stages = "CREATE TABLE {$prefix}run_stages (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
run_id VARCHAR(36) NOT NULL,
stage VARCHAR(50) NOT NULL,
agent_role VARCHAR(50) NOT NULL DEFAULT '',
item_key VARCHAR(64) NOT NULL DEFAULT '',
status VARCHAR(20) NOT NULL DEFAULT 'pending',
attempt SMALLINT UNSIGNED NOT NULL DEFAULT 1,
cost_usd DECIMAL(10,6) NOT NULL DEFAULT 0,
meta_json LONGTEXT DEFAULT NULL,
started_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
finished_at DATETIME DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY run_stage_role_item (run_id, stage, agent_role, item_key),
KEY status_updated (status, updated_at)
) {$charset_collate};";
dbDelta( $sql_prompts );
dbDelta( $sql_run_sources );
dbDelta( $sql_run_decisions );
dbDelta( $sql_runs );
dbDelta( $sql_run_stages );
}
}

View File

@@ -6,12 +6,20 @@ declare(strict_types=1);
*
* Main orchestrator for the PRAutoBlogger plugin.
*
* Registers all WordPress hooks and wires up dependencies.
* DB/option migrations extracted to PRAutoBlogger_DB_Migrations (v0.19.2).
* 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.
*
* @see prautoblogger.php -- Plugin bootstrap.
* @see class-db-migrations.php -- One-time DB/option migrations.
* @see ARCHITECTURE.md -- Data flow diagram.
* 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.
*/
class PRAutoBlogger {
@@ -20,12 +28,14 @@ class PRAutoBlogger {
/** @var PRAutoBlogger_Executor Handles generation cron/AJAX, model registry. */
private PRAutoBlogger_Executor $executor;
/** @var PRAutoBlogger_Ajax_Handlers Handles non-generation AJAX. */
/** @var PRAutoBlogger_Ajax_Handlers Handles non-generation AJAX (images, models, test). */
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 ) {
@@ -36,8 +46,6 @@ 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.
add_action( 'init', array( $this, 'on_check_db_version_for_cron' ) );
add_filter( 'cron_schedules', array( $this, 'filter_add_cron_schedules' ) );
if ( is_admin() ) {
@@ -53,30 +61,13 @@ class PRAutoBlogger {
do_action( 'prautoblogger_loaded' );
}
/**
* 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.
*/
/** Register admin-only hooks (settings, notices, metabox, dashboard widget). */
private function register_admin_hooks(): void {
$admin_page = new PRAutoBlogger_Admin_Page();
add_action( 'admin_menu', array( $admin_page, 'on_register_menu' ), 10 );
add_action( 'admin_menu', array( $admin_page, 'on_register_menu' ) );
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' ) );
@@ -91,6 +82,7 @@ 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' ) );
}
@@ -110,7 +102,13 @@ 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' )
@@ -118,39 +116,55 @@ 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
);
$dispatcher = new PRAutoBlogger_Opik_Dispatcher( $client, new PRAutoBlogger_Opik_Span_Queue() );
$queue = new PRAutoBlogger_Opik_Span_Queue();
$dispatcher = new PRAutoBlogger_Opik_Dispatcher( $client, $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.
add_action( 'prautoblogger_generate_from_idea', array( 'PRAutoBlogger_Ideas_Browser', 'on_cron_generate_from_idea' ) );
add_action( 'prautoblogger_reap_orphan_research_rows', array( 'PRAutoBlogger_Research_Reaper', 'on_cron' ) );
add_action( 'prautoblogger_reap_orphan_research_rows', array( 'PRAutoBlogger_Run_Reaper', 'on_cron' ) );
// 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.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, skipped %d.', (int) $stats['reaped'], (int) $stats['deleted'], (int) $stats['skipped'] ) );
\WP_CLI::success(
sprintf(
'Reaped %d, deleted %d (stale), skipped %d.',
(int) $stats['reaped'],
(int) $stats['deleted'],
(int) $stats['skipped']
)
);
}
);
}
@@ -158,9 +172,11 @@ 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' ) );
@@ -172,7 +188,6 @@ 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' ) );
$ideas = new PRAutoBlogger_Ideas_Browser();
add_action( 'wp_ajax_prautoblogger_generate_from_idea', array( $ideas, 'on_ajax_generate_from_idea' ) );
@@ -180,27 +195,117 @@ class PRAutoBlogger {
}
/**
* Check if database needs migration. Delegates to PRAutoBlogger_DB_Migrations::run().
* Hooked on `admin_init`.
* 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.
*/
public function on_check_db_version(): void {
PRAutoBlogger_DB_Migrations::run();
}
$stored_version = get_option( 'prautoblogger_db_version', '0' );
if ( version_compare( $stored_version, PRAUTOBLOGGER_DB_VERSION, '<' ) ) {
PRAutoBlogger_Activator::activate();
}
/**
* 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() ) {
$this->on_check_db_version();
// 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' );
}
}
/**
* Add custom cron schedules (six-hourly for metrics collection).
*
* @param array<string, array{interval: int, display: string}> $schedules Existing.
* @param array<string, array{interval: int, display: string}> $schedules Existing schedules.
* @return array<string, array{interval: int, display: string}>
*/
public function filter_add_cron_schedules( array $schedules ): array {
@@ -218,6 +323,7 @@ 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.
@@ -234,7 +340,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

@@ -77,9 +77,6 @@ class PRAutoBlogger_Schema_Installer {
// Generation log — every API call with cost tracking. `run_id`
// groups all entries from a single pipeline execution so
// `link_generation_logs()` can accurately attribute costs to a post.
// v0.18.0 adds the nullable audit columns `agent_role` and
// `prompt_version` (dbDelta adds them additively on upgrade;
// historical rows stay valid with NULL).
$sql_generation_log = "CREATE TABLE {$prefix}generation_log (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
post_id BIGINT UNSIGNED DEFAULT NULL,
@@ -93,8 +90,6 @@ class PRAutoBlogger_Schema_Installer {
request_json LONGTEXT DEFAULT NULL,
response_status VARCHAR(20) NOT NULL DEFAULT 'success',
error_message TEXT DEFAULT NULL,
agent_role VARCHAR(50) DEFAULT NULL,
prompt_version VARCHAR(20) DEFAULT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY post_id (post_id),

View File

@@ -9,8 +9,7 @@ declare(strict_types=1);
* What: Builds system and user prompts for source-data analysis, including
* past-performance context for self-improvement feedback loops.
* Who calls it: PRAutoBlogger_Content_Analyzer::analyze_recent_data().
* Dependencies: PRAutoBlogger_Prompt_Registry (versioned prompt bodies),
* WordPress $wpdb (for performance lookup), no external APIs.
* Dependencies: WordPress $wpdb (for performance lookup), no external APIs.
*
* @see core/class-content-analyzer.php — Orchestrates analysis and calls these builders.
* @see ARCHITECTURE.md — Data flow step 2.
@@ -26,26 +25,54 @@ class PRAutoBlogger_Analysis_Prompts {
* @return string System prompt text.
*/
public static function build_system_prompt( string $niche, string $performance_context, int $target_count = 6 ): string {
// Blocks carry their own separators when non-empty so the rendered
// output is byte-identical to the historical concatenation.
$performance_block = '' !== $performance_context ? $performance_context . "\n\n" : '';
$prompt = 'You are a content strategist analyzing social media discussions to find article ideas for a blog';
if ( '' !== $niche ) {
$prompt .= " in the {$niche} niche";
}
$prompt .= ".\n\n";
$prompt .= "IMPORTANT: You must identify exactly {$target_count} DISTINCT article ideas. ";
$prompt .= 'Each idea must cover a substantially different topic — no two ideas should overlap ';
$prompt .= "in their main subject. Aim for diversity across these categories:\n";
$prompt .= "1. QUESTIONS: Recurring questions people ask (\"How do I...\", \"What is...\", \"Is it safe to...\")\n";
$prompt .= "2. COMPLAINTS: Pain points, frustrations, or problems people report\n";
$prompt .= "3. COMPARISONS: Product/method comparisons people discuss (\"X vs Y\", \"Which is better\")\n";
$prompt .= "4. NEWS: Recent developments, rule changes, or trends people are discussing\n";
$prompt .= "5. GUIDES: How-to topics, best practices, or educational content people need\n\n";
$prompt .= "Spread your {$target_count} ideas across multiple categories. ";
$prompt .= 'Be specific — "peptide dosing for BPC-157" is better than "peptide information". ';
$prompt .= "Each idea should be narrow enough to be a single focused blog post.\n\n";
$prompt .= "For each idea, provide:\n";
$prompt .= "- type: 'question', 'complaint', 'comparison', 'news', or 'guide'\n";
$prompt .= "- topic: A clear, specific, narrow topic description\n";
$prompt .= "- summary: 1-2 sentence summary of why this topic matters\n";
$prompt .= "- frequency: How many of the provided posts relate to this topic\n";
$prompt .= "- relevance_score: 0.0 to 1.0 indicating how relevant and article-worthy this is\n";
$prompt .= "- suggested_title: A compelling, unique blog post title\n";
$prompt .= "- key_points: Array of 3-5 key points the article should cover\n";
$prompt .= "- target_keywords: Array of SEO keywords for this topic\n\n";
if ( '' !== $performance_context ) {
$prompt .= $performance_context . "\n\n";
}
$recent_context = self::get_recent_articles_context();
$recent_block = '' !== $recent_context ? $recent_context . "\n\n" : '';
if ( '' !== $recent_context ) {
$prompt .= $recent_context . "\n\n";
}
$instructions = trim( (string) get_option( 'prautoblogger_analysis_instructions', '' ) );
$instructions_block = '' !== $instructions ? "Additional instructions:\n" . $instructions . "\n\n" : '';
// Append user-defined analysis instructions if configured.
$instructions = trim( (string) get_option( 'prautoblogger_analysis_instructions', '' ) );
if ( '' !== $instructions ) {
$prompt .= "Additional instructions:\n" . $instructions . "\n\n";
}
return PRAutoBlogger_Prompt_Registry::render(
'analysis.system',
array(
'niche_clause' => '' !== $niche ? " in the {$niche} niche" : '',
'target_count' => (string) $target_count,
'performance_block' => $performance_block,
'recent_articles_block' => $recent_block,
'instructions_block' => $instructions_block,
)
);
$prompt .= "Respond with valid JSON containing exactly {$target_count} patterns:\n";
$prompt .= '{"patterns": [{"type": "...", "topic": "...", "summary": "...", "frequency": N, "relevance_score": 0.X, "suggested_title": "...", "key_points": [...], "target_keywords": [...]}]}';
return $prompt;
}
/**
@@ -56,13 +83,9 @@ class PRAutoBlogger_Analysis_Prompts {
* @return string User prompt text.
*/
public static function build_user_prompt( string $summary, int $target_count = 6 ): string {
return PRAutoBlogger_Prompt_Registry::render(
'analysis.user',
array(
'target_count' => (string) $target_count,
'summary' => $summary,
)
);
return 'Here are the recent social media posts and comments to analyze. '
. "Find {$target_count} distinct, diverse article ideas from this data:\n\n"
. $summary;
}
/**
@@ -76,9 +99,9 @@ class PRAutoBlogger_Analysis_Prompts {
global $wpdb;
$scores_table = $wpdb->prefix . 'prautoblogger_content_scores';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$top_posts = $wpdb->get_results(
"SELECT cs.post_id, cs.composite_score, p.post_title
"SELECT cs.post_id, cs.composite_score, p.post_title // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
FROM {$scores_table} cs
JOIN {$wpdb->posts} p ON p.ID = cs.post_id
WHERE cs.composite_score > 0

View File

@@ -9,15 +9,8 @@ declare(strict_types=1);
* Extracted from Pipeline_Runner so each chained cron job has a focused,
* single-responsibility worker. One worker = one article = one PHP process.
*
* v0.18.0: per-article stage state (run_stages, item-scoped) makes resume
* idempotent — an already-published item is skipped outright, a done
* review is reused from its snapshot, and the writer reuses done LLM
* stages via Content_Generator::set_run_item(). Editorial verdicts and
* the sources that fed the article are persisted to the audit tables.
*
* Triggered by: Pipeline_Runner (directly for article 1, via cron for 2..N).
* Dependencies: Content_Generator, Chief_Editor, Publisher, Cost_Tracker,
* Run_Stage_State, Audit_Writer.
* Dependencies: Content_Generator, Chief_Editor, Publisher, Cost_Tracker.
*
* Opik instrumentation: Initializes trace context at start, creates spans
* for content generation and editorial review. Feature-flag gated (default off).
@@ -55,27 +48,6 @@ class PRAutoBlogger_Article_Worker {
* @return array{generated: int, published: int, rejected: int, cost: float}
*/
public function generate( PRAutoBlogger_Article_Idea $idea ): array {
$run_id = (string) ( $this->cost_tracker->get_run_id() ?? '' );
$item = PRAutoBlogger_Run_Stage_State::item_key_for_idea( $idea );
$result = array(
'generated' => 0,
'published' => 0,
'rejected' => 0,
'cost' => 0.0,
);
// Idempotent resume: this item already completed its publish stage
// in this run — re-dispatching it must not create a second post or
// charge a second cent.
if ( '' !== $run_id && PRAutoBlogger_Run_Stage_State::is_done( $run_id, 'publish', '', $item ) ) {
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Skipping "%s" — already completed in run %s (idempotent resume).', $idea->get_topic(), $run_id ),
'pipeline'
);
return $result;
}
// Initialize Opik trace context for this article generation.
$opik = $this->should_trace_with_opik();
if ( $opik ) {
@@ -84,7 +56,6 @@ class PRAutoBlogger_Article_Worker {
$llm = new PRAutoBlogger_OpenRouter_Provider();
$generator = new PRAutoBlogger_Content_Generator( $llm, $this->cost_tracker );
$generator->set_run_item( '' !== $run_id ? $run_id : null, $item );
$editor = new PRAutoBlogger_Chief_Editor( $llm, $this->cost_tracker );
$publisher = new PRAutoBlogger_Publisher();
@@ -94,16 +65,22 @@ class PRAutoBlogger_Article_Worker {
true
);
$result = array(
'generated' => 0,
'published' => 0,
'rejected' => 0,
'cost' => 0.0,
);
try {
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Generating article draft via AI…', 'prautoblogger' ) );
$this->broadcast_stage( __( 'Generating article draft via AI…', 'prautoblogger' ) );
$content = $generator->generate( $idea );
$result['generated'] = 1;
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Running editorial pass…', 'prautoblogger' ) );
$review = $this->review_with_state( $editor, $content, $idea, $run_id, $item );
$this->broadcast_stage( __( 'Running editorial pass…', 'prautoblogger' ) );
$review = $editor->review( $content, $idea );
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Saving and publishing…', 'prautoblogger' ) );
PRAutoBlogger_Run_Stage_State::start( $run_id, 'publish', (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( 'publish' ), $item );
$this->broadcast_stage( __( 'Saving and publishing…', 'prautoblogger' ) );
$this->publish_or_draft(
$content,
$idea,
@@ -112,13 +89,7 @@ class PRAutoBlogger_Article_Worker {
$auto_publish,
$result
);
PRAutoBlogger_Run_Stage_State::done( $run_id, 'publish', '', $item );
PRAutoBlogger_Audit_Writer::record_idea_sources( $run_id, $idea );
} catch ( \Throwable $e ) {
if ( '' !== $run_id ) {
PRAutoBlogger_Run_Stage_State::fail_open_for_item( $run_id, $item );
}
PRAutoBlogger_Logger::instance()->error(
sprintf(
'Article generation %s for "%s": %s',
@@ -140,63 +111,6 @@ class PRAutoBlogger_Article_Worker {
return $result;
}
/**
* Editorial review with stage state: a done review is reused from its
* snapshot (never re-charged); a fresh review is checkpointed and its
* verdict recorded to the run_decisions audit table.
*
* Side effects: run_stages upserts, one run_decisions insert, and —
* when not resuming — one LLM call via Chief_Editor.
*
* @param PRAutoBlogger_Chief_Editor $editor Editor agent.
* @param string $content Generated HTML content.
* @param PRAutoBlogger_Article_Idea $idea The idea under review.
* @param string $run_id Run UUID ('' outside a run).
* @param string $item Stage item key for this idea.
* @return PRAutoBlogger_Editorial_Review
*/
private function review_with_state(
PRAutoBlogger_Chief_Editor $editor,
string $content,
PRAutoBlogger_Article_Idea $idea,
string $run_id,
string $item
): PRAutoBlogger_Editorial_Review {
if ( '' !== $run_id ) {
$snapshot = PRAutoBlogger_Run_Stage_State::get_output( $run_id, 'review', '', $item );
if ( null !== $snapshot ) {
$data = json_decode( $snapshot, true );
if ( is_array( $data ) && isset( $data['verdict'] ) ) {
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Reusing completed review for "%s" (idempotent resume).', $idea->get_topic() ),
'pipeline'
);
return new PRAutoBlogger_Editorial_Review( $data );
}
}
PRAutoBlogger_Run_Stage_State::start( $run_id, 'review', (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( 'review' ), $item );
}
$review = $editor->review( $content, $idea );
if ( '' !== $run_id ) {
$snapshot = (string) wp_json_encode(
array(
'verdict' => $review->get_verdict(),
'notes' => $review->get_notes(),
'revised_content' => $review->get_revised_content(),
'quality_score' => $review->get_quality_score(),
'seo_score' => $review->get_seo_score(),
'issues' => $review->get_issues(),
)
);
PRAutoBlogger_Run_Stage_State::done( $run_id, 'review', '', $item, $snapshot );
PRAutoBlogger_Audit_Writer::record_decision( $run_id, 'review', $review->get_verdict(), $review->get_notes() );
}
return $review;
}
/**
* Publish approved content or save as draft.
*
@@ -239,6 +153,21 @@ class PRAutoBlogger_Article_Worker {
}
}
/**
* Update the generation status transient with the current stage.
*
* @param string $stage Human-readable stage description.
*/
private function broadcast_stage( string $stage ): void {
$transient_key = 'prautoblogger_generation_status';
$current = get_transient( $transient_key );
if ( is_array( $current ) && 'running' === ( $current['status'] ?? '' ) ) {
$current['stage'] = $stage;
$current['last_updated'] = time();
set_transient( $transient_key, $current, 600 );
}
}
/**
* Check if Opik tracing is enabled and has credentials.
*

View File

@@ -1,132 +0,0 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Writes run-scoped audit rows: sources considered + stage decisions.
*
* What: Thin, self-healing insert layer over the v0.18.0 audit child
* tables. `record_decision()` persists a stage verdict (Phase 1:
* the chief editor's review verdict; Phase 2 adds curate/editorial/
* seo decisions and citation_score). `record_idea_sources()`
* consolidates which collected sources fed a generated article —
* the run_sources replacement for the historical source_ids_json /
* `_prautoblogger_research_sources` scatter — for NEW runs only (no
* backfill). Every method no-ops when the tables are missing.
* Who triggers it: Article_Worker (decision after review, sources after
* publish), Phase-2 agents.
* Dependencies: WordPress $wpdb, Run_State (availability probe pattern).
*
* @see core/class-article-worker.php — Phase-1 call sites.
* @see ARCHITECTURE.md #21 — Audit substrate design.
*/
class PRAutoBlogger_Audit_Writer {
/** @var bool|null Per-request "audit tables exist" probe result. */
private static ?bool $tables_ok = null;
/**
* Whether the audit child tables exist. Cached per request; never throws.
*
* @return bool
*/
public static function is_available(): bool {
if ( null !== self::$tables_ok ) {
return self::$tables_ok;
}
global $wpdb;
if ( null === $wpdb ) {
return false; // Not cached: $wpdb may appear later in boot.
}
$table = $wpdb->prefix . 'prautoblogger_run_decisions';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
self::$tables_ok = ( $found === $table );
return self::$tables_ok;
}
/**
* Persist a stage decision for a run.
*
* @param string $run_id Run UUID.
* @param string $stage Deciding stage (see Stage_Display_Map).
* @param string $verdict e.g. 'approved', 'revised', 'rejected'.
* @param string $rationale Why (editor notes, governor message, …).
* @param float|null $citation_score Phase-2 citation score (null in Phase 1).
* @return void
*/
public static function record_decision( string $run_id, string $stage, string $verdict, string $rationale = '', ?float $citation_score = null ): void {
if ( '' === $run_id || ! self::is_available() ) {
return;
}
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->insert(
$wpdb->prefix . 'prautoblogger_run_decisions',
array(
'run_id' => $run_id,
'stage' => $stage,
'verdict' => $verdict,
'rationale' => $rationale,
'citation_score' => $citation_score,
'created_at' => current_time( 'mysql' ),
)
);
}
/**
* Record the collected sources that fed a generated article (kept = 1;
* Phase-2 curation adds discard rows with reasons + quality scores).
*
* Resolves the idea's source_data ids to permalinks in one query.
*
* @param string $run_id Run UUID.
* @param PRAutoBlogger_Article_Idea $idea The generated idea.
* @return void
*/
public static function record_idea_sources( string $run_id, PRAutoBlogger_Article_Idea $idea ): void {
if ( '' === $run_id || ! self::is_available() ) {
return;
}
$source_ids = array_filter( array_map( 'intval', $idea->get_source_ids() ) );
if ( empty( $source_ids ) ) {
return;
}
global $wpdb;
$source_table = $wpdb->prefix . 'prautoblogger_source_data';
$placeholders = implode( ',', array_fill( 0, count( $source_ids ), '%d' ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared
$rows = $wpdb->get_results(
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $placeholders is a fixed %d list.
$wpdb->prepare( "SELECT id, source_type, permalink FROM {$source_table} WHERE id IN ({$placeholders})", $source_ids ),
ARRAY_A
);
if ( ! is_array( $rows ) ) {
return;
}
foreach ( $rows as $row ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->insert(
$wpdb->prefix . 'prautoblogger_run_sources',
array(
'run_id' => $run_id,
'agent_role' => 'analyst',
'source_url' => (string) ( $row['permalink'] ?? '' ),
'doi' => null,
'kept' => 1,
'reason' => sprintf( 'Cited by analyzer for "%s" (%s #%d)', $idea->get_topic(), (string) $row['source_type'], (int) $row['id'] ),
'quality_score' => null,
'created_at' => current_time( 'mysql' ),
)
);
}
}
/** Reset per-request caches (tests). */
public static function flush_cache(): void {
self::$tables_ok = null;
}
}

74
includes/core/class-chief-editor.php Normal file → Executable file
View File

@@ -9,7 +9,7 @@ declare(strict_types=1);
* Opik instrumentation: spans per review LLM call.
*
* Triggered by: PRAutoBlogger_Article_Worker.
* Dependencies: LLM_Provider_Interface, Cost_Tracker, Prompt_Registry.
* Dependencies: LLM_Provider_Interface, Cost_Tracker.
*/
class PRAutoBlogger_Chief_Editor {
@@ -64,7 +64,6 @@ class PRAutoBlogger_Chief_Editor {
'temperature' => 0.3,
'max_tokens' => 5000,
'response_format' => array( 'type' => 'json_object' ),
'stage' => 'review',
)
);
@@ -93,47 +92,54 @@ class PRAutoBlogger_Chief_Editor {
/**
* Build system prompt for editorial review.
*
* v0.18.0: renders from the versioned registry ('editor.system') with a
* byte-identical in-code fallback; the admin's extra editor
* instructions stay a setting and are injected as a token value.
*
* @param string $niche Niche description from settings.
* @return string System prompt text.
*/
private function build_system_prompt( string $niche ): string {
$instructions = trim( (string) get_option( 'prautoblogger_editor_instructions', '' ) );
$instructions_block = '' !== $instructions ? "\nAdditional instructions:\n" . $instructions . "\n" : '';
$prompt = 'You are a senior blog editor';
if ( '' !== $niche ) {
$prompt .= " specializing in {$niche} content";
}
$prompt .= ". Review article drafts before publication.\n\n";
return PRAutoBlogger_Prompt_Registry::render(
'editor.system',
array(
'niche_clause' => '' !== $niche ? " specializing in {$niche} content" : '',
'instructions_block' => $instructions_block,
)
);
$prompt .= "Evaluate on: QUALITY, ACCURACY, SEO, COMPLETENESS, READABILITY.\n";
$prompt .= "Respond with JSON: {\n";
$prompt .= ' "verdict": "approved" | "revised" | "rejected",' . "\n";
$prompt .= ' "quality_score": 0.0-1.0,' . "\n";
$prompt .= ' "seo_score": 0.0-1.0,' . "\n";
$prompt .= ' "issues": ["issue1", "issue2"],' . "\n";
$prompt .= ' "notes": "Editorial notes",' . "\n";
$prompt .= ' "revised_content": "Full revised HTML if revised, null otherwise"' . "\n";
$prompt .= "}\n\n";
$prompt .= "Rules:\n";
$prompt .= "- APPROVE if quality_score >= 0.7 and seo_score >= 0.6\n";
$prompt .= "- REVISE if fixable issues exist — provide full revised HTML\n";
$prompt .= "- REJECT if fundamentally flawed\n";
$prompt .= "- Preserve formatting, links, lists when revising\n";
$instructions = trim( (string) get_option( 'prautoblogger_editor_instructions', '' ) );
if ( '' !== $instructions ) {
$prompt .= "\nAdditional instructions:\n" . $instructions . "\n";
}
return $prompt;
}
/**
* Build user prompt for editorial review.
*
* v0.18.0: renders from the versioned registry ('editor.review').
*
* @param string $content The generated HTML content.
* @param PRAutoBlogger_Article_Idea $idea The original article idea.
* @return string User prompt text.
*/
private function build_review_prompt( string $content, PRAutoBlogger_Article_Idea $idea ): string {
return PRAutoBlogger_Prompt_Registry::render(
'editor.review',
array(
'title' => $idea->get_suggested_title(),
'topic' => $idea->get_topic(),
'article_type' => $idea->get_article_type(),
'key_points' => implode( ', ', $idea->get_key_points() ),
'keywords' => implode( ', ', $idea->get_target_keywords() ),
'content' => $content,
)
return sprintf(
"Review this article draft:\n\n" .
"BRIEF: Title: %s | Topic: %s | Type: %s\n" .
"KEY POINTS: %s\n" .
"TARGET KEYWORDS: %s\n\n" .
"CONTENT:\n%s",
$idea->get_suggested_title(),
$idea->get_topic(),
$idea->get_article_type(),
implode( ', ', $idea->get_key_points() ),
implode( ', ', $idea->get_target_keywords() ),
$content
);
}

View File

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

373
includes/core/class-content-generator.php Normal file → Executable file
View File

@@ -6,10 +6,6 @@ declare(strict_types=1);
*
* Supports: single_pass (one LLM call) or multi_step (outline → draft → polish).
* Opik instrumentation: spans per LLM call when feature-flag enabled.
* v0.18.0: prompt copy renders from the versioned prompt registry (with
* byte-identical in-code fallback); every log row carries the run-pinned
* prompt_version. The four stage methods share one execute_stage() helper —
* span lifecycle, LLM dispatch, and cost logging are identical across stages.
*
* Triggered by: PRAutoBlogger_Article_Worker.
* Dependencies: LLM_Provider_Interface, Cost_Tracker, Content_Prompts.
@@ -19,12 +15,6 @@ class PRAutoBlogger_Content_Generator {
private PRAutoBlogger_LLM_Provider_Interface $llm;
private PRAutoBlogger_Cost_Tracker $cost_tracker;
/** @var string|null Run UUID for stage-state checkpointing (null = off). */
private ?string $run_id = null;
/** @var string|null Stage item key scoping this article within the run. */
private ?string $item_key = null;
public function __construct(
PRAutoBlogger_LLM_Provider_Interface $llm,
PRAutoBlogger_Cost_Tracker $cost_tracker
@@ -33,30 +23,10 @@ class PRAutoBlogger_Content_Generator {
$this->cost_tracker = $cost_tracker;
}
/**
* Enable per-stage state checkpointing for one article item (v0.18.0).
*
* When set, every LLM stage (outline/draft/polish or the single-pass
* draft) records pending->running->done state and snapshots its output;
* a re-entered done stage returns the snapshot WITHOUT a new LLM call
* (never re-charged). When unset (default), behavior is exactly
* pre-v0.18.0 — used by callers outside a run (e.g. eval mode).
*
* @param string|null $run_id Run UUID, or null to disable.
* @param string|null $item_key Item key from Run_Stage_State::item_key_for_idea().
* @return $this
*/
public function set_run_item( ?string $run_id, ?string $item_key ): self {
$this->run_id = $run_id;
$this->item_key = $item_key;
return $this;
}
/**
* Generate a blog post from an article idea.
*
* @param PRAutoBlogger_Article_Idea $idea The scored idea to generate content for.
* @param bool $eval_mode True to suppress publish/image side effects.
* @param PRAutoBlogger_Article_Idea $idea The scored idea to generate content for.
* @return string Generated HTML content.
*/
public function generate( PRAutoBlogger_Article_Idea $idea, bool $eval_mode = false ): string {
@@ -82,132 +52,15 @@ class PRAutoBlogger_Content_Generator {
}
/**
* Single-pass: one LLM call produces complete article (Economy tier).
*
* The log row's stage stays 'draft' (historical contract: a draft row
* without an outline row marks a single-pass run) but the prompt key
* is passed explicitly — the stage map would resolve 'draft' to
* 'content.draft'.
*
* @param PRAutoBlogger_Content_Request $request Generation request.
* @return string Generated HTML content.
* Single-pass: one LLM call produces complete article.
*/
private function generate_single_pass( PRAutoBlogger_Content_Request $request ): string {
$model = get_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
return $this->execute_stage(
'draft',
'single_pass_generation',
PRAutoBlogger_Content_Prompts::build_single_pass( $request ),
$request,
$model,
array(
'temperature' => 0.7,
'max_tokens' => 4000,
),
'content.single_pass'
);
}
/**
* Multi-step: outline → draft → polish.
*
* @param PRAutoBlogger_Content_Request $request Generation request.
* @return string Generated HTML content.
*/
private function generate_multi_step( PRAutoBlogger_Content_Request $request ): string {
$model = get_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
$outline = $this->execute_stage(
'outline',
'outline_generation',
PRAutoBlogger_Content_Prompts::build_outline( $request ),
$request,
$model,
array(
'temperature' => 0.5,
'max_tokens' => 1500,
)
);
$draft = $this->execute_stage(
'draft',
'draft_generation',
PRAutoBlogger_Content_Prompts::build_draft( $request, $outline ),
$request,
$model,
array(
'temperature' => 0.7,
'max_tokens' => 4000,
)
);
return $this->execute_stage(
'polish',
'polish_generation',
PRAutoBlogger_Content_Prompts::build_polish( $draft ),
$request,
$model,
array(
'temperature' => 0.4,
'max_tokens' => 4000,
)
);
}
/**
* Run one writing stage: Opik span → LLM call → span close → cost log.
*
* Consolidates the per-stage boilerplate that was previously duplicated
* across the four stage methods (v0.18.0). The system prompt is rebuilt
* per stage exactly as before.
*
* Side effects: one OpenRouter API call, one Opik span (when tracing),
* one generation_log row.
*
* @param string $stage Log stage value ('outline'|'draft'|'polish').
* @param string $span_name Opik span name.
* @param string $user_prompt Rendered user prompt for the stage.
* @param PRAutoBlogger_Content_Request $request Generation request (system prompt input).
* @param string $model Model identifier.
* @param array<string, mixed> $options LLM options (temperature, max_tokens).
* @param string|null $prompt_key Explicit registry key for prompt_version
* stamping (null = derive from stage).
* @return string Stage output content.
*/
private function execute_stage(
string $stage,
string $span_name,
string $user_prompt,
PRAutoBlogger_Content_Request $request,
string $model,
array $options,
?string $prompt_key = null
): string {
// Idempotent resume: a done stage returns its snapshot, no LLM call.
if ( null !== $this->run_id && null !== $this->item_key ) {
$cached = PRAutoBlogger_Run_Stage_State::get_output( $this->run_id, $stage, '', $this->item_key );
if ( null !== $cached ) {
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Reusing completed %s stage output (idempotent resume).', $stage ),
'pipeline'
);
return $cached;
}
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(
array(
'name' => $span_name,
'name' => 'single_pass_generation',
'type' => 'llm',
'model' => $model,
'provider' => 'openrouter',
@@ -222,28 +75,16 @@ class PRAutoBlogger_Content_Generator {
),
array(
'role' => 'user',
'content' => $user_prompt,
'content' => PRAutoBlogger_Content_Prompts::build_single_pass( $request ),
),
),
$model,
$options
array(
'temperature' => 0.7,
'max_tokens' => 4000,
)
);
// 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(
@@ -257,27 +98,193 @@ class PRAutoBlogger_Content_Generator {
$this->cost_tracker->log_api_call(
null,
$stage,
'draft',
$this->llm->get_provider_name(),
$response['model'],
$response['prompt_tokens'],
$response['completion_tokens'],
'success',
'',
null,
$prompt_key
$response['completion_tokens']
);
if ( null !== $this->run_id && null !== $this->item_key ) {
PRAutoBlogger_Run_Stage_State::done(
$this->run_id,
$stage,
'',
$this->item_key,
(string) $response['content'],
$this->llm->estimate_cost( $response['model'], (int) $response['prompt_tokens'], (int) $response['completion_tokens'] )
);
}
return $response['content'];
}
/**
* Multi-step: outline → draft → polish.
*/
private function generate_multi_step( PRAutoBlogger_Content_Request $request ): string {
$model = get_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
$outline = $this->stage_outline( $request, $model );
$draft = $this->stage_draft( $request, $outline, $model );
$polished = $this->stage_polish( $request, $draft, $model );
return $polished;
}
/**
* Stage 1: Generate article outline.
*/
private function stage_outline( PRAutoBlogger_Content_Request $request, string $model ): string {
$ctx = PRAutoBlogger_Opik_Trace_Context::current();
$span_id = $ctx->start_span(
array(
'name' => 'outline_generation',
'type' => 'llm',
'model' => $model,
'provider' => 'openrouter',
)
);
$response = $this->llm->send_chat_completion(
array(
array(
'role' => 'system',
'content' => PRAutoBlogger_Content_Prompts::build_system( $request ),
),
array(
'role' => 'user',
'content' => PRAutoBlogger_Content_Prompts::build_outline( $request ),
),
),
$model,
array(
'temperature' => 0.5,
'max_tokens' => 1500,
)
);
$ctx->end_span(
$span_id,
array(
'usage' => array(
'prompt_tokens' => $response['prompt_tokens'],
'completion_tokens' => $response['completion_tokens'],
'total_tokens' => $response['prompt_tokens'] + $response['completion_tokens'],
),
)
);
$this->cost_tracker->log_api_call(
null,
'outline',
$this->llm->get_provider_name(),
$response['model'],
$response['prompt_tokens'],
$response['completion_tokens']
);
return $response['content'];
}
/**
* Stage 2: Write full draft from outline.
*/
private function stage_draft( PRAutoBlogger_Content_Request $request, string $outline, string $model ): string {
$ctx = PRAutoBlogger_Opik_Trace_Context::current();
$span_id = $ctx->start_span(
array(
'name' => 'draft_generation',
'type' => 'llm',
'model' => $model,
'provider' => 'openrouter',
)
);
$response = $this->llm->send_chat_completion(
array(
array(
'role' => 'system',
'content' => PRAutoBlogger_Content_Prompts::build_system( $request ),
),
array(
'role' => 'user',
'content' => PRAutoBlogger_Content_Prompts::build_draft( $request, $outline ),
),
),
$model,
array(
'temperature' => 0.7,
'max_tokens' => 4000,
)
);
$ctx->end_span(
$span_id,
array(
'usage' => array(
'prompt_tokens' => $response['prompt_tokens'],
'completion_tokens' => $response['completion_tokens'],
'total_tokens' => $response['prompt_tokens'] + $response['completion_tokens'],
),
)
);
$this->cost_tracker->log_api_call(
null,
'draft',
$this->llm->get_provider_name(),
$response['model'],
$response['prompt_tokens'],
$response['completion_tokens']
);
return $response['content'];
}
/**
* Stage 3: Polish and refine draft.
*/
private function stage_polish( PRAutoBlogger_Content_Request $request, string $draft, string $model ): string {
$ctx = PRAutoBlogger_Opik_Trace_Context::current();
$span_id = $ctx->start_span(
array(
'name' => 'polish_generation',
'type' => 'llm',
'model' => $model,
'provider' => 'openrouter',
)
);
$response = $this->llm->send_chat_completion(
array(
array(
'role' => 'system',
'content' => PRAutoBlogger_Content_Prompts::build_system( $request ),
),
array(
'role' => 'user',
'content' => PRAutoBlogger_Content_Prompts::build_polish( $draft ),
),
),
$model,
array(
'temperature' => 0.4,
'max_tokens' => 4000,
)
);
$ctx->end_span(
$span_id,
array(
'usage' => array(
'prompt_tokens' => $response['prompt_tokens'],
'completion_tokens' => $response['completion_tokens'],
'total_tokens' => $response['prompt_tokens'] + $response['completion_tokens'],
),
)
);
$this->cost_tracker->log_api_call(
null,
'polish',
$this->llm->get_provider_name(),
$response['model'],
$response['prompt_tokens'],
$response['completion_tokens']
);
return $response['content'];
}

View File

@@ -11,8 +11,7 @@ declare(strict_types=1);
* independently of the generation orchestration.
*
* Triggered by: PRAutoBlogger_Content_Generator (called for every stage).
* Dependencies: PRAutoBlogger_Prompt_Registry (versioned prompt bodies),
* WordPress query functions (get_posts, get_the_title, get_permalink).
* Dependencies: WordPress query functions (get_posts, get_the_title, get_permalink).
*
* @see core/class-content-generator.php — Consumer of these prompts.
* @see models/class-content-request.php — Data bag passed to every builder.
@@ -25,22 +24,18 @@ class PRAutoBlogger_Content_Prompts {
* Includes niche, tone, mandatory style guide, internal link reference,
* peptide database links, and the "never fabricate URLs" rule.
*
* v0.18.0: the prompt copy renders from the versioned registry
* ('content.system', falls back to the identical in-code default);
* the style guide and linking rules are data injection and stay here.
*
* @param PRAutoBlogger_Content_Request $request Content request with settings.
* @return string Complete system prompt.
*/
public static function build_system( PRAutoBlogger_Content_Request $request ): string {
$niche = $request->get_niche_description();
$prompt = PRAutoBlogger_Prompt_Registry::render(
'content.system',
array(
'niche_clause' => '' !== $niche ? " specializing in {$niche}" : '',
'tone' => $request->get_tone(),
)
);
$prompt = 'You are an expert blog writer';
if ( '' !== $niche ) {
$prompt .= " specializing in {$niche}";
}
$prompt .= '. Write well-researched, engaging, SEO-friendly content. ';
$prompt .= "Use a {$request->get_tone()} tone. ";
$prompt .= 'Output HTML content only — no markdown, no code fences, no commentary.';
// Append user-defined writing instructions as a mandatory style guide.
$instructions = trim( $request->get_writing_instructions() );
@@ -65,17 +60,24 @@ class PRAutoBlogger_Content_Prompts {
public static function build_single_pass( PRAutoBlogger_Content_Request $request ): string {
$idea = $request->get_idea();
return PRAutoBlogger_Prompt_Registry::render(
'content.single_pass',
array(
'title' => $idea->get_suggested_title(),
'topic' => $idea->get_topic(),
'article_type' => $idea->get_article_type(),
'key_points' => implode( "\n- ", $idea->get_key_points() ),
'keywords' => implode( ', ', $idea->get_target_keywords() ),
'min_words' => (string) $request->get_min_word_count(),
'max_words' => (string) $request->get_max_word_count(),
)
return sprintf(
"Write a complete blog post in HTML format.\n\n" .
"Title: %s\nTopic: %s\nType: %s\n\nKey points:\n- %s\n\n" .
"Keywords: %s\n\n" .
"Requirements:\n" .
"- %d-%d words\n" .
"- Proper HTML (h2, h3, p, ul/li)\n" .
"- Engaging intro, strong conclusion with CTA\n" .
"- Do NOT include the title or <html>/<body> tags\n" .
"- Output HTML only, no markdown or commentary\n" .
'- Follow EVERY formatting and structural requirement from your system prompt style guide',
$idea->get_suggested_title(),
$idea->get_topic(),
$idea->get_article_type(),
implode( "\n- ", $idea->get_key_points() ),
implode( ', ', $idea->get_target_keywords() ),
$request->get_min_word_count(),
$request->get_max_word_count()
);
}
@@ -88,17 +90,21 @@ class PRAutoBlogger_Content_Prompts {
public static function build_outline( PRAutoBlogger_Content_Request $request ): string {
$idea = $request->get_idea();
return PRAutoBlogger_Prompt_Registry::render(
'content.outline',
array(
'title' => $idea->get_suggested_title(),
'topic' => $idea->get_topic(),
'article_type' => $idea->get_article_type(),
'key_points' => implode( "\n- ", $idea->get_key_points() ),
'keywords' => implode( ', ', $idea->get_target_keywords() ),
'min_words' => (string) $request->get_min_word_count(),
'max_words' => (string) $request->get_max_word_count(),
)
return sprintf(
"Create a detailed outline for a blog post titled: \"%s\"\n\n" .
"Topic: %s\nArticle type: %s\n\nKey points to cover:\n%s\n\n" .
"Target keywords: %s\n\n" .
'The outline should have 4-6 main sections with bullet points under each. ' .
'Include an introduction hook and a conclusion with a call to action. ' .
"Word count target: %d-%d words.\n\n" .
'Plan the structure to satisfy EVERY requirement in your system prompt style guide.',
$idea->get_suggested_title(),
$idea->get_topic(),
$idea->get_article_type(),
implode( "\n- ", $idea->get_key_points() ),
implode( ', ', $idea->get_target_keywords() ),
$request->get_min_word_count(),
$request->get_max_word_count()
);
}
@@ -110,15 +116,23 @@ class PRAutoBlogger_Content_Prompts {
* @return string Draft prompt.
*/
public static function build_draft( PRAutoBlogger_Content_Request $request, string $outline ): string {
return PRAutoBlogger_Prompt_Registry::render(
'content.draft',
array(
'outline' => $outline,
'tone' => $request->get_tone(),
'min_words' => (string) $request->get_min_word_count(),
'max_words' => (string) $request->get_max_word_count(),
'keywords' => implode( ', ', $request->get_idea()->get_target_keywords() ),
)
return sprintf(
"Using this outline, write the full blog post in HTML format.\n\n" .
"OUTLINE:\n%s\n\n" .
"Requirements:\n" .
"- Write in a %s tone\n" .
"- Target %d-%d words\n" .
"- Use proper HTML headings (h2, h3), paragraphs, and lists\n" .
"- Include an engaging introduction and strong conclusion\n" .
"- Naturally incorporate these keywords: %s\n" .
"- Do NOT include the title in the HTML (it will be set separately)\n" .
"- Do NOT wrap in <html>, <head>, or <body> tags — just the article content\n" .
'- Follow EVERY formatting and structural requirement from your system prompt style guide',
$outline,
$request->get_tone(),
$request->get_min_word_count(),
$request->get_max_word_count(),
implode( ', ', $request->get_idea()->get_target_keywords() )
);
}
@@ -129,10 +143,18 @@ class PRAutoBlogger_Content_Prompts {
* @return string Polish prompt.
*/
public static function build_polish( string $draft ): string {
return PRAutoBlogger_Prompt_Registry::render(
'content.polish',
array( 'draft' => $draft )
);
return "Review and polish this blog post draft. Improve:\n" .
"1. Flow and readability\n" .
"2. SEO optimization (headings, keyword placement)\n" .
"3. Engagement (hooks, transitions, call-to-action)\n" .
"4. Accuracy and clarity\n" .
"5. Remove any filler or redundant sentences\n\n" .
'IMPORTANT: Preserve all bullet points, numbered lists, hyperlinks, and ' .
'structural elements from the draft. Do NOT flatten lists into prose or ' .
'remove links. Ensure every requirement from your system prompt style ' .
"guide is satisfied in the final output.\n\n" .
"Return the polished HTML content only. Do not add commentary.\n\n" .
"DRAFT:\n" . $draft;
}
/**

View File

@@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Thrown when a reserve-before-call would push a run past its cost ceiling.
*
* What: Dedicated exception type so call paths can distinguish "this run
* was halted by the per-run cost governor" from generic API
* failures. By the time it is thrown the governor has already
* marked the run `halted`, recorded the overage on the runs row,
* and logged — catchers must NOT dispatch the call they were about
* to make, and chained jobs abort via the run-status check in
* Pipeline_Runner::process_next_queued_article().
* Who triggers it: PRAutoBlogger_Cost_Governor on a failed reservation.
* Dependencies: none (extends RuntimeException).
*
* @see core/class-cost-governor.php — Sole thrower.
* @see core/class-pipeline-runner.php — Aborts queued articles of halted runs.
*/
class PRAutoBlogger_Cost_Ceiling_Exception extends \RuntimeException {
}

View File

@@ -1,239 +0,0 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Per-run cost governor — reserve-before-call against the run ledger.
*
* What: Net-new enforcement (the monthly Cost_Tracker cap only bounds the
* month; `get_current_run_cost()` was an unguarded accumulator).
* Before any governed call dispatches, its worst-case cost estimate
* is atomically added to the run's `reserved_usd` via a single
* conditional UPDATE — the same atomic-conditional-write discipline
* as PRAutoBlogger_Generation_Lock (#10) — so concurrent writers
* (curl_multi fan-outs, chained jobs) cannot slip past the ceiling
* between check and call. Batches reserve their SUMMED estimate
* before dispatch. After each response the reservation settles to
* the actual cost, so a cheap call never holds its worst case.
* Breach: the run is marked `halted` (sticky), the overage is
* recorded on the runs row, the Review Queue surfaces it, and a
* Cost_Ceiling_Exception aborts the un-dispatched call. Standalone
* calls outside any run (no run context) are not run-governed —
* exactly the historical behavior; the monthly cap and the
* Cloudflare AI Gateway path are untouched everywhere.
* Who triggers it: PRAutoBlogger_OpenRouter_Provider::send_chat_completion
* (every text-LLM call path), PRAutoBlogger_Image_Pipeline (image
* curl_multi batch).
* Dependencies: Run_Context (which run is this process in),
* Run_State (ledger row + halt), OpenRouter_Pricing (#18 chain),
* WordPress $wpdb, Logger.
*
* @see class-generation-lock.php — The atomic-write pattern this mirrors.
* @see core/class-run-state.php — The ledger row reserved against.
* @see providers/class-open-router-provider.php — Text-call integration point.
* @see core/class-image-pipeline.php — Batch reservation integration point.
* @see ARCHITECTURE.md #21 — Design rationale.
*/
class PRAutoBlogger_Cost_Governor {
/**
* Rough chars-per-token divisor for prompt-size estimation. An internal
* estimator detail (NOT a behavior setting — the enforced ceiling is
* the prautoblogger_per_run_cost_ceiling_usd SETTING).
*/
private const CHARS_PER_TOKEN = 4;
/**
* Worst-case completion tokens assumed when a caller sets no
* max_tokens. Every current call site sets one; this is belt-and-braces.
*/
private const DEFAULT_MAX_COMPLETION_TOKENS = 4096;
/**
* Reserve the worst-case cost of one chat-completion call for the
* process's current run. Call before dispatch.
*
* Returns null (call proceeds ungoverned, exactly as pre-v0.18.0) when
* this process is not inside a run, the run row/ledger is unavailable
* (half-migrated schema), or the run's ceiling is 0/disabled.
*
* @param string $model Model identifier.
* @param array<int, array{role: string, content: string}> $messages Chat messages (prompt-size estimate).
* @param array<string, mixed> $options Call options (max_tokens).
* @return array{run_id: string, amount: float}|null Open reservation, or null when ungoverned.
* @throws PRAutoBlogger_Cost_Ceiling_Exception When the reservation would breach the ceiling
* (the run is already halted when this throws).
*/
public static function open_chat_reservation( string $model, array $messages, array $options ): ?array {
return self::open_amount_reservation(
self::estimate_chat_cost( $model, $messages, $options ),
'llm:' . $model
);
}
/**
* Reserve a pre-computed worst-case amount (e.g. a curl_multi image
* batch's summed estimate) for the process's current run.
*
* @param float $amount_usd Worst-case cost estimate in USD.
* @param string $context Short label for logs ('llm:<model>', 'image_batch').
* @return array{run_id: string, amount: float}|null Open reservation, or null when ungoverned.
* @throws PRAutoBlogger_Cost_Ceiling_Exception When the reservation would breach the ceiling.
*/
public static function open_amount_reservation( float $amount_usd, string $context ): ?array {
$run_id = PRAutoBlogger_Run_Context::current_run_id();
if ( null === $run_id || '' === $run_id || $amount_usd <= 0 ) {
return null;
}
if ( ! PRAutoBlogger_Run_State::is_available() ) {
return null; // Half-migrated schema — degrade to historical behavior.
}
PRAutoBlogger_Run_State::ensure_run( $run_id );
$run = PRAutoBlogger_Run_State::get_run( $run_id );
if ( null === $run ) {
return null;
}
if ( (float) $run['ceiling_usd'] <= 0 ) {
return null; // Ceiling disabled for this run.
}
if ( self::reserve( $run_id, $amount_usd ) ) {
return array(
'run_id' => $run_id,
'amount' => $amount_usd,
);
}
self::on_breach( $run_id, $amount_usd, $context );
return null; // Unreachable — on_breach always throws.
}
/**
* Settle an open reservation to the call's actual cost: the hold is
* released and the actual amount moves to settled_usd.
*
* @param array{run_id: string, amount: float}|null $reservation Reservation from open_*(), or null (no-op).
* @param float $actual_usd Actual cost of the call.
* @return void
*/
public static function settle( ?array $reservation, float $actual_usd ): void {
if ( null === $reservation || ! PRAutoBlogger_Run_State::is_available() ) {
return;
}
global $wpdb;
$table = PRAutoBlogger_Run_State::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->query(
$wpdb->prepare(
"UPDATE {$table}
SET reserved_usd = GREATEST(reserved_usd - %f, 0),
settled_usd = settled_usd + %f,
updated_at = %s
WHERE run_id = %s",
$reservation['amount'],
max( $actual_usd, 0 ),
current_time( 'mysql' ),
$reservation['run_id']
)
);
}
/**
* Release an open reservation without settling cost (failed call).
*
* @param array{run_id: string, amount: float}|null $reservation Reservation from open_*(), or null (no-op).
* @return void
*/
public static function release( ?array $reservation ): void {
self::settle( $reservation, 0.0 );
}
/**
* Worst-case cost estimate for a chat call: estimated prompt tokens
* (message chars / 4) plus the call's max_tokens completion budget,
* priced via the #18 pricing chain (hardcoded table -> cached model
* registry -> conservative fallback).
*
* @param string $model Model identifier.
* @param array<int, array{role: string, content: string}> $messages Chat messages.
* @param array<string, mixed> $options Call options.
* @return float Estimated worst-case USD cost.
*/
public static function estimate_chat_cost( string $model, array $messages, array $options ): float {
$chars = 0;
foreach ( $messages as $message ) {
$chars += strlen( (string) ( $message['content'] ?? '' ) );
}
$prompt_tokens = (int) ceil( $chars / self::CHARS_PER_TOKEN );
$completion_tokens = (int) ( $options['max_tokens'] ?? self::DEFAULT_MAX_COMPLETION_TOKENS );
return ( new PRAutoBlogger_OpenRouter_Pricing() )->estimate_cost( $model, $prompt_tokens, $completion_tokens );
}
/**
* Atomic conditional reservation — the #10 discipline. One UPDATE
* whose WHERE clause enforces `settled + reserved + estimate <=
* ceiling` AND that the run is still open; affected-rows tells us who
* won. Concurrent reservers serialize on the row lock.
*
* @param string $run_id Run UUID.
* @param float $amount Estimate to reserve.
* @return bool True when the reservation was written.
*/
private static function reserve( string $run_id, float $amount ): bool {
global $wpdb;
$table = PRAutoBlogger_Run_State::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$updated = $wpdb->query(
$wpdb->prepare(
"UPDATE {$table}
SET reserved_usd = reserved_usd + %f, updated_at = %s
WHERE run_id = %s
AND status IN ('pending','running')
AND (reserved_usd + settled_usd + %f) <= ceiling_usd",
$amount,
current_time( 'mysql' ),
$run_id,
$amount
)
);
return 1 === (int) $updated;
}
/**
* Handle a failed reservation: halt the run (sticky), record the
* overage, log, throw. The Review Queue lists halted runs; chained
* queue jobs abort via the run-status check.
*
* @param string $run_id Run UUID.
* @param float $amount The estimate that failed to reserve.
* @param string $context Short label for logs.
* @return void
* @throws PRAutoBlogger_Cost_Ceiling_Exception Always.
*/
private static function on_breach( string $run_id, float $amount, string $context ): void {
$run = PRAutoBlogger_Run_State::get_run( $run_id );
$ceiling = null !== $run ? (float) $run['ceiling_usd'] : 0.0;
$held = null !== $run ? (float) $run['reserved_usd'] + (float) $run['settled_usd'] : 0.0;
$overage = max( ( $held + $amount ) - $ceiling, 0.0 );
PRAutoBlogger_Run_State::mark_status( $run_id, 'halted' );
PRAutoBlogger_Run_State::record_overage( $run_id, $overage );
$message = sprintf(
/* translators: 1: blocked amount USD, 2: call context, 3: committed USD, 4: ceiling USD, 5: run id. */
__( 'Per-run cost ceiling reached: reserving $%1$.4f for %2$s would push the run to $%3$.4f against a $%4$.2f ceiling. Run %5$s halted and routed to the Review Queue.', 'prautoblogger' ),
$amount,
$context,
$held + $amount,
$ceiling,
$run_id
);
PRAutoBlogger_Logger::instance()->error( $message, 'cost-governor' );
throw new PRAutoBlogger_Cost_Ceiling_Exception( $message );
}
}

View File

@@ -36,9 +36,8 @@ 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(
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT COALESCE(SUM(estimated_cost), 0) FROM {$table} WHERE created_at >= %s AND response_status = 'success'",
$first_of_month
)
@@ -65,10 +64,9 @@ 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
"SELECT DATE(created_at) as day, SUM(estimated_cost) as total_cost // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
FROM {$table}
WHERE created_at >= %s AND response_status = 'success'
GROUP BY DATE(created_at)
@@ -103,13 +101,12 @@ 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
COUNT(*) as call_count // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
FROM {$table}
WHERE created_at BETWEEN %s AND %s AND response_status = 'success'
GROUP BY stage",
@@ -143,63 +140,4 @@ 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.
*
* Used by the model picker field renderer to calculate estimated costs
* per generation based on historical token usage. Maps setting IDs to their
* constituting stages (e.g., writing model controls outline + draft + polish).
* Moved here from Cost_Tracker in v0.18.0 (read-only reporting query;
* Cost_Tracker keeps a delegating wrapper for API compatibility).
*
* @param array<string> $stages Stage names ('analysis', 'outline', 'draft', 'polish', 'review').
* @param int $days Historical window (default 30 days).
*
* @return array{avg_prompt_tokens: float, avg_completion_tokens: float, sample_size: int}
* Returns empty counters if no history. Never throws.
*/
public function get_avg_tokens_for_stages( array $stages, int $days = 30 ): array {
global $wpdb;
if ( empty( $stages ) ) {
return array(
'avg_prompt_tokens' => 0.0,
'avg_completion_tokens' => 0.0,
'sample_size' => 0,
);
}
$cutoff_datetime = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) );
$stage_list = implode( ',', array_map( array( $wpdb, 'prepare' ), array_fill( 0, count( $stages ), '%s' ), $stages ) );
$table_name = $wpdb->prefix . 'prautoblogger_generation_log';
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Prepared stage list via array_map + prepare
$query = $wpdb->prepare(
"SELECT AVG(prompt_tokens) as avg_prompt,
AVG(completion_tokens) as avg_completion,
COUNT(*) as sample_count
FROM $table_name
WHERE stage IN ( $stage_list )
AND created_at >= %s",
$cutoff_datetime
);
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- phpcs pragma above
$result = $wpdb->get_row( $query, ARRAY_A );
if ( ! $result || 0 === (int) ( $result['sample_count'] ?? 0 ) ) {
return array(
'avg_prompt_tokens' => 0.0,
'avg_completion_tokens' => 0.0,
'sample_size' => 0,
);
}
return array(
'avg_prompt_tokens' => (float) ( $result['avg_prompt'] ?? 0 ),
'avg_completion_tokens' => (float) ( $result['avg_completion'] ?? 0 ),
'sample_size' => (int) ( $result['sample_count'] ?? 0 ),
);
}
}

View File

@@ -47,14 +47,6 @@ class PRAutoBlogger_Cost_Tracker {
*/
public function set_run_id( string $run_id ): void {
$this->run_id = $run_id;
// v0.18.0: declaring a run id makes this PHP process part of that
// run — record the context (cost-governor guard, prompt-version
// stamping for components without a run reference), make sure the
// run ledger row exists, and pin the active prompt versions once.
// All three are self-healing no-ops on a half-migrated schema.
PRAutoBlogger_Run_Context::set_run_id( $run_id );
PRAutoBlogger_Run_State::ensure_run( $run_id );
PRAutoBlogger_Prompt_Registry::pin_for_run( $run_id );
}
/**
@@ -71,20 +63,14 @@ class PRAutoBlogger_Cost_Tracker {
*
* Side effects: database insert into prab_generation_log.
*
* @param int|null $post_id WordPress post ID (null during generation, set later).
* @param string $stage Pipeline stage (see PRAutoBlogger_Stage_Display_Map).
* @param string $provider Provider name: 'OpenRouter'.
* @param string $model Model identifier used.
* @param int $prompt_tokens Input tokens consumed.
* @param int $completion_tokens Output tokens generated.
* @param string $response_status 'success', 'error', or 'timeout'.
* @param string $error_message Error details if status is not 'success'.
* @param string|null $agent_role v0.18.0 — role that made the call; derived
* from the stage map when null.
* @param string|null $prompt_key v0.18.0 — registry key the call rendered
* with (e.g. 'content.single_pass'); derived
* from the stage map when null. Resolves the
* run-pinned prompt_version stamped on the row.
* @param int|null $post_id WordPress post ID (null during generation, set later).
* @param string $stage Pipeline stage: 'analysis', 'outline', 'draft', 'polish', 'review'.
* @param string $provider Provider name: 'OpenRouter'.
* @param string $model Model identifier used.
* @param int $prompt_tokens Input tokens consumed.
* @param int $completion_tokens Output tokens generated.
* @param string $response_status 'success', 'error', or 'timeout'.
* @param string $error_message Error details if status is not 'success'.
*
* @return void
*/
@@ -96,9 +82,7 @@ class PRAutoBlogger_Cost_Tracker {
int $prompt_tokens,
int $completion_tokens,
string $response_status = 'success',
string $error_message = '',
?string $agent_role = null,
?string $prompt_key = null
string $error_message = ''
): void {
$llm = new PRAutoBlogger_OpenRouter_Provider();
$cost = $llm->estimate_cost( $model, $prompt_tokens, $completion_tokens );
@@ -117,8 +101,6 @@ class PRAutoBlogger_Cost_Tracker {
'estimated_cost' => $cost,
'response_status' => $response_status,
'error_message' => $error_message,
'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' ),
)
);
@@ -127,19 +109,9 @@ class PRAutoBlogger_Cost_Tracker {
$table = $wpdb->prefix . 'prautoblogger_generation_log';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$inserted = $wpdb->insert( $table, $log_entry->to_db_row() );
$wpdb->insert( $table, $log_entry->to_db_row() );
// Self-healing on a half-migrated schema (e.g. cron fires after a
// deploy but before any admin_init migration pass): retry without
// the v0.18.0 audit columns rather than losing the cost row.
if ( false === $inserted ) {
$legacy_row = $log_entry->to_db_row();
unset( $legacy_row['agent_role'], $legacy_row['prompt_version'] );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$inserted = $wpdb->insert( $table, $legacy_row );
}
if ( false === $inserted || false === $wpdb->insert_id ) {
if ( false === $wpdb->insert_id ) {
PRAutoBlogger_Logger::instance()->error( 'Failed to log API call: ' . $wpdb->last_error, 'cost-tracker' );
}
}
@@ -189,55 +161,23 @@ class PRAutoBlogger_Cost_Tracker {
}
$table = $wpdb->prefix . 'prautoblogger_generation_log';
$row = array(
'post_id' => $post_id,
'run_id' => $this->run_id,
'stage' => $stage,
'provider' => 'cloudflare-workers-ai',
'model' => $model,
'prompt_tokens' => 0,
'completion_tokens' => 0,
'estimated_cost' => $cost_usd,
'response_status' => 'success',
'error_message' => '',
'agent_role' => PRAutoBlogger_Stage_Display_Map::default_agent_role( $stage ),
'prompt_version' => $this->resolve_prompt_version( $stage, null ),
'created_at' => current_time( 'mysql' ),
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$inserted = $wpdb->insert( $table, $row );
// Self-healing on a half-migrated schema — see log_api_call().
if ( false === $inserted ) {
unset( $row['agent_role'], $row['prompt_version'] );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->insert( $table, $row );
}
}
/**
* Resolve the pinned prompt version to stamp on a log row.
*
* Uses the explicit registry key when the call site passed one,
* otherwise the stage's primary key from the display map. The version
* comes from the run's pins; the run is this tracker's run_id or —
* for standalone trackers inside a pipeline process (e.g. the image
* prompt rewriter's) — the process-level run context. Null when there
* is no run, no pins, or no key (historical behavior preserved).
*
* @param string $stage Stage being logged.
* @param string|null $prompt_key Explicit registry key, or null to derive.
* @return string|null Pinned version as string, or null.
*/
private function resolve_prompt_version( string $stage, ?string $prompt_key ): ?string {
$key = $prompt_key ?? PRAutoBlogger_Stage_Display_Map::default_prompt_key( $stage );
if ( null === $key ) {
return null;
}
$run_id = $this->run_id ?? PRAutoBlogger_Run_Context::current_run_id();
$pins = PRAutoBlogger_Prompt_Registry::pins_for_run( $run_id );
return isset( $pins[ $key ] ) ? (string) $pins[ $key ] : null;
$wpdb->insert(
$table,
array(
'post_id' => $post_id,
'run_id' => $this->run_id,
'stage' => $stage,
'provider' => 'cloudflare-workers-ai',
'model' => $model,
'prompt_tokens' => 0,
'completion_tokens' => 0,
'estimated_cost' => $cost_usd,
'response_status' => 'success',
'error_message' => '',
'created_at' => current_time( 'mysql' ),
)
);
}
/**
@@ -274,9 +214,9 @@ class PRAutoBlogger_Cost_Tracker {
/**
* Get average input/output token counts for given stages over a time period.
*
* Thin delegate to PRAutoBlogger_Cost_Reporter (the read-only reporting
* class) — kept here for API compatibility with the model-picker field
* and existing tests. See Cost_Reporter for the query.
* Used by the model picker field renderer to calculate estimated costs
* per generation based on historical token usage. Maps setting IDs to their
* constituting stages (e.g., writing model controls outline + draft + polish).
*
* @param array<string> $stages Stage names ('analysis', 'outline', 'draft', 'polish', 'review').
* @param int $days Historical window (default 30 days).
@@ -285,6 +225,46 @@ class PRAutoBlogger_Cost_Tracker {
* Returns empty counters if no history. Never throws.
*/
public function get_avg_tokens_for_stages( array $stages, int $days = 30 ): array {
return ( new PRAutoBlogger_Cost_Reporter() )->get_avg_tokens_for_stages( $stages, $days );
global $wpdb;
if ( empty( $stages ) ) {
return array(
'avg_prompt_tokens' => 0.0,
'avg_completion_tokens' => 0.0,
'sample_size' => 0,
);
}
$cutoff_datetime = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) );
$stage_list = implode( ',', array_map( array( $wpdb, 'prepare' ), array_fill( 0, count( $stages ), '%s' ), $stages ) );
$table_name = $wpdb->prefix . 'prautoblogger_generation_log';
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Prepared stage list via array_map + prepare
$query = $wpdb->prepare(
"SELECT AVG(prompt_tokens) as avg_prompt,
AVG(completion_tokens) as avg_completion,
COUNT(*) as sample_count
FROM $table_name
WHERE stage IN ( $stage_list )
AND created_at >= %s",
$cutoff_datetime
);
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- phpcs pragma above
$result = $wpdb->get_row( $query, ARRAY_A );
if ( ! $result || 0 === (int) ( $result['sample_count'] ?? 0 ) ) {
return array(
'avg_prompt_tokens' => 0.0,
'avg_completion_tokens' => 0.0,
'sample_size' => 0,
);
}
return array(
'avg_prompt_tokens' => (float) ( $result['avg_prompt'] ?? 0 ),
'avg_completion_tokens' => (float) ( $result['avg_completion'] ?? 0 ),
'sample_size' => (int) ( $result['sample_count'] ?? 0 ),
);
}
}

View File

@@ -125,16 +125,6 @@ class PRAutoBlogger_Image_Pipeline {
return $result;
}
// v0.18.0 — per-run governor: reserve the curl_multi batch's SUMMED
// worst-case estimate before dispatch (no-op outside a governed
// run). A breach halts the run and skips the whole batch.
try {
$batch_reservation = PRAutoBlogger_Cost_Governor::open_amount_reservation( $estimated_cost, 'image_batch' );
} catch ( PRAutoBlogger_Cost_Ceiling_Exception $e ) {
$result['errors'][] = $e->getMessage();
return $result;
}
// Build all prompts upfront (LLM calls happen here, sequentially).
$article_prompt = $this->prompt_builder->build_article_prompt( $article_data );
$batch_requests = array(
@@ -161,19 +151,11 @@ class PRAutoBlogger_Image_Pipeline {
try {
$batch_results = $this->provider->generate_image_batch( $batch_requests );
} catch ( \Throwable $e ) {
PRAutoBlogger_Cost_Governor::release( $batch_reservation );
PRAutoBlogger_Logger::instance()->error( 'Batch generation failed: ' . $e->getMessage(), 'image_pipeline' );
$result['errors'][] = $e->getMessage();
return $result;
}
// Settle the batch reservation to the actual per-image costs.
$actual_batch_cost = 0.0;
foreach ( $batch_results as $batch_entry ) {
$actual_batch_cost += (float) ( $batch_entry['cost_usd'] ?? 0 );
}
PRAutoBlogger_Cost_Governor::settle( $batch_reservation, $actual_batch_cost );
// Single-shot retry for any slot the provider flagged as NSFW-blocked.
if ( PRAutoBlogger_Image_NSFW_Retry::is_enabled() ) {
( new PRAutoBlogger_Image_NSFW_Retry( $this->provider, $this->prompt_builder ) )

View File

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

98
includes/core/class-pipeline-runner.php Normal file → Executable file
View File

@@ -65,7 +65,6 @@ class PRAutoBlogger_Pipeline_Runner {
$ideas = $this->orchestrate( $cost_tracker );
if ( empty( $ideas ) ) {
PRAutoBlogger_Run_State::mark_status( (string) $cost_tracker->get_run_id(), 'done' );
return array(
'generated' => 0,
'published' => 0,
@@ -85,8 +84,7 @@ class PRAutoBlogger_Pipeline_Runner {
} else {
// Single-article run — amortize research costs now (1 article = full cost).
PRAutoBlogger_Post_Assembler::amortize_research_costs( $cost_tracker->get_run_id() );
PRAutoBlogger_Pipeline_Status::log_summary( $result );
PRAutoBlogger_Run_State::mark_status( (string) $cost_tracker->get_run_id(), 'done' );
$this->log_summary( $result );
}
return $result;
@@ -117,19 +115,6 @@ class PRAutoBlogger_Pipeline_Runner {
'Budget limit reached. Aborting remaining queued articles.',
'pipeline'
);
PRAutoBlogger_Run_State::mark_status( (string) $queue['run_id'], 'failed' );
$this->finish_queue();
return;
}
// v0.18.0 — a run halted by the per-run cost governor (or failed by
// the reaper) must not dispatch its remaining queued articles.
$run_status = PRAutoBlogger_Run_State::get_status( $queue['run_id'] );
if ( in_array( $run_status, array( 'halted', 'failed' ), true ) ) {
PRAutoBlogger_Logger::instance()->warning(
sprintf( 'Run %s is %s. Aborting remaining queued articles.', $queue['run_id'], $run_status ),
'pipeline'
);
$this->finish_queue();
return;
}
@@ -155,16 +140,15 @@ class PRAutoBlogger_Pipeline_Runner {
if ( ! empty( $queue['ideas'] ) ) {
update_option( self::QUEUE_KEY, $queue, false );
PRAutoBlogger_Pipeline_Status::update_queue_progress( $queue );
$this->update_queue_status( $queue );
$this->schedule_next();
} else {
// All articles done — amortize shared research costs across them.
PRAutoBlogger_Post_Assembler::amortize_research_costs( $queue['run_id'] );
PRAutoBlogger_Pipeline_Status::write_final( $queue['results'] );
$this->write_final_status( $queue['results'] );
delete_option( self::QUEUE_KEY );
PRAutoBlogger_Generation_Lock::release();
PRAutoBlogger_Pipeline_Status::log_summary( $queue['results'] );
PRAutoBlogger_Run_State::mark_status( (string) $queue['run_id'], 'done' );
$this->log_summary( $queue['results'] );
}
}
@@ -179,26 +163,20 @@ class PRAutoBlogger_Pipeline_Runner {
private function orchestrate( PRAutoBlogger_Cost_Tracker $cost_tracker ): array {
$target = absint( get_option( 'prautoblogger_daily_article_target', 1 ) );
$run_id = (string) $cost_tracker->get_run_id();
$enabled = json_decode( get_option( 'prautoblogger_enabled_sources', '["reddit"]' ), true );
$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', (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( 'research' ) );
$this->broadcast_stage( sprintf( __( 'Collecting sources from %s…', 'prautoblogger' ), $source_labels ) );
( 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', (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( 'analysis' ) );
$this->broadcast_stage( __( 'Analyzing topics and scoring…', 'prautoblogger' ) );
$llm = new PRAutoBlogger_OpenRouter_Provider();
$analyzer = new PRAutoBlogger_Content_Analyzer( $llm, $cost_tracker );
$analysis = $analyzer->analyze_recent_data( max( $target * 2, 6 ) );
PRAutoBlogger_Run_Stage_State::done( $run_id, 'analysis' );
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Selecting best topic…', 'prautoblogger' ) );
$this->broadcast_stage( __( 'Selecting best topic…', 'prautoblogger' ) );
$scorer = new PRAutoBlogger_Idea_Scorer();
$scorer->set_skip_dedup( $this->skip_dedup );
$ideas = $scorer->score_and_rank( $analysis, $target );
@@ -233,7 +211,7 @@ class PRAutoBlogger_Pipeline_Runner {
);
update_option( self::QUEUE_KEY, $queue, false );
PRAutoBlogger_Pipeline_Status::update_queue_progress( $queue );
$this->update_queue_status( $queue );
$this->schedule_next();
PRAutoBlogger_Logger::instance()->info(
@@ -258,13 +236,71 @@ class PRAutoBlogger_Pipeline_Runner {
);
}
/** Update the frontend status transient with queue progress. */
private function update_queue_status( array $queue ): void {
$done = $queue['results']['generated'] + $queue['results']['rejected'];
$total = $done + count( $queue['ideas'] );
$current = get_transient( 'prautoblogger_generation_status' );
set_transient(
'prautoblogger_generation_status',
array(
'status' => 'running',
'stage' => sprintf( __( 'Generating article %1$d of %2$d…', 'prautoblogger' ), $done + 1, $total ),
'started' => is_array( $current ) ? ( $current['started'] ?? time() ) : time(),
'last_updated' => time(),
),
600
);
}
/** Write the final "complete" status transient. */
private function write_final_status( array $r ): void {
set_transient(
'prautoblogger_generation_status',
array(
'status' => 'complete',
'generated' => $r['generated'],
'published' => $r['published'],
'rejected' => $r['rejected'],
'cost' => $r['cost'],
),
600
);
}
/** Clean up a stale or empty queue. */
private function finish_queue(): void {
$queue = get_option( self::QUEUE_KEY );
if ( is_array( $queue ) && isset( $queue['results'] ) ) {
PRAutoBlogger_Pipeline_Status::write_final( $queue['results'] );
$this->write_final_status( $queue['results'] );
}
delete_option( self::QUEUE_KEY );
PRAutoBlogger_Generation_Lock::release();
}
/** Log the pipeline summary. */
private function log_summary( array $r ): void {
PRAutoBlogger_Logger::instance()->info(
sprintf(
'Pipeline complete: %d generated, %d published, %d rejected. Cost: $%.4f',
$r['generated'],
$r['published'],
$r['rejected'],
$r['cost']
),
'pipeline'
);
}
/** Update the generation status transient with the current stage. */
private function broadcast_stage( string $stage ): void {
$key = 'prautoblogger_generation_status';
$current = get_transient( $key );
if ( is_array( $current ) && 'running' === ( $current['status'] ?? '' ) ) {
$current['stage'] = $stage;
$current['last_updated'] = time();
set_transient( $key, $current, 600 );
}
}
}

View File

@@ -1,106 +0,0 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Frontend status-transient + summary-log helpers for pipeline runs.
*
* What: The generation-status transient plumbing (stage broadcast, queue
* progress, final result) and the pipeline summary log line,
* extracted verbatim from Pipeline_Runner / Article_Worker in
* v0.18.0 (300-line cap; the two classes carried duplicate
* broadcast helpers). Pure presentation/state — no business logic.
* Who triggers it: Pipeline_Runner, Article_Worker.
* Dependencies: WordPress transients, PRAutoBlogger_Logger.
*
* @see core/class-pipeline-runner.php — Queue progress + final status writes.
* @see core/class-article-worker.php — Per-stage broadcasts.
* @see class-executor.php — Poller that reads this transient.
*/
class PRAutoBlogger_Pipeline_Status {
/** Transient key polled by the admin frontend. */
private const KEY = 'prautoblogger_generation_status';
/** Seconds the status transient stays readable. */
private const TTL = 600;
/**
* Update the live stage text (only while a run is broadcasting).
*
* @param string $stage Human-readable stage description.
* @return void
*/
public static function broadcast( string $stage ): void {
$current = get_transient( self::KEY );
if ( is_array( $current ) && 'running' === ( $current['status'] ?? '' ) ) {
$current['stage'] = $stage;
$current['last_updated'] = time();
set_transient( self::KEY, $current, self::TTL );
}
}
/**
* Update queue progress ("Generating article X of Y…").
*
* @param array $queue Queue array with 'results' + 'ideas'.
* @return void
*/
public static function update_queue_progress( array $queue ): void {
$done = $queue['results']['generated'] + $queue['results']['rejected'];
$total = $done + count( $queue['ideas'] );
$current = get_transient( self::KEY );
set_transient(
self::KEY,
array(
'status' => 'running',
/* translators: 1: current article number, 2: total articles. */
'stage' => sprintf( __( 'Generating article %1$d of %2$d…', 'prautoblogger' ), $done + 1, $total ),
'started' => is_array( $current ) ? ( $current['started'] ?? time() ) : time(),
'last_updated' => time(),
),
self::TTL
);
}
/**
* Write the final "complete" status.
*
* @param array $r Result counters (generated/published/rejected/cost).
* @return void
*/
public static function write_final( array $r ): void {
set_transient(
self::KEY,
array(
'status' => 'complete',
'generated' => $r['generated'],
'published' => $r['published'],
'rejected' => $r['rejected'],
'cost' => $r['cost'],
),
self::TTL
);
}
/**
* Log the pipeline summary line.
*
* @param array $r Result counters (generated/published/rejected/cost).
* @return void
*/
public static function log_summary( array $r ): void {
PRAutoBlogger_Logger::instance()->info(
sprintf(
'Pipeline complete: %d generated, %d published, %d rejected. Cost: $%.4f',
$r['generated'],
$r['published'],
$r['rejected'],
$r['cost']
),
'pipeline'
);
}
}

View File

@@ -108,8 +108,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( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT id, estimated_cost, provider, model, prompt_tokens, completion_tokens
$wpdb->prepare(
"SELECT id, estimated_cost, provider, model, prompt_tokens, completion_tokens // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
FROM {$table}
WHERE run_id = %s AND stage = 'llm_research' AND post_id IS NULL
LIMIT 1",

View File

@@ -1,147 +0,0 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Canonical default bodies for the editor, research, and image registry keys.
*
* What: Companion to PRAutoBlogger_Prompt_Defaults (split for the 300-line
* cap, mirroring the settings-fields/-extended split). Holds the
* chief-editor and LLM-research prompt copy extracted verbatim from
* their classes, and references the two image keys that form the
* image-composer PR seam: `image.rewriter_system` (the illustration
* rewriter prompt) and `image.style_template` (the Style Template,
* ARCHITECTURE #20). The composer PR CONSUMES these keys — it must
* not grow its own prompt storage, and the key names are frozen on
* the pipeline-v2-phase1 convo thread.
* Who triggers it: PRAutoBlogger_Prompt_Registry (render fallback + seed),
* PRAutoBlogger_Activator (seed migration).
* Dependencies: PRAutoBlogger_Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT,
* PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE (referenced, not duplicated).
*
* @see core/class-prompt-defaults.php — Content + analysis keys.
* @see core/class-prompt-registry.php — Render/seed consumer.
* @see core/class-chief-editor.php — Computes the editor token values.
* @see providers/class-llm-research-provider.php — research.system consumer.
*/
class PRAutoBlogger_Prompt_Defaults_Editorial {
/**
* Chief-editor system prompt. instructions_block carries its own
* leading newline + trailing newline when non-empty ('' when absent),
* matching the historical concatenation byte-for-byte.
*/
public const EDITOR_SYSTEM = <<<'TPL'
You are a senior blog editor{{ niche_clause }}. Review article drafts before publication.
Evaluate on: QUALITY, ACCURACY, SEO, COMPLETENESS, READABILITY.
Respond with JSON: {
"verdict": "approved" | "revised" | "rejected",
"quality_score": 0.0-1.0,
"seo_score": 0.0-1.0,
"issues": ["issue1", "issue2"],
"notes": "Editorial notes",
"revised_content": "Full revised HTML if revised, null otherwise"
}
Rules:
- APPROVE if quality_score >= 0.7 and seo_score >= 0.6
- REVISE if fixable issues exist — provide full revised HTML
- REJECT if fundamentally flawed
- Preserve formatting, links, lists when revising
{{ instructions_block }}
TPL;
/** Chief-editor review user prompt. */
public const EDITOR_REVIEW = <<<'TPL'
Review this article draft:
BRIEF: Title: {{ title }} | Topic: {{ topic }} | Type: {{ article_type }}
KEY POINTS: {{ key_points }}
TARGET KEYWORDS: {{ keywords }}
CONTENT:
{{ content }}
TPL;
/**
* LLM deep-research system prompt (fully static). The user-side
* research brief stays the `prautoblogger_research_prompt` SETTING —
* it is site configuration, not pipeline copy.
*/
public const RESEARCH_SYSTEM = <<<'TPL'
You are a deep research analyst specializing in emerging trends, scientific developments, and community knowledge in niche health and biohacking domains. Your task is to identify substantive, actionable findings.
Using your training knowledge, scan across:
- Recent scientific literature and research directions
- Active community discussions, debates, and evolving consensus
- Emerging products, protocols, and methodologies gaining traction
- Common questions, misconceptions, and points of confusion
- Regulatory changes, safety concerns, and legal shifts
- Gaps between what practitioners want to know and what content exists
Your findings must be:
1. SUBSTANTIVE — 2-3 paragraphs of detailed analysis per finding, not a headline
2. ACTIONABLE — practical enough to seed a full article
3. REALISTIC — grounded in what is actually discussed or researched; do NOT invent citations
4. TIMELY — reflect current knowledge, recent shifts, or emerging questions
Respond with valid JSON only (no preamble, no markdown fences):
{"findings": [{"title": "string", "content": "string (2-3 paragraphs)", "relevance_score": 0-100, "category": "question|trend|comparison|guide|misconception|safety"}]}
Generate 8-12 findings. Prioritize depth over breadth. Avoid generic or surface-level topics.
TPL;
/**
* Registry definitions for the editor / research / image keys.
*
* The image bodies are referenced from their existing single sources
* of truth rather than duplicated. Note the image keys seed the
* registry for the composer PR; the v0.16.0 option-override chain
* (`prautoblogger_image_prompt_instructions`,
* `prautoblogger_image_style_template`) is unchanged in Phase 1.
*
* @return array<string, array{body: string, model_option: ?string, params: array<string, mixed>}>
*/
public static function defs(): array {
return array(
'editor.system' => array(
'body' => self::EDITOR_SYSTEM,
'model_option' => 'prautoblogger_editor_model',
'params' => array(
'temperature' => 0.3,
'max_tokens' => 5000,
'response_format' => array( 'type' => 'json_object' ),
),
),
'editor.review' => array(
'body' => self::EDITOR_REVIEW,
'model_option' => 'prautoblogger_editor_model',
'params' => array(),
),
'research.system' => array(
'body' => self::RESEARCH_SYSTEM,
'model_option' => 'prautoblogger_research_model',
'params' => array(
'temperature' => 0.7,
'max_tokens' => 8000,
'response_format' => array( 'type' => 'json_object' ),
),
),
'image.rewriter_system' => array(
'body' => PRAutoBlogger_Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT,
'model_option' => 'prautoblogger_analysis_model',
'params' => array(
'temperature' => 0.7,
'max_tokens' => 180,
),
),
'image.style_template' => array(
'body' => PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE,
'model_option' => 'prautoblogger_image_model',
'params' => array(),
),
);
}
}

View File

@@ -1,210 +0,0 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Canonical default bodies for the content + analysis prompt-registry keys.
*
* What: The v0.16.0 hardcoded prompt texts, extracted verbatim into
* `{{ token }}` templates. These constants are the single source of
* truth twice over: the v1.2.0 migration seeds them into
* `wp_prautoblogger_prompts` as version 1, AND the registry falls
* back to them whenever the table is missing/empty — so a
* half-migrated site renders byte-identical prompts and never
* fatals. Versions in the table are immutable; to change a prompt,
* create a new version (see Prompt_Registry).
* Who triggers it: PRAutoBlogger_Prompt_Registry (render fallback + seed),
* PRAutoBlogger_Activator (seed migration).
* Dependencies: none — pure constants.
*
* @see core/class-prompt-registry.php — Render/seed consumer.
* @see core/class-prompt-defaults-editorial.php — Editor/research/image keys.
* @see core/class-content-prompts.php — Computes the token values.
* @see core/class-analysis-prompts.php — Computes the token values.
*/
class PRAutoBlogger_Prompt_Defaults {
/**
* System prompt shared across all writing stages. The mandatory style
* guide block and the linking rules stay code-side (they are data
* injection, not prompt copy) and are appended after this body.
*/
public const CONTENT_SYSTEM = 'You are an expert blog writer{{ niche_clause }}. Write well-researched, engaging, SEO-friendly content. Use a {{ tone }} tone. Output HTML content only — no markdown, no code fences, no commentary.';
/** Single-pass writer prompt (Economy tier — logs stage 'draft'). */
public const CONTENT_SINGLE_PASS = <<<'TPL'
Write a complete blog post in HTML format.
Title: {{ title }}
Topic: {{ topic }}
Type: {{ article_type }}
Key points:
- {{ key_points }}
Keywords: {{ keywords }}
Requirements:
- {{ min_words }}-{{ max_words }} words
- Proper HTML (h2, h3, p, ul/li)
- Engaging intro, strong conclusion with CTA
- Do NOT include the title or <html>/<body> tags
- Output HTML only, no markdown or commentary
- Follow EVERY formatting and structural requirement from your system prompt style guide
TPL;
/** Multi-step stage 1: outline. */
public const CONTENT_OUTLINE = <<<'TPL'
Create a detailed outline for a blog post titled: "{{ title }}"
Topic: {{ topic }}
Article type: {{ article_type }}
Key points to cover:
{{ key_points }}
Target keywords: {{ keywords }}
The outline should have 4-6 main sections with bullet points under each. Include an introduction hook and a conclusion with a call to action. Word count target: {{ min_words }}-{{ max_words }} words.
Plan the structure to satisfy EVERY requirement in your system prompt style guide.
TPL;
/** Multi-step stage 2: full draft from outline. */
public const CONTENT_DRAFT = <<<'TPL'
Using this outline, write the full blog post in HTML format.
OUTLINE:
{{ outline }}
Requirements:
- Write in a {{ tone }} tone
- Target {{ min_words }}-{{ max_words }} words
- Use proper HTML headings (h2, h3), paragraphs, and lists
- Include an engaging introduction and strong conclusion
- Naturally incorporate these keywords: {{ keywords }}
- Do NOT include the title in the HTML (it will be set separately)
- Do NOT wrap in <html>, <head>, or <body> tags — just the article content
- Follow EVERY formatting and structural requirement from your system prompt style guide
TPL;
/** Multi-step stage 3: polish (canonical name — ARCHITECTURE once said 'edit'). */
public const CONTENT_POLISH = <<<'TPL'
Review and polish this blog post draft. Improve:
1. Flow and readability
2. SEO optimization (headings, keyword placement)
3. Engagement (hooks, transitions, call-to-action)
4. Accuracy and clarity
5. Remove any filler or redundant sentences
IMPORTANT: Preserve all bullet points, numbered lists, hyperlinks, and structural elements from the draft. Do NOT flatten lists into prose or remove links. Ensure every requirement from your system prompt style guide is satisfied in the final output.
Return the polished HTML content only. Do not add commentary.
DRAFT:
{{ draft }}
TPL;
/**
* Analyzer system prompt. The *_block tokens carry their own trailing
* separators when non-empty ('' when absent) so the rendered output is
* byte-identical to the historical concatenation.
*/
public const ANALYSIS_SYSTEM = <<<'TPL'
You are a content strategist analyzing social media discussions to find article ideas for a blog{{ niche_clause }}.
IMPORTANT: You must identify exactly {{ target_count }} DISTINCT article ideas. Each idea must cover a substantially different topic — no two ideas should overlap in their main subject. Aim for diversity across these categories:
1. QUESTIONS: Recurring questions people ask ("How do I...", "What is...", "Is it safe to...")
2. COMPLAINTS: Pain points, frustrations, or problems people report
3. COMPARISONS: Product/method comparisons people discuss ("X vs Y", "Which is better")
4. NEWS: Recent developments, rule changes, or trends people are discussing
5. GUIDES: How-to topics, best practices, or educational content people need
Spread your {{ target_count }} ideas across multiple categories. Be specific — "peptide dosing for BPC-157" is better than "peptide information". Each idea should be narrow enough to be a single focused blog post.
For each idea, provide:
- type: 'question', 'complaint', 'comparison', 'news', or 'guide'
- topic: A clear, specific, narrow topic description
- summary: 1-2 sentence summary of why this topic matters
- frequency: How many of the provided posts relate to this topic
- relevance_score: 0.0 to 1.0 indicating how relevant and article-worthy this is
- suggested_title: A compelling, unique blog post title
- key_points: Array of 3-5 key points the article should cover
- target_keywords: Array of SEO keywords for this topic
{{ performance_block }}{{ recent_articles_block }}{{ instructions_block }}Respond with valid JSON containing exactly {{ target_count }} patterns:
{"patterns": [{"type": "...", "topic": "...", "summary": "...", "frequency": N, "relevance_score": 0.X, "suggested_title": "...", "key_points": [...], "target_keywords": [...]}]}
TPL;
/** Analyzer user prompt wrapping the source-data summary. */
public const ANALYSIS_USER = <<<'TPL'
Here are the recent social media posts and comments to analyze. Find {{ target_count }} distinct, diverse article ideas from this data:
{{ summary }}
TPL;
/**
* Registry definitions for the content + analysis keys.
*
* Each entry: body (the v1 template), model_option (the wp_option that
* selects the model at call time — informational in Phase 1) and params
* (the call-time LLM parameters, snapshotted for audit comparability).
*
* @return array<string, array{body: string, model_option: ?string, params: array<string, mixed>}>
*/
public static function defs(): array {
return array(
'content.system' => array(
'body' => self::CONTENT_SYSTEM,
'model_option' => 'prautoblogger_writing_model',
'params' => array(),
),
'content.single_pass' => array(
'body' => self::CONTENT_SINGLE_PASS,
'model_option' => 'prautoblogger_writing_model',
'params' => array(
'temperature' => 0.7,
'max_tokens' => 4000,
),
),
'content.outline' => array(
'body' => self::CONTENT_OUTLINE,
'model_option' => 'prautoblogger_writing_model',
'params' => array(
'temperature' => 0.5,
'max_tokens' => 1500,
),
),
'content.draft' => array(
'body' => self::CONTENT_DRAFT,
'model_option' => 'prautoblogger_writing_model',
'params' => array(
'temperature' => 0.7,
'max_tokens' => 4000,
),
),
'content.polish' => array(
'body' => self::CONTENT_POLISH,
'model_option' => 'prautoblogger_writing_model',
'params' => array(
'temperature' => 0.4,
'max_tokens' => 4000,
),
),
'analysis.system' => array(
'body' => self::ANALYSIS_SYSTEM,
'model_option' => 'prautoblogger_analysis_model',
'params' => array(
'temperature' => 0.5,
'max_tokens' => 8000,
),
),
'analysis.user' => array(
'body' => self::ANALYSIS_USER,
'model_option' => 'prautoblogger_analysis_model',
'params' => array(),
),
);
}
}

View File

@@ -1,143 +0,0 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Write side of the versioned prompt registry: create / activate / seed.
*
* What: Companion to PRAutoBlogger_Prompt_Registry (split for the 300-line
* cap). Owns every mutation of `wp_prautoblogger_prompts` and
* enforces the immutability rule: a stored body is NEVER updated —
* a change is a new (prompt_key, version) row, and `activate()`
* flips the single active flag per key. `seed_v1()` writes the
* canonical v0.16.0 texts as version 1 (idempotent — keys that
* already have rows keep their history). This is the API surface
* the Phase-2 in-admin Prompts editor will call; Phase 1 ships no UI.
* Who triggers it: PRAutoBlogger_Activator (seed migration on db 1.2.0),
* Phase-2 admin (create/activate), WP-CLI (future).
* Dependencies: WordPress $wpdb, PRAutoBlogger_Prompt_Registry (table name,
* availability probe, defs, cache flush).
*
* @see core/class-prompt-registry.php — Read side (render, pins, fallback).
* @see class-activator.php — Calls seed_v1() once, flag-gated.
*/
class PRAutoBlogger_Prompt_Registry_Writer {
/**
* All versions of a key, newest first (Phase-2 list/diff support).
*
* @param string $key Registry key.
* @return array<int, array<string, mixed>>
*/
public static function list_versions( string $key ): array {
if ( ! PRAutoBlogger_Prompt_Registry::is_available() ) {
return array();
}
global $wpdb;
$table = PRAutoBlogger_Prompt_Registry::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
$wpdb->prepare( "SELECT * FROM {$table} WHERE prompt_key = %s ORDER BY version DESC", $key ),
ARRAY_A
);
return is_array( $rows ) ? $rows : array();
}
/**
* Create a NEW immutable version of a key and (optionally) activate it.
* Never mutates an existing row's body — that is the whole point.
*
* @param string $key Registry key.
* @param string $body Template body.
* @param string|null $model Optional model hint.
* @param array<string, mixed>|null $params Optional params snapshot.
* @param string $author Who created it (seed tag, login, agent).
* @param bool $activate Flip active to this version (default true).
* @return int New version number, or 0 on failure / table unavailable.
*/
public static function create_version( string $key, string $body, ?string $model, ?array $params, string $author, bool $activate = true ): int {
if ( ! PRAutoBlogger_Prompt_Registry::is_available() ) {
return 0;
}
global $wpdb;
$table = PRAutoBlogger_Prompt_Registry::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$max = (int) $wpdb->get_var(
$wpdb->prepare( "SELECT MAX(version) FROM {$table} WHERE prompt_key = %s", $key )
);
$next = $max + 1;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$ok = $wpdb->insert(
$table,
array(
'prompt_key' => $key,
'version' => $next,
'body' => $body,
'model' => $model,
'params_json' => null !== $params ? wp_json_encode( $params ) : null,
'author' => $author,
'created_at' => current_time( 'mysql' ),
'active' => 0,
)
);
if ( false === $ok ) {
return 0;
}
if ( $activate ) {
self::activate( $key, $next );
}
return $next;
}
/**
* Make one version the single active version of its key.
*
* @param string $key Registry key.
* @param int $version Version to activate.
* @return bool True when the version is now active.
*/
public static function activate( string $key, int $version ): bool {
if ( ! PRAutoBlogger_Prompt_Registry::is_available() ) {
return false;
}
global $wpdb;
$table = PRAutoBlogger_Prompt_Registry::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query( $wpdb->prepare( "UPDATE {$table} SET active = 0 WHERE prompt_key = %s", $key ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$updated = $wpdb->query(
$wpdb->prepare( "UPDATE {$table} SET active = 1 WHERE prompt_key = %s AND version = %d", $key, $version )
);
PRAutoBlogger_Prompt_Registry::flush_cache();
return 1 === (int) $updated;
}
/**
* Seed every registry key with its canonical v1 body. Idempotent: keys
* that already have rows are left untouched (their history is theirs).
*
* Side effects: up to one INSERT + two UPDATEs per missing key.
*
* @param string $author Audit tag for the seed rows (e.g. 'seed:v0.18.0').
* @return int Number of keys seeded.
*/
public static function seed_v1( string $author = 'seed:v0.18.0' ): int {
if ( ! PRAutoBlogger_Prompt_Registry::is_available() ) {
return 0;
}
$seeded = 0;
foreach ( PRAutoBlogger_Prompt_Registry::defs() as $key => $def ) {
if ( ! empty( self::list_versions( $key ) ) ) {
continue;
}
$model = null !== $def['model_option'] ? (string) get_option( $def['model_option'], '' ) : null;
$version = self::create_version( $key, $def['body'], '' !== (string) $model ? $model : null, $def['params'], $author, true );
if ( $version > 0 ) {
++$seeded;
}
}
PRAutoBlogger_Prompt_Registry::flush_cache();
return $seeded;
}
}

View File

@@ -1,214 +0,0 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Versioned prompt registry — immutable versions, one active row per key.
*
* What: Read/write API over `wp_prautoblogger_prompts`. Rendering resolves
* the ACTIVE body for a key and fills `{{ token }}` placeholders;
* when the table is missing or has no row for the key (half-migrated
* site, failed seed), it falls back to the canonical defaults the
* seed itself uses — byte-identical output, no fatals (self-healing).
* Versions are IMMUTABLE: there is no update path for a stored body;
* changes create a new (prompt_key, version) row and flip `active`
* (write side lives in PRAutoBlogger_Prompt_Registry_Writer).
* Runs pin the active version of every key at start (stored on the
* runs row) so generation_log rows can be stamped with the exact
* prompt_version they rendered with. No admin UI in Phase 1 — the
* list/activate/create API is what the Phase-2 Prompts screen needs.
* Who triggers it: Content_Prompts / Analysis_Prompts / Chief_Editor /
* LLM_Research_Provider (render), Cost_Tracker (pins_for_run),
* Activator (seed_v1), Cost_Tracker::set_run_id (pin_for_run).
* Dependencies: WordPress $wpdb, Prompt_Defaults(+Editorial), wp_json_encode.
*
* @see core/class-prompt-defaults.php — Canonical v1 bodies (seed + fallback).
* @see core/class-prompt-defaults-editorial.php — Editor/research/image bodies.
* @see core/class-run-state.php — Owns the runs row the pins live on.
* @see ARCHITECTURE.md #21 — Design rationale.
*/
class PRAutoBlogger_Prompt_Registry {
/** @var array<string, ?array<string, mixed>> Per-request active-row cache. */
private static array $active_cache = array();
/** @var array<string, array<string, int>> Per-request run-pins cache. */
private static array $pins_cache = array();
/** @var bool|null Per-request "table exists" probe result. */
private static ?bool $table_ok = null;
/** Fully-qualified prompts table name (shared with Prompt_Registry_Writer). */
public static function table_name(): string {
global $wpdb;
return $wpdb->prefix . 'prautoblogger_prompts';
}
/**
* Whether the prompts table exists and is queryable. Cached per request;
* never throws — any failure reads as "not available" and every consumer
* falls back to the in-code defaults.
*
* @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;
}
/**
* All registry definitions (content + analysis + editor + research + image).
*
* @return array<string, array{body: string, model_option: ?string, params: array<string, mixed>}>
*/
public static function defs(): array {
return array_merge(
PRAutoBlogger_Prompt_Defaults::defs(),
PRAutoBlogger_Prompt_Defaults_Editorial::defs()
);
}
/**
* Canonical in-code default body for a key (the same text seeded as v1).
*
* @param string $key Registry key.
* @return string|null Body template, or null for unknown keys.
*/
public static function default_body( string $key ): ?string {
$defs = self::defs();
return isset( $defs[ $key ] ) ? $defs[ $key ]['body'] : null;
}
/**
* Active row for a key, or null when none / table unavailable.
*
* @param string $key Registry key.
* @return array<string, mixed>|null Row with prompt_key, version, body, model, params_json, author, created_at, active.
*/
public static function get_active( string $key ): ?array {
if ( array_key_exists( $key, self::$active_cache ) ) {
return self::$active_cache[ $key ];
}
$row = null;
if ( self::is_available() ) {
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 prompt_key = %s AND active = 1 ORDER BY version DESC LIMIT 1",
$key
),
ARRAY_A
);
$row = is_array( $row ) ? $row : null;
}
self::$active_cache[ $key ] = $row;
return $row;
}
/**
* Render a prompt: active body for the key (fallback: canonical default),
* with `{{ token }}` placeholders substituted.
*
* @param string $key Registry key (e.g. 'content.single_pass').
* @param array<string, string> $tokens Token name => replacement value.
* @return string Rendered prompt ('' only if the key is unknown AND absent).
*/
public static function render( string $key, array $tokens = array() ): string {
$active = self::get_active( $key );
$body = null !== $active ? (string) $active['body'] : (string) self::default_body( $key );
return self::fill( $body, $tokens );
}
/**
* Substitute `{{ token }}` placeholders in a template body.
*
* @param string $body Template body.
* @param array<string, string> $tokens Token name => replacement value.
* @return string
*/
public static function fill( string $body, array $tokens ): string {
foreach ( $tokens as $name => $value ) {
$body = str_replace( '{{ ' . $name . ' }}', (string) $value, $body );
}
return $body;
}
/**
* Map of prompt_key => active version for every key present in the table.
*
* @return array<string, int>
*/
public static function active_versions(): array {
if ( ! self::is_available() ) {
return array();
}
global $wpdb;
$table = self::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results( "SELECT prompt_key, version FROM {$table} WHERE active = 1", ARRAY_A );
$map = array();
foreach ( ( is_array( $rows ) ? $rows : array() ) as $row ) {
$map[ (string) $row['prompt_key'] ] = (int) $row['version'];
}
return $map;
}
/**
* Pin the current active versions onto a run's row (once, at run start).
* No-ops when the runs row already carries pins (resume keeps the
* versions the run started with) or when tables are unavailable.
*
* @param string $run_id Pipeline run UUID.
* @return void
*/
public static function pin_for_run( string $run_id ): void {
if ( '' === $run_id || ! self::is_available() ) {
return;
}
$pins = self::active_versions();
if ( empty( $pins ) ) {
return;
}
PRAutoBlogger_Run_State::set_pins_if_absent( $run_id, $pins );
unset( self::$pins_cache[ $run_id ] );
}
/**
* The pinned prompt versions for a run (cached per request). Empty when
* the run has no pins (pre-1.2.0 rows, standalone calls, missing table).
*
* @param string|null $run_id Pipeline run UUID, or null.
* @return array<string, int>
*/
public static function pins_for_run( ?string $run_id ): array {
if ( null === $run_id || '' === $run_id ) {
return array();
}
if ( isset( self::$pins_cache[ $run_id ] ) ) {
return self::$pins_cache[ $run_id ];
}
$pins = PRAutoBlogger_Run_State::get_pins( $run_id );
self::$pins_cache[ $run_id ] = $pins;
return $pins;
}
/** Reset per-request caches (tests). */
public static function flush_cache(): void {
self::$active_cache = array();
self::$pins_cache = array();
self::$table_ok = null;
}
}

74
includes/core/class-publisher.php Normal file → Executable file
View File

@@ -82,39 +82,6 @@ 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
// the idea hash disambiguates within the run.
$idea_hash = PRAutoBlogger_Run_Stage_State::idea_hash( $idea );
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;
}
}
// Clean LLM artifacts, then inject peptide hyperlinks deterministically
// before the content enters WordPress. Peptide linker no-ops gracefully
// if PR Core is not active.
@@ -127,7 +94,7 @@ class PRAutoBlogger_Publisher {
'post_status' => $post_status,
'post_type' => 'post',
'post_author' => PRAutoBlogger_Post_Assembler::get_default_author_id(),
'meta_input' => $this->build_meta( $idea, $review, $run_id, $idea_hash ),
'meta_input' => $this->build_meta( $idea, $review, $run_id ),
);
/** @see class-prautoblogger.php — listeners registered in main loader. */
@@ -173,8 +140,7 @@ class PRAutoBlogger_Publisher {
private function build_meta(
PRAutoBlogger_Article_Idea $idea,
PRAutoBlogger_Editorial_Review $review,
?string $run_id = null,
string $idea_hash = ''
?string $run_id = null
): array {
$meta = array(
'_prautoblogger_generated' => '1',
@@ -194,42 +160,6 @@ class PRAutoBlogger_Publisher {
if ( null !== $run_id && '' !== $run_id ) {
$meta['_prautoblogger_run_id'] = $run_id;
}
if ( '' !== $idea_hash ) {
$meta['_prautoblogger_idea_hash'] = $idea_hash;
}
return $meta;
}
/**
* Find a post already created for this (run, idea) pair.
*
* Direct postmeta self-join (any post_status, including trash) — the
* idempotency check must see drafts and pending posts too.
*
* @param string $run_id Pipeline run UUID.
* @param string $idea_hash Idea hash from Run_Stage_State::idea_hash().
* @return int Existing post ID, or 0 when none.
*/
private function find_existing_post( string $run_id, string $idea_hash ): int {
global $wpdb;
if ( null === $wpdb ) {
return 0;
}
$postmeta = $wpdb->prefix . 'postmeta';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$post_id = $wpdb->get_var(
$wpdb->prepare(
"SELECT pm_run.post_id FROM {$postmeta} pm_run
INNER JOIN {$postmeta} pm_hash
ON pm_hash.post_id = pm_run.post_id
AND pm_hash.meta_key = '_prautoblogger_idea_hash'
AND pm_hash.meta_value = %s
WHERE pm_run.meta_key = '_prautoblogger_run_id' AND pm_run.meta_value = %s
LIMIT 1",
$idea_hash,
$run_id
)
);
return null !== $post_id ? (int) $post_id : 0;
}
}

View File

@@ -1,59 +0,0 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Per-PHP-process holder for the active pipeline run id.
*
* What: A process-scoped static slot recording which run this PHP request
* is executing, set whenever Cost_Tracker::set_run_id() is called
* (daily cron, manual generation, queued-article jobs, ideas
* browser). It lets components that are constructed without a run
* reference — the OpenRouter provider (cost-governor guard) and
* standalone Cost_Tracker instances (e.g. the image prompt
* rewriter's) — resolve the surrounding run for reservation and
* prompt-version stamping without threading a parameter through
* every constructor. Mirrors the Opik_Trace_Context precedent.
* Who triggers it: PRAutoBlogger_Cost_Tracker::set_run_id() (set);
* Cost_Governor + Cost_Tracker (read).
* Dependencies: none.
*
* @see core/class-cost-tracker.php — Sets the context on set_run_id().
* @see core/class-cost-governor.php — Reads it to guard LLM calls.
*/
class PRAutoBlogger_Run_Context {
/** @var string|null Active run id for this PHP process, or null. */
private static ?string $run_id = null;
/**
* Record the active run id for this process.
*
* @param string $run_id Pipeline run UUID.
* @return void
*/
public static function set_run_id( string $run_id ): void {
if ( '' !== $run_id ) {
self::$run_id = $run_id;
}
}
/**
* The active run id for this process, or null outside any run.
*
* @return string|null
*/
public static function current_run_id(): ?string {
return self::$run_id;
}
/**
* Clear the context (tests / end of run).
*
* @return void
*/
public static function clear(): void {
self::$run_id = null;
}
}

View File

@@ -1,270 +0,0 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Daily sweep for stuck runs + audit-payload retention (extends #19).
*
* What: Rides the EXISTING `prautoblogger_reap_orphan_research_rows` cron
* (no new schedule) alongside the v0.8.1 Research_Reaper. Three jobs:
* 1. Stage sweep — run_stages rows stuck `running` longer than
* 2× the stage's expected wall-clock are marked `failed`
* (Hostinger kills PHP at ~120s; a stage "running" for 10+
* minutes is dead, its process gone).
* 2. Run sweep — runs rows still open (pending/running) with no
* 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
* payloads (`meta_json` on run_stages) are NULLed after R days;
* R comes from the `prautoblogger_request_json_retention_days`
* SETTING (default constant 14; 0 = keep forever) — never a
* literal at the point of use.
* Who triggers it: WP-Cron `prautoblogger_reap_orphan_research_rows`
* (daily 03:15, registered in class-prautoblogger.php).
* Dependencies: Run_State, Run_Stage_State, WordPress $wpdb, Logger.
*
* @see core/class-research-reaper.php — The #19 orphan-cost reaper (same cron).
* @see core/class-run-state.php — Run rows swept here.
* @see ARCHITECTURE.md #21 — Design rationale.
*/
class PRAutoBlogger_Run_Reaper {
/**
* Expected per-stage wall-clock seconds (sweep threshold = 2×). An
* engineering constant (like Research_Reaper::GRACE_WINDOW_SECONDS),
* overridable per stage via the
* `prautoblogger_filter_stage_expected_seconds` filter.
*/
private const EXPECTED_STAGE_SECONDS = 300;
/**
* Expected whole-run wall-clock seconds (collect + analyze + up to 10
* chained articles ≈ 30 min worst case). Sweep threshold = 2×.
* Overridable via `prautoblogger_filter_expected_run_seconds`.
*/
private const EXPECTED_RUN_SECONDS = 1800;
/**
* Cron action handler — entrypoint. Pure delegate, never throws.
*
* @return void
*/
public static function on_cron(): void {
self::reap();
}
/**
* Run all three sweeps. Returns a summary (cron path ignores it).
*
* Side effects: UPDATEs on run_stages / runs / generation_log; INFO
* log lines for swept runs.
*
* @return array{stages_failed: int, runs_failed: int, payloads_pruned: int}
*/
public static function reap(): array {
$stats = array(
'stages_failed' => 0,
'runs_failed' => 0,
'payloads_pruned' => 0,
);
try {
$stats['stages_failed'] = self::sweep_stuck_stages();
$stats['runs_failed'] = self::sweep_stuck_runs();
$stats['payloads_pruned'] = self::prune_payloads();
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->warning(
'Run reaper encountered an error: ' . $e->getMessage(),
'run-reaper'
);
}
return $stats;
}
/**
* Runs that died mid-pipeline, for audit surfaces: failed/halted runs
* that still have non-done stage rows ("incomplete").
*
* @param int $limit Max rows (default 20).
* @return array<int, array<string, mixed>>
*/
public static function incomplete_runs( int $limit = 20 ): array {
if ( ! PRAutoBlogger_Run_State::is_available() || ! PRAutoBlogger_Run_Stage_State::is_available() ) {
return array();
}
global $wpdb;
$runs = PRAutoBlogger_Run_State::table_name();
$stages = PRAutoBlogger_Run_Stage_State::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT r.* FROM {$runs} r
WHERE r.status IN ('failed','halted')
AND EXISTS (SELECT 1 FROM {$stages} s WHERE s.run_id = r.run_id AND s.status != 'done')
ORDER BY r.updated_at DESC LIMIT %d",
$limit
),
ARRAY_A
);
return is_array( $rows ) ? $rows : array();
}
/**
* Stage sweep: `running` rows older than 2× expected stage wall-clock
* become `failed`.
*
* @return int Rows swept.
*/
private static function sweep_stuck_stages(): int {
if ( ! PRAutoBlogger_Run_Stage_State::is_available() ) {
return 0;
}
global $wpdb;
$table = PRAutoBlogger_Run_Stage_State::table_name();
/**
* Filter the expected per-stage wall-clock seconds used by the
* stuck-stage sweep (threshold is 2× this value).
*
* @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 ) );
$now = current_time( 'mysql' );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$swept = $wpdb->query(
$wpdb->prepare(
"UPDATE {$table} SET status = 'failed', updated_at = %s, finished_at = %s
WHERE status = 'running' AND updated_at < %s",
$now,
$now,
$cutoff
)
);
return is_numeric( $swept ) ? (int) $swept : 0;
}
/**
* Run sweep: open runs with no activity for 2× expected run wall-clock
* are marked `failed`; their pending/running stages are reaped.
*
* @return int Runs swept.
*/
private static function sweep_stuck_runs(): int {
if ( ! PRAutoBlogger_Run_State::is_available() ) {
return 0;
}
global $wpdb;
$runs = PRAutoBlogger_Run_State::table_name();
/**
* Filter the expected whole-run wall-clock seconds used by the
* stuck-run sweep (threshold is 2× this value).
*
* @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 ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$stuck = $wpdb->get_col(
$wpdb->prepare(
"SELECT run_id FROM {$runs} WHERE status IN ('pending','running') AND updated_at < %s",
$cutoff
)
);
if ( ! is_array( $stuck ) || empty( $stuck ) ) {
return 0;
}
$swept = 0;
foreach ( $stuck as $run_id ) {
$run_id = (string) $run_id;
if ( ! PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' ) ) {
continue;
}
self::reap_open_stages( $run_id );
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Run %s stuck past 2x expected wall-clock — marked failed (renders as incomplete).', $run_id ),
'run-reaper'
);
++$swept;
}
return $swept;
}
/**
* Mark a failed run's pending/running stage rows as failed.
*
* @param string $run_id Run UUID.
* @return void
*/
private static function reap_open_stages( string $run_id ): void {
if ( ! 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 status IN ('pending','running')",
$now,
$now,
$run_id
)
);
}
/**
* Retention: NULL heavy payloads older than R days. R is read from the
* `prautoblogger_request_json_retention_days` setting at every call
* (default = PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS;
* 0 or negative = keep forever).
*
* @return int Rows pruned (both tables combined).
*/
private static function prune_payloads(): int {
$days = (int) get_option(
'prautoblogger_request_json_retention_days',
PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS
);
if ( $days <= 0 ) {
return 0;
}
global $wpdb;
$cutoff = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) );
$pruned = 0;
$gen_log = $wpdb->prefix . 'prautoblogger_generation_log';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$result = $wpdb->query(
$wpdb->prepare(
"UPDATE {$gen_log} SET request_json = NULL WHERE request_json IS NOT NULL AND created_at < %s",
$cutoff
)
);
$pruned += is_numeric( $result ) ? (int) $result : 0;
if ( PRAutoBlogger_Run_Stage_State::is_available() ) {
$stages = PRAutoBlogger_Run_Stage_State::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$result = $wpdb->query(
$wpdb->prepare(
"UPDATE {$stages} SET meta_json = NULL WHERE meta_json IS NOT NULL AND updated_at < %s",
$cutoff
)
);
$pruned += is_numeric( $result ) ? (int) $result : 0;
}
return $pruned;
}
}

View File

@@ -1,313 +0,0 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Per-run per-stage state machine over `wp_prautoblogger_run_stages`.
*
* 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.
*
* 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.
*
* @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 — Pipeline v2 Phase 1 substrate.
*/
class PRAutoBlogger_Run_Stage_State {
/** @var bool|null Per-request "run_stages table exists" probe result. */
private static ?bool $table_ok = null;
/** @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.
*
* @return bool
*/
public static function is_available(): bool {
if ( null !== self::$table_ok ) {
return self::$table_ok;
}
global $wpdb;
if ( null === $wpdb ) {
return false;
}
$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;
}
/**
* Stable 16-hex hash for one article idea within a run.
*
* @param PRAutoBlogger_Article_Idea $idea The idea.
* @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 ('' reserved for run-level stages).
*
* @param PRAutoBlogger_Article_Idea $idea The idea.
* @return string e.g. 'idea:1a2b3c4d5e6f7a8b'.
*/
public static function item_key_for_idea( PRAutoBlogger_Article_Idea $idea ): string {
return 'idea:' . self::idea_hash( $idea );
}
/**
* 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 || ! self::is_available() ) {
return;
}
$agent_role = self::resolve_role( $stage, $agent_role );
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
)
);
}
/**
* Mark a stage done and persist its output for resume-without-recharge.
*
* @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 || ! self::is_available() ) {
return;
}
$agent_role = self::resolve_role( $stage, $agent_role );
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
)
);
}
/**
* 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 || ! self::is_available() ) {
return;
}
$agent_role = self::resolve_role( $stage, $agent_role );
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
)
);
}
/**
* 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 ('' = resolve from stage map).
* @param string $item_key Article scope.
* @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 = self::resolve_role( $stage, $agent_role );
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",
$run_id,
$stage,
$agent_role,
$item_key
),
ARRAY_A
);
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;
}
/**
* Whether a stage already completed (resume entry-point check).
*
* @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.
* @return bool
*/
public static function is_done( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): bool {
$row = self::get( $run_id, $stage, $agent_role, $item_key );
return null !== $row && 'done' === $row['status'];
}
/**
* A done stage's stored output (null when absent, not done, or pruned).
*
* @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.
* @return string|null
*/
public static function get_output( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): ?string {
$row = self::get( $run_id, $stage, $agent_role, $item_key );
if ( null === $row || 'done' !== $row['status'] || 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;
}
/**
* 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 || ! 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 item_key = %s AND status IN ('pending','running')",
$now,
$now,
$run_id,
$item_key
)
);
}
/** Reset per-request caches (tests). */
public static function flush_cache(): void {
self::$table_ok = null;
}
/**
* 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).
*
* @param string $stage Stage name.
* @param string $agent_role Caller-supplied role ('' = auto-resolve).
* @return string Effective agent role.
*/
private 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

@@ -1,259 +0,0 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Run-level state: the `wp_prautoblogger_runs` ledger + lifecycle row.
*
* What: One row per pipeline run (keyed by run_id UUID) carrying the run
* status (pending|running|done|failed|halted), the per-run cost
* ledger the Cost_Governor reserves against (ceiling / reserved /
* settled / overage), and the prompt versions pinned at run start.
* Every method is self-healing: a missing table (half-migrated
* site) degrades to no-ops/empty reads, never a fatal. Final states
* are sticky — a halted or failed run cannot be flipped back to
* running by a late writer. Per-stage state lives in
* PRAutoBlogger_Run_Stage_State (run_stages table).
* Who triggers it: Cost_Tracker::set_run_id() (ensure_run + pinning),
* Cost_Governor (ledger + halt), Run_Reaper (stuck-run sweep),
* Review Queue (halted-run surfacing), Pipeline_Runner (completion).
* Dependencies: WordPress $wpdb, get_option (ceiling setting), wp_json_encode.
*
* @see core/class-cost-governor.php — Reserves against this row atomically.
* @see core/class-prompt-registry.php — Reads/writes pins via this class.
* @see core/class-run-stage-state.php — Per-stage rows (commit 4).
* @see ARCHITECTURE.md #21 — Design rationale.
*/
class PRAutoBlogger_Run_State {
/** @var bool|null Per-request "runs table exists" probe result. */
private static ?bool $table_ok = null;
/** Fully-qualified runs table name (shared with Cost_Governor). */
public static function table_name(): string {
global $wpdb;
return $wpdb->prefix . 'prautoblogger_runs';
}
/**
* Whether the runs table exists and is queryable. Cached per request;
* never throws.
*
* @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;
}
/**
* The per-run cost ceiling, read from the SETTING at call time
* (never a literal at the point of use). 0 disables the per-run guard.
*
* @return float Ceiling in USD.
*/
public static function ceiling_setting(): float {
return (float) get_option( 'prautoblogger_per_run_cost_ceiling_usd', PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD );
}
/**
* Create the run row if it does not exist (INSERT IGNORE — concurrent
* callers cannot double-create). Snapshots the ceiling setting at run
* start so a mid-run settings change cannot move the goalposts.
*
* Side effects: one INSERT IGNORE.
*
* @param string $run_id Pipeline run UUID.
* @return void
*/
public static function ensure_run( string $run_id ): 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 IGNORE INTO {$table}
(run_id, status, ceiling_usd, reserved_usd, settled_usd, overage_usd, started_at, updated_at)
VALUES (%s, 'running', %f, 0, 0, 0, %s, %s)",
$run_id,
self::ceiling_setting(),
$now,
$now
)
);
}
/**
* Fetch a run row.
*
* @param string $run_id Pipeline run UUID.
* @return array<string, mixed>|null Row, or null when absent/unavailable.
*/
public static function get_run( string $run_id ): ?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", $run_id ),
ARRAY_A
);
return is_array( $row ) ? $row : null;
}
/**
* Current run status, or null when the run has no row.
*
* @param string $run_id Pipeline run UUID.
* @return string|null pending|running|done|failed|halted, or null.
*/
public static function get_status( string $run_id ): ?string {
$run = self::get_run( $run_id );
return null !== $run ? (string) $run['status'] : null;
}
/**
* Store the pinned prompt versions — only if the run has none yet, so
* a resumed run keeps the versions it started with.
*
* @param string $run_id Pipeline run UUID.
* @param array<string, int> $pins prompt_key => version map.
* @return void
*/
public static function set_pins_if_absent( string $run_id, array $pins ): void {
if ( '' === $run_id || empty( $pins ) || ! self::is_available() ) {
return;
}
self::ensure_run( $run_id );
global $wpdb;
$table = self::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->query(
$wpdb->prepare(
"UPDATE {$table} SET pinned_prompts_json = %s, updated_at = %s
WHERE run_id = %s AND (pinned_prompts_json IS NULL OR pinned_prompts_json = '')",
wp_json_encode( $pins ),
current_time( 'mysql' ),
$run_id
)
);
}
/**
* The pinned prompt versions for a run.
*
* @param string $run_id Pipeline run UUID.
* @return array<string, int> prompt_key => version ({} when none).
*/
public static function get_pins( string $run_id ): array {
$run = self::get_run( $run_id );
if ( null === $run || empty( $run['pinned_prompts_json'] ) ) {
return array();
}
$pins = json_decode( (string) $run['pinned_prompts_json'], true );
return is_array( $pins ) ? array_map( 'intval', $pins ) : array();
}
/**
* Move a run to a new status. Forward transitions only touch open
* (pending/running) runs; `halted` and `failed` are sticky and `done`
* cannot overwrite them.
*
* @param string $run_id Pipeline run UUID.
* @param string $status One of done|failed|halted|running.
* @return bool True when a row transitioned.
*/
public static function mark_status( string $run_id, string $status ): bool {
if ( '' === $run_id || ! self::is_available() ) {
return false;
}
if ( ! in_array( $status, array( 'running', 'done', 'failed', 'halted' ), true ) ) {
return false;
}
global $wpdb;
$table = self::table_name();
$now = current_time( 'mysql' );
$finished = in_array( $status, array( 'done', 'failed', 'halted' ), true ) ? $now : null;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$updated = $wpdb->query(
$wpdb->prepare(
"UPDATE {$table} SET status = %s, updated_at = %s, finished_at = %s
WHERE run_id = %s AND status IN ('pending','running')",
$status,
$now,
$finished,
$run_id
)
);
return $updated >= 1;
}
/**
* Record by how much a breaching reservation exceeded the ceiling.
*
* @param string $run_id Pipeline run UUID.
* @param float $overage USD amount over the ceiling.
* @return void
*/
public static function record_overage( string $run_id, float $overage ): void {
if ( '' === $run_id || $overage <= 0 || ! self::is_available() ) {
return;
}
global $wpdb;
$table = self::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->query(
$wpdb->prepare(
"UPDATE {$table} SET overage_usd = GREATEST(overage_usd, %f), updated_at = %s WHERE run_id = %s",
$overage,
current_time( 'mysql' ),
$run_id
)
);
}
/**
* Recent halted runs, for the Review Queue surface.
*
* @param int $limit Max rows (default 10).
* @return array<int, array<string, mixed>>
*/
public static function halted_runs( int $limit = 10 ): array {
if ( ! self::is_available() ) {
return array();
}
global $wpdb;
$table = self::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$table} WHERE status = 'halted' ORDER BY updated_at DESC LIMIT %d",
$limit
),
ARRAY_A
);
return is_array( $rows ) ? $rows : array();
}
/** Reset per-request caches (tests). */
public static function flush_cache(): void {
self::$table_ok = null;
}
}

View File

@@ -76,33 +76,8 @@ class PRAutoBlogger_Source_Collector {
'collector'
);
} catch ( \Throwable $e ) {
// 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.
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.
}
}

View File

@@ -1,172 +0,0 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Display map for every generation_log / run_stages `stage` value, old and new.
*
* What: Single source of truth that renders historical stage values
* (analysis, outline, draft, polish, review, llm_research, image_a,
* image_b, image_prompt_rewrite, opik_eval_judge), the Pipeline v2
* vocabulary (research, curate, draft, editorial, seo, publish), and
* any unknown value (humanized fallback) coherently. Also maps each
* stage to its default agent role and primary prompt-registry key so
* audit stamping never hardcodes those associations at call sites.
* The `stage` column is VARCHAR — this PHP map is the vocabulary,
* there is no SQL enum.
* Who triggers it: Cost_Tracker (stamping), audit/metrics rendering, Run_State.
* Dependencies: none — pure static lookup.
*
* @see core/class-cost-tracker.php — Derives agent_role / prompt key when not passed.
* @see core/class-prompt-registry.php — Owns the prompt keys referenced here.
* @see ARCHITECTURE.md — Stage vocabulary (canonical edit-stage name: polish).
*/
class PRAutoBlogger_Stage_Display_Map {
/**
* Stage definitions: label (translated lazily), default agent role,
* and the primary prompt-registry key the stage renders with (null
* when the stage has no registry-managed prompt).
*
* @var array<string, array{label: string, role: ?string, prompt_key: ?string}>
*/
private const MAP = array(
// ── Historical vocabulary (rows that exist on prod) ─────────────
'analysis' => array(
'label' => 'Topic Analysis',
'role' => 'analyst',
'prompt_key' => 'analysis.system',
),
'outline' => array(
'label' => 'Outline',
'role' => 'writer',
'prompt_key' => 'content.outline',
),
'draft' => array(
'label' => 'Draft',
'role' => 'writer',
'prompt_key' => 'content.draft',
),
'polish' => array(
'label' => 'Polish',
'role' => 'writer',
'prompt_key' => 'content.polish',
),
'review' => array(
'label' => 'Editorial Review',
'role' => 'editor',
'prompt_key' => 'editor.system',
),
'llm_research' => array(
'label' => 'LLM Research',
'role' => 'researcher',
'prompt_key' => 'research.system',
),
'image_a' => array(
'label' => 'Image A',
'role' => 'illustrator',
'prompt_key' => 'image.style_template',
),
'image_b' => array(
'label' => 'Image B',
'role' => 'illustrator',
'prompt_key' => 'image.style_template',
),
'image_prompt_rewrite' => array(
'label' => 'Image Prompt Rewrite',
'role' => 'illustrator',
'prompt_key' => 'image.rewriter_system',
),
'opik_eval_judge' => array(
'label' => 'Opik Eval Judge',
'role' => 'judge',
'prompt_key' => null,
),
// ── Pipeline v2 vocabulary (new runs may write these) ───────────
'research' => array(
'label' => 'Research',
'role' => 'researcher',
'prompt_key' => 'research.system',
),
'curate' => array(
'label' => 'Curate',
'role' => 'curator',
'prompt_key' => null,
),
'editorial' => array(
'label' => 'Editorial Loop',
'role' => 'editor',
'prompt_key' => null,
),
'seo' => array(
'label' => 'SEO',
'role' => 'seo',
'prompt_key' => null,
),
'publish' => array(
'label' => 'Publish',
'role' => 'publisher',
'prompt_key' => null,
),
);
/**
* Human-readable label for a stage value. Unknown stages are humanized
* (underscores to spaces, words capitalized) so the audit view never
* renders blank for a value this map predates or postdates.
*
* @param string $stage Raw stage value from the database.
* @return string Translated display label.
*/
public static function label( string $stage ): string {
if ( isset( self::MAP[ $stage ] ) ) {
// phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText -- labels are a fixed const set.
return __( self::MAP[ $stage ]['label'], 'prautoblogger' );
}
return ucwords( str_replace( array( '_', '-' ), ' ', $stage ) );
}
/**
* Default agent role recorded for a stage when the call site does not
* pass one explicitly. Null for unknown stages (column stays NULL).
*
* @param string $stage Raw stage value.
* @return string|null Agent role slug, or null when unknown.
*/
public static function default_agent_role( string $stage ): ?string {
return self::MAP[ $stage ]['role'] ?? null;
}
/**
* Primary prompt-registry key a stage renders with. Used to resolve the
* pinned prompt_version stamped on generation_log rows when the call
* site does not pass an explicit key (e.g. single-pass passes
* 'content.single_pass' for its 'draft' row).
*
* @param string $stage Raw stage value.
* @return string|null Registry key, or null when the stage has none.
*/
public static function default_prompt_key( string $stage ): ?string {
return self::MAP[ $stage ]['prompt_key'] ?? null;
}
/**
* Whether the stage value is part of the known vocabulary.
*
* @param string $stage Raw stage value.
* @return bool True when the stage is a known historical or v2 value.
*/
public static function is_known( string $stage ): bool {
return isset( self::MAP[ $stage ] );
}
/**
* The full vocabulary, for audit listings and tests.
*
* @return array<string, array{label: string, role: ?string, prompt_key: ?string}>
*/
public static function all(): array {
return self::MAP;
}
}

10
includes/models/class-generation-log.php Normal file → Executable file
View File

@@ -26,8 +26,6 @@ class PRAutoBlogger_Generation_Log {
private ?string $request_json;
private string $response_status;
private ?string $error_message;
private ?string $agent_role;
private ?string $prompt_version;
private string $created_at;
/**
@@ -46,8 +44,6 @@ class PRAutoBlogger_Generation_Log {
$this->request_json = $data['request_json'] ?? null;
$this->response_status = $data['response_status'] ?? 'success';
$this->error_message = $data['error_message'] ?? null;
$this->agent_role = $data['agent_role'] ?? null;
$this->prompt_version = isset( $data['prompt_version'] ) ? (string) $data['prompt_version'] : null;
$this->created_at = $data['created_at'] ?? current_time( 'mysql' );
}
@@ -75,10 +71,6 @@ class PRAutoBlogger_Generation_Log {
return $this->response_status; }
public function get_error_message(): ?string {
return $this->error_message; }
public function get_agent_role(): ?string {
return $this->agent_role; }
public function get_prompt_version(): ?string {
return $this->prompt_version; }
public function get_created_at(): string {
return $this->created_at; }
@@ -98,8 +90,6 @@ class PRAutoBlogger_Generation_Log {
'request_json' => $this->request_json,
'response_status' => $this->response_status,
'error_message' => $this->error_message,
'agent_role' => $this->agent_role,
'prompt_version' => $this->prompt_version,
'created_at' => $this->created_at,
);
}

View File

@@ -70,7 +70,6 @@ class PRAutoBlogger_LLM_Research_Provider implements PRAutoBlogger_Source_Provid
'temperature' => 0.7,
'max_tokens' => 8000,
'response_format' => array( 'type' => 'json_object' ),
'stage' => 'llm_research',
)
);
@@ -124,9 +123,25 @@ class PRAutoBlogger_LLM_Research_Provider implements PRAutoBlogger_Source_Provid
* @return string
*/
private function build_system_prompt(): string {
// v0.18.0: renders from the versioned registry ('research.system')
// with a byte-identical in-code fallback.
return PRAutoBlogger_Prompt_Registry::render( 'research.system' );
return 'You are a deep research analyst specializing in emerging trends, '
. 'scientific developments, and community knowledge in niche health and '
. "biohacking domains. Your task is to identify substantive, actionable findings.\n\n"
. "Using your training knowledge, scan across:\n"
. "- Recent scientific literature and research directions\n"
. "- Active community discussions, debates, and evolving consensus\n"
. "- Emerging products, protocols, and methodologies gaining traction\n"
. "- Common questions, misconceptions, and points of confusion\n"
. "- Regulatory changes, safety concerns, and legal shifts\n"
. "- Gaps between what practitioners want to know and what content exists\n\n"
. "Your findings must be:\n"
. "1. SUBSTANTIVE — 2-3 paragraphs of detailed analysis per finding, not a headline\n"
. "2. ACTIONABLE — practical enough to seed a full article\n"
. "3. REALISTIC — grounded in what is actually discussed or researched; do NOT invent citations\n"
. "4. TIMELY — reflect current knowledge, recent shifts, or emerging questions\n\n"
. "Respond with valid JSON only (no preamble, no markdown fences):\n"
. '{"findings": [{"title": "string", "content": "string (2-3 paragraphs)", '
. '"relevance_score": 0-100, "category": "question|trend|comparison|guide|misconception|safety"}]}' . "\n\n"
. 'Generate 8-12 findings. Prioritize depth over breadth. Avoid generic or surface-level topics.';
}
/**

View File

@@ -1,168 +0,0 @@
<?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

@@ -132,9 +132,7 @@ class PRAutoBlogger_OpenRouter_Pricing {
/**
* Estimate the cost of an API call in USD.
*
* Uses hardcoded pricing first, falls back to cached model data. IDs
* carrying a date-snapshot suffix that miss both sources are retried
* once as their base ID (see resolve_pricing()).
* Uses hardcoded pricing first, falls back to cached model data.
*
* @param string $model Model identifier.
* @param int $prompt_tokens Input tokens.
@@ -148,7 +146,18 @@ class PRAutoBlogger_OpenRouter_Pricing {
return 0.0;
}
$pricing = $this->resolve_pricing( $model );
$pricing = self::MODEL_PRICING[ $model ] ?? null;
// Fall back to cached model list pricing.
if ( null === $pricing ) {
$models = $this->get_available_models();
foreach ( $models as $m ) {
if ( $m['id'] === $model && isset( $m['pricing'] ) ) {
$pricing = $m['pricing'];
break;
}
}
}
if ( null === $pricing ) {
// Unknown model — use a conservative estimate.
@@ -163,82 +172,6 @@ class PRAutoBlogger_OpenRouter_Pricing {
+ ( $completion_tokens * $pricing['completion'] / 1000000 );
}
/**
* Resolve pricing for a model ID, normalizing dated snapshot IDs.
*
* Tries the exact ID first. On a miss, strips a trailing date-snapshot
* suffix and retries once with the base ID: OpenRouter responses can
* name a dated snapshot of the requested model (e.g. request
* 'deepseek/deepseek-v4-flash', response model
* 'deepseek/deepseek-v4-flash-20260423') while pricing sources only
* list the base ID. Every cost path (governor reserve/settle and
* generation-log booking) prices through this seam, so all of them
* survive snapshot IDs instead of booking the conservative estimate.
*
* Side effects: may fetch the models list over HTTP on cache miss
* (via get_available_models()).
*
* @param string $model Model ID as requested, or as returned by the API.
*
* @return array{prompt: float, completion: float}|null Per-million USD pricing, or null when unknown.
*/
private function resolve_pricing( string $model ): ?array {
$pricing = $this->lookup_pricing( $model );
if ( null !== $pricing ) {
return $pricing;
}
$base = $this->strip_snapshot_suffix( $model );
if ( $base !== $model ) {
return $this->lookup_pricing( $base );
}
return null;
}
/**
* Look up one exact model ID: hardcoded table first, then cached model list.
*
* Side effects: may fetch the models list over HTTP on cache miss
* (via get_available_models()).
*
* @param string $model Model ID (exact match, no normalization).
*
* @return array{prompt: float, completion: float}|null Per-million USD pricing, or null on miss.
*/
private function lookup_pricing( string $model ): ?array {
$pricing = self::MODEL_PRICING[ $model ] ?? null;
if ( null !== $pricing ) {
return $pricing;
}
foreach ( $this->get_available_models() as $m ) {
if ( $m['id'] === $model && isset( $m['pricing'] ) ) {
return $m['pricing'];
}
}
return null;
}
/**
* Strip a trailing date-snapshot suffix (e.g. '-20260423') from a model ID.
*
* Only a dash followed by an 8-digit, 20-prefixed date at the very end
* is treated as a snapshot suffix. Any other trailing digit run (e.g.
* 'foo-202612345') is part of the model name and left untouched.
*
* Side effects: none — pure string transformation.
*
* @param string $model Model ID.
*
* @return string Base model ID, or the input unchanged when no snapshot suffix is present.
*/
private function strip_snapshot_suffix( string $model ): string {
$base = preg_replace( '/-20\d{6}$/', '', $model );
return is_string( $base ) ? $base : $model;
}
/**
* Get the decrypted API key from options.
*

View File

@@ -11,17 +11,14 @@ declare(strict_types=1);
* separate provider integrations.
*
* Triggered by: Content_Analyzer, Content_Generator, Chief_Editor, Metrics_Collector.
* Dependencies: PRAutoBlogger_Encryption (for API key decryption), wp_remote_post(),
* PRAutoBlogger_OpenRouter_Pricing, PRAutoBlogger_Cost_Governor (per-run
* reserve-before-call guard around every chat completion).
* Dependencies: PRAutoBlogger_Encryption (for API key decryption), wp_remote_post(), PRAutoBlogger_OpenRouter_Pricing.
*
* @see interface-llm-provider.php — Interface this class implements.
* @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 body/header building + cURL filter.
* @see class-open-router-request-builder.php — Request 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.
*/
@@ -41,15 +38,8 @@ 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, 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.
* reasoning?: array{enabled: bool, effort?: string},
* } $options Optional parameters. Pass 'reasoning' to override the global setting.
*
* @return array{
* content: string,
@@ -60,39 +50,56 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
* finish_reason: string,
* }
*
* @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).
* @throws \RuntimeException On API error after retries exhausted.
*/
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 = $builder->resolve_api_key();
// 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 );
// 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).
// 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'];
$api_key = $this->get_api_key();
if ( '' === $api_key ) {
throw new \RuntimeException(
__( 'OpenRouter API key is not configured. Go to PRAutoBlogger → Settings.', 'prautoblogger' )
);
}
// Validate key format — OpenRouter keys start with "sk-or-".
// A key that decrypts to garbage (e.g. after a salt change) won't match.
if ( 0 !== strpos( $api_key, 'sk-or-' ) ) {
PRAutoBlogger_Logger::instance()->error(
sprintf(
'Decrypted API key has unexpected format (prefix="%s", len=%d). Re-enter your key in settings.',
substr( $api_key, 0, 6 ),
strlen( $api_key )
),
'openrouter'
);
throw new \RuntimeException(
__( 'OpenRouter API key appears corrupted (unexpected format). Please re-enter your key in PRAutoBlogger → Settings.', 'prautoblogger' )
);
}
$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' ),
);
}
$reservation = PRAutoBlogger_Cost_Governor::open_chat_reservation( $model, $messages, $govern_options );
$last_error = '';
@@ -102,6 +109,7 @@ 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 );
@@ -183,28 +191,8 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
);
}
// Success — parse response, settle the reservation to actuals.
$parsed = $parser->parse_success( $data, $model );
PRAutoBlogger_Cost_Governor::settle(
$reservation,
$this->estimate_cost( $model, (int) $parsed['prompt_tokens'], (int) $parsed['completion_tokens'] )
);
$reservation = null;
// 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 );
}
);
// Success — parse response.
return $parser->parse_success( $data, $model );
}
// All retries exhausted.
@@ -219,9 +207,6 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
} finally {
// Always clean up the cURL filter to avoid leaking into other requests.
remove_action( 'http_api_curl', $curl_auth_filter, 99 );
// Release any reservation still open (failure/exception path) so
// a failed call does not permanently hold its worst-case estimate.
PRAutoBlogger_Cost_Governor::release( $reservation );
}
}
@@ -290,4 +275,17 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
public function validate_credentials_detailed(): array {
return ( new PRAutoBlogger_OpenRouter_Validator() )->run();
}
/**
* Get the decrypted API key from options.
*
* @return string Decrypted API key, or empty string if not set.
*/
private function get_api_key(): string {
$encrypted = get_option( 'prautoblogger_openrouter_api_key', '' );
if ( '' === $encrypted ) {
return '';
}
return PRAutoBlogger_Encryption::decrypt( $encrypted );
}
}

View File

@@ -18,100 +18,6 @@ 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.
*
@@ -172,43 +78,4 @@ class PRAutoBlogger_OpenRouter_Request_Builder {
add_action( 'http_api_curl', $curl_auth_filter, 99, 3 );
return $curl_auth_filter;
}
/**
* Fetch, decrypt, and format-validate the OpenRouter API key.
*
* Moved from OpenRouter_Provider in v0.18.0 (300-line cap); the
* messages and behavior are unchanged.
*
* @return string Validated plaintext API key.
* @throws \RuntimeException When the key is missing or has an
* unexpected format (corrupted decryption).
*/
public function resolve_api_key(): string {
$encrypted = get_option( 'prautoblogger_openrouter_api_key', '' );
$api_key = '' === $encrypted ? '' : PRAutoBlogger_Encryption::decrypt( $encrypted );
if ( '' === $api_key ) {
throw new \RuntimeException(
__( 'OpenRouter API key is not configured. Go to PRAutoBlogger → Settings.', 'prautoblogger' )
);
}
// Validate key format — OpenRouter keys start with "sk-or-".
// A key that decrypts to garbage (e.g. after a salt change) won't match.
if ( 0 !== strpos( $api_key, 'sk-or-' ) ) {
PRAutoBlogger_Logger::instance()->error(
sprintf(
'Decrypted API key has unexpected format (prefix="%s", len=%d). Re-enter your key in settings.',
substr( $api_key, 0, 6 ),
strlen( $api_key )
),
'openrouter'
);
throw new \RuntimeException(
__( 'OpenRouter API key appears corrupted (unexpected format). Please re-enter your key in PRAutoBlogger → Settings.', 'prautoblogger' )
);
}
return $api_key;
}
}

View File

@@ -1,211 +0,0 @@
<?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.
*/
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 ) {
throw new \RuntimeException(
sprintf( 'Runware modelSearch returned HTTP %d', $status )
);
}
$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

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

View File

@@ -4,30 +4,18 @@ declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Runware model catalog sync — orchestrates periodic fetching, caching, failure
* cooldown, and fallback for the Runware model list.
* Runware model catalog sync — fetches live model list via Runware's /v1 endpoint,
* filters to text-to-image tasks, caches normalized results, and falls back to
* hardcoded list on API failure.
*
* What: Syncs Runware's model catalog (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.
* What: Syncs Runware's model catalog periodically (daily cron + on-demand AJAX),
* caches the results in WP options, and merges pricing data from the pricing
* class when Runware's API doesn't expose it.
* Who triggers it: Daily cron (prautoblogger_sync_runware_models), AJAX "Sync now"
* button in PRAdmin, and lazy-load via PRAutoBlogger_Image_Model_Registry.
* Dependencies: PRAutoBlogger_Runware_Catalog_Fetcher, PRAutoBlogger_Logger,
* PRAutoBlogger_Runware_Image_Pricing, PRAutoBlogger_Image_Model_Registry.
* Dependencies: 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.
@@ -35,27 +23,20 @@ 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;
/**
* 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.
* Fetch the Runware model catalog, normalize to the Image_Model_Registry
* shape, and write to WP options cache. Returns true on success, false on
* API failure or parse error.
*
* Side effects: makes HTTP request to Runware API, writes WP options.
* Error handling: logs via PRAutoBlogger_Logger, never throws, always
* returns bool to allow fallback to cached/hardcoded list.
* returns bool to allow fallback to cached list.
*
* @return bool True on successful fetch and cache update; false otherwise.
* @return bool True on successful fetch and cache update; false on API or parse error.
*/
public function sync(): bool {
try {
@@ -68,19 +49,16 @@ class PRAutoBlogger_Runware_Model_Catalog {
return false;
}
$fetcher = new PRAutoBlogger_Runware_Catalog_Fetcher();
$raw_models = $fetcher->fetch_all_text_to_image( $api_key );
$raw_models = $this->fetch_models_from_api( $api_key );
if ( empty( $raw_models ) ) {
PRAutoBlogger_Logger::instance()->warning(
'Runware model catalog fetch returned empty list. Using cached/fallback models.',
'runware-catalog'
);
$this->record_failure();
return false;
}
$normalized = $fetcher->normalize_models( $raw_models );
$normalized = $this->normalize_models( $raw_models );
$with_pricing = $this->merge_pricing( $normalized );
if ( empty( $with_pricing ) ) {
@@ -88,13 +66,11 @@ 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( self::CACHE_OPTION, $with_pricing, false );
update_option( self::CACHE_TS_OPTION, time(), false );
delete_option( self::FAILURE_TS_OPTION );
update_option( 'prautoblogger_runware_model_cache', $with_pricing, false );
update_option( 'prautoblogger_runware_model_cache_updated_at', time(), false );
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Runware model catalog synced: %d models cached.', count( $with_pricing ) ),
@@ -106,37 +82,38 @@ 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;
}
}
/**
* 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).
* Get the normalized model list, with smart caching logic:
* - If cache is fresh (< 24h), return it.
* - If cache is stale or absent, trigger a sync.
* - If sync fails, return the last-known-good cache.
* - If no cache exists, return the hardcoded fallback (never empty).
*
* @return array<int, array<string, mixed>> Normalized model list.
*/
public function get_models(): array {
if ( ! $this->is_stale() ) {
$cached = get_option( self::CACHE_OPTION, array() );
$cached = get_option( 'prautoblogger_runware_model_cache', array() );
if ( is_array( $cached ) && ! empty( $cached ) ) {
return $cached;
}
}
if ( ! $this->is_in_failure_cooldown() && $this->sync() ) {
$cached = get_option( self::CACHE_OPTION, array() );
if ( $this->sync() ) {
$cached = get_option( 'prautoblogger_runware_model_cache', array() );
if ( is_array( $cached ) && ! empty( $cached ) ) {
return $cached;
}
}
$cached = get_option( self::CACHE_OPTION, array() );
$cached = get_option( 'prautoblogger_runware_model_cache', array() );
if ( is_array( $cached ) && ! empty( $cached ) ) {
PRAutoBlogger_Logger::instance()->info(
'Using stale Runware model cache (live sync failed or suppressed by cooldown).',
'Using stale Runware model cache (live sync failed).',
'runware-catalog'
);
return $cached;
@@ -154,12 +131,12 @@ class PRAutoBlogger_Runware_Model_Catalog {
}
/**
* Unix timestamp of the last successful sync, or null if never synced.
* Get the Unix timestamp of the last successful sync, or null if never synced.
*
* @return int|null
* @return int|null Unix timestamp, or null if never synced.
*/
public function get_last_synced_at(): ?int {
$ts = get_option( self::CACHE_TS_OPTION, null );
$ts = get_option( 'prautoblogger_runware_model_cache_updated_at', null );
if ( null === $ts ) {
return null;
}
@@ -168,23 +145,9 @@ class PRAutoBlogger_Runware_Model_Catalog {
}
/**
* Unix timestamp of the last sync failure, or null if no failure on record.
* Check if the cache is stale (older than 24h) or absent.
*
* @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.
* @return bool True if cache should be refreshed; false if still fresh.
*/
public function is_stale(): bool {
$updated_at = $this->get_last_synced_at();
@@ -195,51 +158,123 @@ class PRAutoBlogger_Runware_Model_Catalog {
}
/**
* Whether a sync attempt should be suppressed due to a recent failure.
* Fetch the raw model list from Runware's /v1 endpoint via a models task.
*
* 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.
* @param string $api_key Decrypted Runware API key.
* @return array Raw model list (may be empty).
* @throws \RuntimeException On HTTP error or malformed response.
*/
public function is_in_failure_cooldown(): bool {
$last_failure = $this->get_last_failure_at();
if ( null === $last_failure ) {
return false;
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() )
);
}
$cooldown = (int) get_option( self::COOLDOWN_SETTING, self::COOLDOWN_DEFAULT );
if ( $cooldown <= 0 ) {
$cooldown = self::COOLDOWN_DEFAULT;
$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 )
);
}
return ( time() - $last_failure ) < $cooldown;
$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();
}
/**
* Merge pricing from PRAutoBlogger_Runware_Image_Pricing into normalized models.
* Models without a pricing entry get cost_per_image=null.
* Normalize raw Runware model objects to the PRAutoBlogger_Image_Model_Registry
* shape: id, name, provider, cost_per_image, capabilities, description.
*
* Filters to taskType='imageInference' (text-to-image only), excluding
* inpainting (imageInference + requiresImage=true), img2img, video, upscalers.
*
* @param array<int, array<string, mixed>> $raw_models Raw API response.
* @return array<int, array<string, mixed>> Normalized models.
*/
private function normalize_models( array $raw_models ): array {
$normalized = array();
foreach ( $raw_models as $raw ) {
if ( ! is_array( $raw ) ) {
continue;
}
$task_type = $raw['taskType'] ?? '';
if ( 'imageInference' !== $task_type ) {
continue;
}
if ( isset( $raw['requiresImage'] ) && true === $raw['requiresImage'] ) {
continue;
}
$model_id = (string) ( $raw['id'] ?? '' );
if ( '' === $model_id ) {
continue;
}
$normalized[] = array(
'id' => $model_id,
'name' => (string) ( $raw['name'] ?? $model_id ),
'provider' => 'runware',
'cost_per_image' => null,
'capabilities' => array( 'image_generation' ),
'description' => (string) ( $raw['description'] ?? '' ),
);
}
return $normalized;
}
/**
* Merge pricing data from PRAutoBlogger_Runware_Image_Pricing into the
* normalized catalog. Models without pricing data get cost_per_image=null.
*
* @param array<int, array<string, mixed>> $normalized Normalized models.
* @return array<int, array<string, mixed>> Models with pricing merged.
*/
private function merge_pricing( array $normalized ): array {
$pricing_table = PRAutoBlogger_Runware_Image_Pricing::get_model_costs();
foreach ( $normalized as &$model ) {
$model_id = $model['id'] ?? '';
if ( isset( $pricing_table[ $model_id ] ) ) {
$model['cost_per_image'] = (float) $pricing_table[ $model_id ];
}
}
return $normalized;
}
/**
* 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 );
return $normalized;
}
/**
@@ -252,10 +287,12 @@ 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,7 +82,6 @@ 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.19.2
* Version: 0.17.0
* Requires at least: 6.0
* Requires PHP: 7.4
* Author: PeptideRepo
@@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) {
| Defined here so every file in the plugin can reference paths, versions,
| and limits without magic strings.
*/
define( 'PRAUTOBLOGGER_VERSION', '0.19.2' );
define( 'PRAUTOBLOGGER_VERSION', '0.17.0' );
define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' );
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
@@ -107,7 +107,6 @@ 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';
/*
|--------------------------------------------------------------------------

View File

@@ -1,160 +0,0 @@
<?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>
</h1>
<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

@@ -1,224 +0,0 @@
<?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>
</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';
}
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>
<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

@@ -1,126 +0,0 @@
<?php
/**
* Partial: single stage section for the Article Dossier.
*
* Variables injected by dossier-page.php:
* $stage_row (array) -- run_stages row incl. display_output
* $stage_name (string) -- stage key
* $log_rows (array) -- generation_log rows for this stage
*
* 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 );
?>
<div class="prab-dossier-section prab-dossier-section--stage prab-stage-status--<?php echo esc_attr( $s_status ); ?>"
data-stage="<?php echo esc_attr( $stage_name ); ?>"
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 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; ?>
</div><!-- .prab-dossier-section--stage -->

View File

@@ -1,41 +0,0 @@
<?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>

56
templates/admin/review-queue.php Normal file → Executable file
View File

@@ -18,37 +18,6 @@ $bulk_type = isset( $_GET['prautoblogger_bulk_type'] ) ? sanitize_text_field( wp
<div class="wrap prautoblogger-review-queue">
<h1><?php esc_html_e( 'PRAutoBlogger — Review Queue', 'prautoblogger' ); ?></h1>
<?php if ( ! empty( $halted_runs ) ) : ?>
<div class="notice notice-error">
<p>
<strong><?php esc_html_e( 'Runs halted by the per-run cost ceiling', 'prautoblogger' ); ?></strong>
<?php esc_html_e( '— these runs were stopped before exceeding their budget and need human review. Raise the ceiling in Settings or discard the run.', 'prautoblogger' ); ?>
</p>
<table class="widefat striped" style="margin-bottom: 8px;">
<thead>
<tr>
<th><?php esc_html_e( 'Run ID', 'prautoblogger' ); ?></th>
<th><?php esc_html_e( 'Halted at', 'prautoblogger' ); ?></th>
<th><?php esc_html_e( 'Spent + held (USD)', 'prautoblogger' ); ?></th>
<th><?php esc_html_e( 'Ceiling (USD)', 'prautoblogger' ); ?></th>
<th><?php esc_html_e( 'Blocked overage (USD)', 'prautoblogger' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $halted_runs as $halted_run ) : ?>
<tr>
<td><code><?php echo esc_html( (string) $halted_run['run_id'] ); ?></code></td>
<td><?php echo esc_html( (string) ( $halted_run['finished_at'] ?? $halted_run['updated_at'] ) ); ?></td>
<td><?php echo esc_html( number_format( (float) $halted_run['settled_usd'] + (float) $halted_run['reserved_usd'], 4 ) ); ?></td>
<td><?php echo esc_html( number_format( (float) $halted_run['ceiling_usd'], 2 ) ); ?></td>
<td><?php echo esc_html( number_format( (float) $halted_run['overage_usd'], 4 ) ); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<?php if ( $bulk_done > 0 && '' !== $bulk_type ) : ?>
<div class="notice notice-success is-dismissible">
<p>
@@ -117,10 +86,7 @@ $bulk_type = isset( $_GET['prautoblogger_bulk_type'] ) ? sanitize_text_field( wp
</tr>
</thead>
<tbody>
<?php
while ( $query->have_posts() ) :
$query->the_post();
?>
<?php while ( $query->have_posts() ) : $query->the_post(); ?>
<?php
$post_id = get_the_ID();
$verdict = get_post_meta( $post_id, '_prautoblogger_editor_verdict', true );
@@ -173,18 +139,14 @@ $bulk_type = isset( $_GET['prautoblogger_bulk_type'] ) ? sanitize_text_field( wp
if ( $total_pages > 1 ) :
$current_page = isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1;
echo '<div class="prautoblogger-pagination">';
echo wp_kses_post(
paginate_links(
array(
'base' => add_query_arg( 'paged', '%#%' ),
'format' => '',
'current' => $current_page,
'total' => $total_pages,
'prev_text' => '&laquo;',
'next_text' => '&raquo;',
)
)
);
echo wp_kses_post( paginate_links( [
'base' => add_query_arg( 'paged', '%#%' ),
'format' => '',
'current' => $current_page,
'total' => $total_pages,
'prev_text' => '&laquo;',
'next_text' => '&raquo;',
] ) );
echo '</div>';
endif;
?>

57
tests/bootstrap.php Normal file → Executable file
View File

@@ -21,17 +21,6 @@ if ( ! defined( 'PRAB_PLUGIN_DIR' ) ) {
define( 'PRAB_PLUGIN_DIR', dirname( __DIR__ ) . '/' );
}
// Pipeline v2 Phase 1 defaults (source of truth: prautoblogger.php).
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD' ) ) {
define( 'PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD', 0.50 );
}
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS' ) ) {
define( 'PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS', 14 );
}
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_REASONING_MAX_TOKENS' ) ) {
define( 'PRAUTOBLOGGER_DEFAULT_REASONING_MAX_TOKENS', 2048 );
}
// WordPress time constants used by CostTracker and ContentAnalyzer.
if ( ! defined( 'DAY_IN_SECONDS' ) ) {
define( 'DAY_IN_SECONDS', 86400 );
@@ -103,17 +92,12 @@ if ( ! defined( 'OBJECT' ) ) {
/**
* Minimal WP_Query stub for unit tests.
*
* IdeaScorer uses WP_Query to check for existing posts with empty results.
* Board data provider tests inject posts via WP_Query::$_test_posts_queue
* (static FIFO): push arrays of WP_Post objects before instantiating the
* provider, and each new WP_Query() call will dequeue one batch.
* IdeaScorer uses WP_Query to check for existing posts.
* This stub returns empty results by default.
*/
if ( ! class_exists( 'WP_Query' ) ) {
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound
class WP_Query {
/** @var array<int, array> FIFO queue of post-arrays for tests. */
public static $_test_posts_queue = [];
/** @var array */
public $posts = [];
/** @var int */
@@ -122,11 +106,7 @@ if ( ! class_exists( 'WP_Query' ) ) {
public $have_posts = false;
public function __construct( $args = [] ) {
if ( ! empty( self::$_test_posts_queue ) ) {
$this->posts = array_shift( self::$_test_posts_queue );
$this->found_posts = count( $this->posts );
$this->have_posts = $this->found_posts > 0;
}
// No-op for testing — returns no posts.
}
public function have_posts(): bool {
@@ -174,34 +154,3 @@ if ( ! class_exists( 'WP_Error' ) ) {
}
}
}
/**
* Minimal WP_Post stub for unit tests.
*
* Board data provider tests return WP_Post instances from WP_Query.
* Class defined here so instanceof checks in production code work correctly.
*/
if ( ! class_exists( 'WP_Post' ) ) {
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound
class WP_Post {
/** @var int */
public $ID = 0;
/** @var string */
public $post_title = '';
/** @var string */
public $post_status = 'publish';
/** @var string */
public $post_date_gmt = '';
/** @var string */
public $post_modified_gmt = '';
/**
* @param array<string, mixed> $data
*/
public function __construct( array $data = [] ) {
foreach ( $data as $key => $value ) {
$this->$key = $value;
}
}
}
}

View File

@@ -1,131 +0,0 @@
{
"data": [
{
"taskType": "modelSearch",
"taskUUID": "aabbccdd-0000-0000-0000-000000000001",
"totalResults": 5,
"results": [
{
"air": "runware:100@1",
"name": "FLUX.1 schnell",
"nameContent": "FLUX.1 schnell",
"comment": "Fast 4-step text-to-image model",
"category": "checkpoint",
"private": false,
"capabilities": ["io:text-to-image", "form:checkpoint"],
"tags": [],
"heroImage": "",
"version": "",
"architecture": "",
"nsfwLevel": null,
"downloadCount": 0,
"inferenceCount24h": 0,
"rating": 0,
"ratingCount": 0,
"thumbsUpCount": 0,
"thumbsDownCount": 0,
"featured": 1,
"addedUnixTimestamp": 0,
"updatedDateUnixTimestamp": 0,
"source": "curated"
},
{
"air": "runware:101@1",
"name": "FLUX.1 dev",
"nameContent": "FLUX.1 dev",
"comment": "High fidelity 28-step text-to-image model",
"category": "checkpoint",
"private": false,
"capabilities": ["io:text-to-image", "form:checkpoint"],
"tags": [],
"heroImage": "",
"version": "",
"architecture": "",
"nsfwLevel": null,
"downloadCount": 0,
"inferenceCount24h": 0,
"rating": 0,
"ratingCount": 0,
"thumbsUpCount": 0,
"thumbsDownCount": 0,
"featured": 0,
"addedUnixTimestamp": 0,
"updatedDateUnixTimestamp": 0,
"source": "curated"
},
{
"air": "some-text-model:llm@1",
"name": "Some Text LLM",
"nameContent": "Some Text LLM",
"comment": "A text-to-text model that should be excluded",
"category": "text",
"private": false,
"capabilities": ["io:text-to-text", "form:checkpoint"],
"tags": [],
"heroImage": "",
"version": "",
"architecture": "",
"nsfwLevel": null,
"downloadCount": 0,
"inferenceCount24h": 0,
"rating": 0,
"ratingCount": 0,
"thumbsUpCount": 0,
"thumbsDownCount": 0,
"featured": 0,
"addedUnixTimestamp": 0,
"updatedDateUnixTimestamp": 0,
"source": "curated"
},
{
"air": "runware:400@2",
"name": "FLUX.2 klein 9B",
"nameContent": "FLUX.2 klein 9B",
"comment": "Compact 9B text-to-image model",
"category": "checkpoint",
"private": false,
"capabilities": ["io:text-to-image", "form:checkpoint"],
"tags": [],
"heroImage": "",
"version": "",
"architecture": "",
"nsfwLevel": null,
"downloadCount": 0,
"inferenceCount24h": 0,
"rating": 0,
"ratingCount": 0,
"thumbsUpCount": 0,
"thumbsDownCount": 0,
"featured": 0,
"addedUnixTimestamp": 0,
"updatedDateUnixTimestamp": 0,
"source": "curated"
},
{
"air": "upscale-model:upscaler@1",
"name": "Upscale Model",
"nameContent": "Upscale Model",
"comment": "Image upscaler — should be excluded from t2i list",
"category": "checkpoint",
"private": false,
"capabilities": ["io:image-to-image", "op:upscale", "form:checkpoint"],
"tags": [],
"heroImage": "",
"version": "",
"architecture": "",
"nsfwLevel": null,
"downloadCount": 0,
"inferenceCount24h": 0,
"rating": 0,
"ratingCount": 0,
"thumbsUpCount": 0,
"thumbsDownCount": 0,
"featured": 0,
"addedUnixTimestamp": 0,
"updatedDateUnixTimestamp": 0,
"source": "curated"
}
]
}
]
}

View File

@@ -1,300 +0,0 @@
<?php
/**
* Tests for PRAutoBlogger_Board_Data_Provider — column/state bucket mapping.
*
* Seeds mock DB results and WordPress post data, then asserts that each
* board column receives the correct cards. No live DB required; uses Brain\Monkey
* and a mock $wpdb.
*
* @package PRAutoBlogger\Tests\Admin
*/
namespace PRAutoBlogger\Tests\Admin;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class BoardDataProviderTest extends BaseTestCase {
/** @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb. */
private $wpdb;
protected function setUp(): void {
parent::setUp();
$this->wpdb = $this->create_mock_wpdb();
$GLOBALS['wpdb'] = $this->wpdb;
// Reset WP_Query test queue before each test.
\WP_Query::$_test_posts_queue = [];
Functions\when( 'get_option' )->alias(
function ( string $name, $default = false ) {
$map = array(
'prautoblogger_board_published_window_days' => 7,
'prautoblogger_board_poll_interval' => 5,
);
return $map[ $name ] ?? $default;
}
);
// Transient stub: by default no active run.
Functions\when( 'get_transient' )->justReturn( false );
// Admin URL stub.
Functions\when( 'admin_url' )->alias(
function ( string $path = '' ) {
return 'https://test.example.com/wp-admin/' . ltrim( $path, '/' );
}
);
// DAY_IN_SECONDS constant.
if ( ! defined( 'DAY_IN_SECONDS' ) ) {
define( 'DAY_IN_SECONDS', 86400 );
}
}
protected function tearDown(): void {
\WP_Query::$_test_posts_queue = [];
unset( $GLOBALS['wpdb'] );
parent::tearDown();
}
// ── Generating cards ────────────────────────────────────────────────────
/**
* A running transient with status='running' yields a Generating card.
*/
public function test_generating_cards_from_transient(): void {
Functions\when( 'get_transient' )->alias(
function ( $key ) {
if ( 'prautoblogger_generation_status' === $key ) {
return array(
'status' => 'running',
'stage' => 'Drafting article…',
'started' => time() - 60,
);
}
return false;
}
);
// No gen_log secondary runs needed.
$this->wpdb->method( 'prepare' )->willReturn( 'SELECT 1' );
$this->wpdb->method( 'get_results' )->willReturn( array() );
$provider = new \PRAutoBlogger_Board_Data_Provider();
$cards = $provider->get_generating_cards();
$this->assertCount( 1, $cards );
$this->assertSame( 'Drafting article…', $cards[0]['stage_current'] );
$this->assertSame( 'logs', $cards[0]['click_action'] );
}
/**
* When the transient is idle, orphan gen_log rows produce Generating cards.
*/
public function test_generating_cards_from_gen_log(): void {
Functions\when( 'get_transient' )->justReturn( false );
$this->wpdb->method( 'prepare' )->willReturn( 'SELECT 1' );
$this->wpdb->method( 'get_results' )->willReturn(
array(
array(
'run_id' => 'abc-123',
'started_at' => gmdate( 'Y-m-d H:i:s', time() - 120 ),
'last_stage' => 'draft',
'stage_count' => '3',
'cost_total' => '0.005000',
),
)
);
$provider = new \PRAutoBlogger_Board_Data_Provider();
$cards = $provider->get_generating_cards();
$this->assertCount( 1, $cards );
$this->assertSame( 'abc-123', $cards[0]['run_id'] );
$this->assertSame( 'draft', $cards[0]['stage_current'] );
$this->assertEqualsWithDelta( 0.005, $cards[0]['cost_total'], 0.0001 );
}
/**
* An idle transient + empty gen_log yields zero Generating cards.
*/
public function test_no_generating_cards_when_idle(): void {
Functions\when( 'get_transient' )->justReturn( false );
$this->wpdb->method( 'prepare' )->willReturn( 'SELECT 1' );
$this->wpdb->method( 'get_results' )->willReturn( array() );
$provider = new \PRAutoBlogger_Board_Data_Provider();
$cards = $provider->get_generating_cards();
$this->assertCount( 0, $cards );
}
// ── In Review cards ─────────────────────────────────────────────────────
/**
* Draft posts with the _prautoblogger_generated meta appear in In Review.
*/
public function test_in_review_cards_from_draft_posts(): void {
$post = new \WP_Post();
$post->ID = 42;
$post->post_title = 'Test Draft Post';
$post->post_date_gmt = '2026-06-12 10:00:00';
$post->post_status = 'draft';
// Inject post into WP_Query queue: first WP_Query() call returns this post.
\WP_Query::$_test_posts_queue[] = array( $post );
Functions\when( 'get_post_meta' )->alias(
function ( $post_id, $key, $single ) use ( $post ) {
if ( $post_id === $post->ID ) {
if ( '_prautoblogger_run_id' === $key ) {
return 'run-draft-999';
}
if ( '_prautoblogger_total_cost' === $key ) {
return '0.012000';
}
}
return '';
}
);
Functions\when( 'get_edit_post_link' )->justReturn(
'https://test.example.com/wp-admin/post.php?post=42&action=edit'
);
$provider = new \PRAutoBlogger_Board_Data_Provider();
$cards = $provider->get_in_review_cards();
$this->assertCount( 1, $cards );
$this->assertSame( 42, $cards[0]['post_id'] );
$this->assertSame( 'Test Draft Post', $cards[0]['title'] );
// M2: primary click_action is dossier; secondary review_url/edit_url still present.
$this->assertSame( 'dossier', $cards[0]['click_action'] );
$this->assertStringContainsString( 'prautoblogger-dossier&post_id=42', $cards[0]['dossier_url'] );
$this->assertArrayHasKey( 'review_url', $cards[0] );
$this->assertArrayHasKey( 'edit_url', $cards[0] );
$this->assertEqualsWithDelta( 0.012, $cards[0]['cost_total'], 0.0001 );
}
// ── Published cards ─────────────────────────────────────────────────────
/**
* Published posts within the window appear in Published.
*/
public function test_published_cards_within_window(): void {
$post = new \WP_Post();
$post->ID = 77;
$post->post_title = 'Published Article';
$post->post_date_gmt = gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS );
$post->post_status = 'publish';
// Inject post into WP_Query queue: first WP_Query() call returns this post.
\WP_Query::$_test_posts_queue[] = array( $post );
Functions\when( 'get_post_meta' )->alias(
function ( $post_id, $key, $single ) use ( $post ) {
if ( $post_id === $post->ID ) {
if ( '_prautoblogger_run_id' === $key ) {
return 'run-pub-77';
}
if ( '_prautoblogger_total_cost' === $key ) {
return '0.020000';
}
}
return '';
}
);
Functions\when( 'get_edit_post_link' )->justReturn(
'https://test.example.com/wp-admin/post.php?post=77&action=edit'
);
Functions\when( 'get_permalink' )->justReturn( 'https://test.example.com/?p=77' );
$provider = new \PRAutoBlogger_Board_Data_Provider();
$cards = $provider->get_published_cards();
$this->assertCount( 1, $cards );
$this->assertSame( 77, $cards[0]['post_id'] );
// M2: primary click_action is dossier; secondary edit_url/post_url still present.
$this->assertSame( 'dossier', $cards[0]['click_action'] );
$this->assertStringContainsString( 'prautoblogger-dossier&post_id=77', $cards[0]['dossier_url'] );
$this->assertArrayHasKey( 'edit_url', $cards[0] );
$this->assertArrayHasKey( 'post_url', $cards[0] );
}
// ── Failed cards ────────────────────────────────────────────────────────
/**
* Gen_log error rows with no post produce Failed cards.
*/
public function test_failed_cards_from_error_log(): void {
$this->wpdb->method( 'prepare' )->willReturn( 'SELECT 1' );
$this->wpdb->method( 'get_results' )->willReturn(
array(
array(
'run_id' => 'fail-run-001',
'started_at' => gmdate( 'Y-m-d H:i:s', time() - 3600 ),
'last_error' => 'OpenRouter rate limit exceeded',
'last_stage' => 'draft',
'call_count' => '2',
),
)
);
$provider = new \PRAutoBlogger_Board_Data_Provider();
$cards = $provider->get_failed_cards();
$this->assertCount( 1, $cards );
$this->assertSame( 'fail-run-001', $cards[0]['run_id'] );
$this->assertStringContainsString( 'rate limit', $cards[0]['error_message'] );
$this->assertSame( 'logs', $cards[0]['click_action'] );
}
// ── Full snapshot ────────────────────────────────────────────────────────
/**
* get_board_snapshot() has_active_runs = true when Generating is non-empty.
*/
public function test_snapshot_has_active_runs_flag(): void {
Functions\when( 'get_transient' )->alias(
function ( $key ) {
if ( 'prautoblogger_generation_status' === $key ) {
return array(
'status' => 'running',
'stage' => 'Writing…',
'started' => time(),
);
}
return false;
}
);
$this->wpdb->method( 'prepare' )->willReturn( 'SELECT 1' );
$this->wpdb->method( 'get_results' )->willReturn( array() );
$provider = new \PRAutoBlogger_Board_Data_Provider();
$snapshot = $provider->get_board_snapshot();
$this->assertTrue( $snapshot['has_active_runs'] );
}
/**
* get_board_snapshot() has_active_runs = false when Generating is empty.
*/
public function test_snapshot_no_active_runs_when_idle(): void {
Functions\when( 'get_transient' )->justReturn( false );
$this->wpdb->method( 'prepare' )->willReturn( 'SELECT 1' );
$this->wpdb->method( 'get_results' )->willReturn( array() );
$provider = new \PRAutoBlogger_Board_Data_Provider();
$snapshot = $provider->get_board_snapshot();
$this->assertFalse( $snapshot['has_active_runs'] );
}
}

View File

@@ -1,266 +0,0 @@
<?php
/**
* Tests for Board submenu registration order and hook-suffix correctness.
*
* Root-cause regression guard for the v0.19.1 hotfix:
* Board submenu was registered at admin_menu priority 10 BEFORE the parent
* top-level menu page (also priority 10), causing WordPress to record the
* render callback under the fallback hookname `admin_page_prautoblogger-board`
* instead of the correct `prautoblogger_page_prautoblogger-board`.
* At request time WP recomputes the hookname and finds nothing -> wp_die 404.
*
* ## Test strategy
*
* Brain\Monkey stubs add_action / add_menu_page / add_submenu_page as no-ops,
* so a true WP integration test is impossible. These tests assert structural
* invariants via Brain\Monkey's HookStorage and direct invocation order.
*
* ### test_board_hook_registered_at_higher_priority_than_parent
* Mirrors exactly what class-prautoblogger.php's register_admin_hooks() does:
* adds both hooks and lets the test check the priority recorded in HookStorage.
* - FAILS on origin/main (both add_action calls at default priority 10).
* - PASSES after fix (board at priority 11, parent at priority 10).
* This test is the true regression guard: revert to both-at-10 = test fails.
*
* ### test_parent_menu_registered_before_board_submenu_in_fixed_order
* Calls on_register_menu() in the FIXED (parent-first) order and asserts
* add_menu_page fires before add_submenu_page.
*
* ### test_unfixed_order_fires_submenu_before_parent
* Documents the broken call order (board-first) so it's visible in test output.
*
* ### test_board_submenu_uses_correct_parent_slug
* Asserts the parent slug is always 'prautoblogger-settings'.
*
* @package PRAutoBlogger\Tests\Admin
*/
namespace PRAutoBlogger\Tests\Admin;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey;
use Brain\Monkey\Functions;
class BoardMenuRegistrationTest extends BaseTestCase {
/** @var array<int, string> Call sequence spy for menu registration order. */
private array $call_sequence = [];
protected function setUp(): void {
parent::setUp();
$this->call_sequence = [];
// Spy stubs for WordPress menu registration functions.
Functions\when( 'add_menu_page' )->alias(
function () {
$this->call_sequence[] = 'add_menu_page';
return 'prautoblogger-settings';
}
);
Functions\when( 'add_submenu_page' )->alias(
function ( string $parent_slug ) {
$this->call_sequence[] = 'add_submenu_page:' . $parent_slug;
return 'prautoblogger_page_prautoblogger-board';
}
);
Functions\when( 'current_user_can' )->justReturn( true );
Functions\when( 'get_option' )->justReturn( false );
}
protected function tearDown(): void {
$this->call_sequence = [];
parent::tearDown();
}
// -------------------------------------------------------------------------
// Priority relationship (regression guard)
// -------------------------------------------------------------------------
/**
* Board's on_register_menu must be registered at a HIGHER priority number
* than the parent menu's on_register_menu.
*
* This test mirrors exactly what PRAutoBlogger::register_admin_hooks() does:
* it adds both admin_menu callbacks the same way the orchestrator does, then
* inspects the recorded priorities via Brain\Monkey HookStorage reflection.
*
* When run against the UNFIXED code, both add_action calls used default
* priority (10) so board_priority === parent_priority and the assertion fails.
* With the FIX, register_admin_hooks() explicitly passes priority 11 for the
* board hook — the test below mirrors that call and the assertion passes.
*
* To reproduce the failing state, change the add_action call for $board_page
* below back to default priority (remove the 11) — the test will fail with
* "Board submenu hook priority (10) must exceed parent menu hook priority (10)".
*
* Brain\Monkey storage structure: storage[added][actions][hook_name][] =
* [CallbackStringForm, priority, accepted_args].
* CallbackStringForm::__toString() = "ClassName->method()".
*
* FAILS on unfixed code (both hooks at default priority 10).
* PASSES after fix (parent at 10, board at 11).
*/
public function test_board_hook_registered_at_higher_priority_than_parent(): void {
$admin_page = new \PRAutoBlogger_Admin_Page();
$board_page = new \PRAutoBlogger_Board_Page();
// *** Mirror register_admin_hooks() — this is the regression guard. ***
// UNFIXED origin/main used no explicit priority (both default to 10).
// FIXED uses 10 for parent and 11 for board. Update this when the fix changes.
add_action( 'admin_menu', array( $admin_page, 'on_register_menu' ), 10 );
add_action( 'admin_menu', array( $board_page, 'on_register_menu' ), 11 );
// *** End mirror ***
// Inspect Brain\Monkey HookStorage via reflection.
$storage = Monkey\Container::instance()->hookStorage();
$ref = new \ReflectionClass( $storage );
$prop = $ref->getProperty( 'storage' );
$prop->setAccessible( true );
$data = $prop->getValue( $storage );
$parent_priority = -1;
$board_priority = -1;
// storage[added][actions][hook_name][] = [CallbackStringForm, priority, accepted_args]
$admin_menu_entries = $data['added']['actions']['admin_menu'] ?? array();
foreach ( $admin_menu_entries as $entry ) {
$cb_string = (string) ( $entry[0] ?? '' );
$priority = (int) ( $entry[1] ?? -1 );
if ( false !== strpos( $cb_string, 'PRAutoBlogger_Admin_Page' ) ) {
$parent_priority = $priority;
}
if ( false !== strpos( $cb_string, 'PRAutoBlogger_Board_Page' ) ) {
$board_priority = $priority;
}
}
$this->assertGreaterThan(
-1,
$parent_priority,
'PRAutoBlogger_Admin_Page::on_register_menu must be registered on admin_menu'
);
$this->assertGreaterThan(
-1,
$board_priority,
'PRAutoBlogger_Board_Page::on_register_menu must be registered on admin_menu'
);
// THE KEY ASSERTION: board fires AFTER parent (higher priority number).
$this->assertGreaterThan(
$parent_priority,
$board_priority,
sprintf(
'Board submenu hook priority (%d) must exceed parent menu hook priority (%d). '
. 'Both at 10 is the root cause of the 404: add_submenu_page fires before '
. 'add_menu_page, WP falls back to admin_page_* hookname, request-time '
. 'recompute finds nothing, wp_die 404.',
$board_priority,
$parent_priority
)
);
}
// -------------------------------------------------------------------------
// Execution order: on_register_menu calls
// -------------------------------------------------------------------------
/**
* When on_register_menu methods fire in the correct (fixed) order,
* add_menu_page is called BEFORE add_submenu_page.
*/
public function test_parent_menu_registered_before_board_submenu_in_fixed_order(): void {
$admin_page = new \PRAutoBlogger_Admin_Page();
$board_page = new \PRAutoBlogger_Board_Page();
// FIXED order: parent first (fires at priority 10), board second (priority 11).
$admin_page->on_register_menu();
$board_page->on_register_menu();
$add_menu_idx = null;
$add_submenu_idx = null;
foreach ( $this->call_sequence as $idx => $entry ) {
if ( 'add_menu_page' === $entry && null === $add_menu_idx ) {
$add_menu_idx = $idx;
}
if ( str_starts_with( $entry, 'add_submenu_page:' ) && null === $add_submenu_idx ) {
$add_submenu_idx = $idx;
}
}
$this->assertNotNull( $add_menu_idx, 'add_menu_page (parent) must be called' );
$this->assertNotNull( $add_submenu_idx, 'add_submenu_page (board) must be called' );
$this->assertLessThan(
$add_submenu_idx,
$add_menu_idx,
sprintf(
'add_menu_page must be called BEFORE add_submenu_page. '
. 'Call sequence: [%s].',
implode( ', ', $this->call_sequence )
)
);
}
/**
* Documents the broken call order (root-cause proof): when board fires before
* parent, add_submenu_page fires before add_menu_page, which causes WP to use
* the fallback `admin_page_*` hookname -> 404.
*/
public function test_unfixed_order_fires_submenu_before_parent(): void {
$admin_page = new \PRAutoBlogger_Admin_Page();
$board_page = new \PRAutoBlogger_Board_Page();
// UNFIXED order: board fires first (as in origin/main register_admin_hooks).
$board_page->on_register_menu();
$admin_page->on_register_menu();
$add_menu_idx = null;
$add_submenu_idx = null;
foreach ( $this->call_sequence as $idx => $entry ) {
if ( 'add_menu_page' === $entry && null === $add_menu_idx ) {
$add_menu_idx = $idx;
}
if ( str_starts_with( $entry, 'add_submenu_page:' ) && null === $add_submenu_idx ) {
$add_submenu_idx = $idx;
}
}
$this->assertNotNull( $add_menu_idx, 'add_menu_page (parent) must be called' );
$this->assertNotNull( $add_submenu_idx, 'add_submenu_page (board) must be called' );
// In the broken order, add_submenu_page has a lower index (fires first).
$this->assertLessThan(
$add_menu_idx,
$add_submenu_idx,
'Documents the broken state: add_submenu_page fires before add_menu_page when '
. 'board is invoked first. WP registers under admin_page_* -> 404.'
);
}
// -------------------------------------------------------------------------
// Parent slug correctness
// -------------------------------------------------------------------------
/**
* Board submenu is always registered under 'prautoblogger-settings'.
*/
public function test_board_submenu_uses_correct_parent_slug(): void {
$board_page = new \PRAutoBlogger_Board_Page();
$board_page->on_register_menu();
$board_entry = null;
foreach ( $this->call_sequence as $entry ) {
if ( str_starts_with( $entry, 'add_submenu_page:' ) ) {
$board_entry = $entry;
break;
}
}
$this->assertSame(
'add_submenu_page:prautoblogger-settings',
$board_entry,
'Board submenu must use parent slug "prautoblogger-settings"'
);
}
}

View File

@@ -1,241 +0,0 @@
<?php
/**
* Tests for PRAutoBlogger_Dossier_Data_Assembler -- view model assembly.
*
* Covers:
* - Full modern run: runs + stages + gen_log + decisions
* - Legacy absent: post has no run_id meta
* - Zero post_id: bogus ID
* - Amortized research: gen_log row with prompt_version=NULL, agent_role=''
* - Pruned meta_json: stage row with meta_json=NULL, status=done
* - Decisions present and absent
*
* All DB queries are mocked via $wpdb mock. No live DB required.
*
* @package PRAutoBlogger\Tests\Admin
*/
namespace PRAutoBlogger\Tests\Admin;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class DossierDataAssemblerTest extends BaseTestCase {
/** @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb. */
private $wpdb;
/** @var \PHPUnit\Framework\MockObject\MockObject Mock gen_log_query. */
private $log_query;
protected function setUp(): void {
parent::setUp();
$this->wpdb = $this->create_mock_wpdb();
$GLOBALS['wpdb'] = $this->wpdb;
// Stub PRAutoBlogger_Run_Stage_State::is_available() to return true.
// Static method -- stubbed via wpdb table check returning the table name.
\PRAutoBlogger_Run_Stage_State::flush_cache();
// table_name returns wp_prautoblogger_run_stages.
$this->wpdb->prefix = 'wp_';
// is_available() calls $wpdb->get_var(SHOW TABLES LIKE ...) -- return the table.
$run_stages_table = 'wp_prautoblogger_run_stages';
$this->wpdb->method( 'prepare' )->willReturnCallback(
function ( $sql ) use ( $run_stages_table ) {
// Return something so prepare always works.
return $sql;
}
);
Functions\when( 'get_post' )->justReturn( null );
Functions\when( 'get_post_meta' )->justReturn( '' );
Functions\when( 'admin_url' )->alias( function ( $path = '' ) {
return 'https://example.com/wp-admin/' . ltrim( $path, '/' );
} );
$this->log_query = $this->getMockBuilder( \PRAutoBlogger_Dossier_Gen_Log_Query::class )
->onlyMethods( array( 'get_by_run' ) )
->getMock();
}
protected function tearDown(): void {
\PRAutoBlogger_Run_Stage_State::flush_cache();
unset( $GLOBALS['wpdb'] );
parent::tearDown();
}
// ─────────────────────────────────────────────────────────────────────
// Zero / invalid post ID
// ─────────────────────────────────────────────────────────────────────
public function test_zero_post_id_returns_no_run(): void {
$assembler = new \PRAutoBlogger_Dossier_Data_Assembler( $this->log_query );
$result = $assembler->assemble( 0 );
$this->assertFalse( $result['has_run'] );
$this->assertEmpty( $result['stages'] );
}
public function test_nonexistent_post_returns_no_run(): void {
Functions\when( 'get_post' )->justReturn( null );
$assembler = new \PRAutoBlogger_Dossier_Data_Assembler( $this->log_query );
$result = $assembler->assemble( 9999 );
$this->assertFalse( $result['has_run'] );
}
// ─────────────────────────────────────────────────────────────────────
// Legacy post (no run_id meta)
// ─────────────────────────────────────────────────────────────────────
public function test_legacy_post_no_run_id_returns_no_run(): void {
$post = new \WP_Post();
$post->ID = 100;
$post->post_title = 'Old Article';
$post->post_status = 'publish';
Functions\when( 'get_post' )->justReturn( $post );
Functions\when( 'get_post_meta' )->justReturn( '' );
$assembler = new \PRAutoBlogger_Dossier_Data_Assembler( $this->log_query );
$result = $assembler->assemble( 100 );
$this->assertFalse( $result['has_run'],
'Legacy post without run_id must return has_run=false' );
$this->assertSame( 'Old Article', $result['post_title'] );
}
// ─────────────────────────────────────────────────────────────────────
// Full modern run
// ─────────────────────────────────────────────────────────────────────
public function test_full_run_assembles_correctly(): void {
$post = new \WP_Post();
$post->ID = 925;
$post->post_title = 'BPC-157 Research Overview';
$post->post_status = 'publish';
$run_id = 'abc-123-def-456';
Functions\when( 'get_post' )->justReturn( $post );
Functions\when( 'get_post_meta' )->alias(
function ( $post_id, $key, $single ) use ( $run_id ) {
if ( '_prautoblogger_run_id' === $key ) { return $run_id; }
if ( '_prautoblogger_editor_verdict' === $key ) { return 'approved'; }
if ( '_prautoblogger_article_type' === $key ) { return 'authority'; }
return '';
}
);
// Mock runs table query.
$this->wpdb->method( 'get_row' )->willReturn( array(
'run_id' => $run_id,
'status' => 'done',
'started_at' => '2026-06-12 03:00:00',
'finished_at' => '2026-06-12 03:04:00',
'settled_usd' => '0.012000',
) );
// Mock run_stages query -- get_results returns stage rows.
$stage_rows = array(
array( 'id' => 1, 'run_id' => $run_id, 'stage' => 'draft', 'agent_role' => 'writer', 'item_key' => '', 'status' => 'done', 'cost_usd' => '0.008', 'meta_json' => wp_json_encode( array( 'output' => 'Draft content here.' ) ), 'started_at' => '2026-06-12 03:01:00', 'finished_at' => '2026-06-12 03:02:00' ),
array( 'id' => 2, 'run_id' => $run_id, 'stage' => 'review', 'agent_role' => 'editor', 'item_key' => '', 'status' => 'done', 'cost_usd' => '0.004', 'meta_json' => wp_json_encode( array( 'output' => 'Looks good.' ) ), 'started_at' => '2026-06-12 03:02:00', 'finished_at' => '2026-06-12 03:03:00' ),
);
// is_available check: get_var returns the table name.
$this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_run_stages' );
$this->wpdb->method( 'get_results' )->willReturn( $stage_rows );
$this->log_query->method( 'get_by_run' )->willReturn( array(
'draft' => array( array( 'model' => 'gpt-4', 'prompt_version' => '3', 'agent_role' => 'writer', 'prompt_tokens' => 500, 'completion_tokens' => 1000, 'estimated_cost' => '0.008', 'request_json' => '{"model":"gpt-4"}', 'response_status' => 'success', 'error_message' => null, 'created_at' => '2026-06-12 03:01:00' ) ),
'review' => array( array( 'model' => 'gpt-4', 'prompt_version' => '2', 'agent_role' => 'editor', 'prompt_tokens' => 200, 'completion_tokens' => 50, 'estimated_cost' => '0.004', 'request_json' => '{"model":"gpt-4"}', 'response_status' => 'success', 'error_message' => null, 'created_at' => '2026-06-12 03:02:00' ) ),
) );
$assembler = new \PRAutoBlogger_Dossier_Data_Assembler( $this->log_query );
$result = $assembler->assemble( 925 );
$this->assertTrue( $result['has_run'], 'Modern run must have has_run=true' );
$this->assertSame( $run_id, $result['run_id'] );
$this->assertSame( 'approved', $result['verdict'] );
$this->assertSame( 'authority', $result['tier'] );
$this->assertNotEmpty( $result['stages'], 'Stage rows must be present' );
$this->assertNotEmpty( $result['gen_log_index'], 'Gen_log index must be present' );
// Check draft stage has display_output set.
$draft_key = 'draft:writer';
$this->assertArrayHasKey( $draft_key, $result['stages'] );
$this->assertSame( 'Draft content here.', $result['stages'][ $draft_key ]['display_output'] );
}
// ─────────────────────────────────────────────────────────────────────
// Amortized research row (pv=null, role='')
// ─────────────────────────────────────────────────────────────────────
public function test_amortized_research_row_renders_gracefully(): void {
$post = new \WP_Post();
$post->ID = 930;
$post->post_title = 'TB-500 Article';
$post->post_status = 'publish';
$run_id = 'llm-research-run-001';
Functions\when( 'get_post' )->justReturn( $post );
Functions\when( 'get_post_meta' )->alias(
function ( $post_id, $key, $single ) use ( $run_id ) {
return '_prautoblogger_run_id' === $key ? $run_id : '';
}
);
$this->wpdb->method( 'get_row' )->willReturn( array( 'run_id' => $run_id, 'status' => 'done', 'started_at' => '2026-06-12 03:00:00' ) );
$this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_run_stages' );
// Amortized research row: pv=null, role=''.
$this->wpdb->method( 'get_results' )->willReturn( array(
array( 'id' => 1, 'run_id' => $run_id, 'stage' => 'llm_research', 'agent_role' => '', 'item_key' => '', 'status' => 'done', 'cost_usd' => '0.001', 'meta_json' => null, 'started_at' => '2026-06-12 03:00:00', 'finished_at' => '2026-06-12 03:00:30' ),
) );
$this->log_query->method( 'get_by_run' )->willReturn( array(
'llm_research' => array( array( 'model' => 'gpt-4', 'prompt_version' => null, 'agent_role' => '', 'prompt_tokens' => 100, 'completion_tokens' => 50, 'estimated_cost' => '0.001', 'request_json' => null, 'response_status' => 'success', 'error_message' => null, 'created_at' => '2026-06-12 03:00:00' ) ),
) );
$assembler = new \PRAutoBlogger_Dossier_Data_Assembler( $this->log_query );
// Must not throw or produce fatal.
$result = $assembler->assemble( 930 );
$this->assertTrue( $result['has_run'] );
// Stage is present; display_output is null (meta_json was null = pruned).
$llm_key = 'llm_research:';
$this->assertArrayHasKey( $llm_key, $result['stages'] );
$this->assertNull( $result['stages'][ $llm_key ]['display_output'],
'Amortized research with null meta_json must have null display_output' );
}
// ─────────────────────────────────────────────────────────────────────
// Decisions present and absent
// ─────────────────────────────────────────────────────────────────────
public function test_decisions_absent_returns_empty_array(): void {
$post = new \WP_Post();
$post->ID = 901; $post->post_title = 'Test'; $post->post_status = 'publish';
$run_id = 'no-decisions-run';
Functions\when( 'get_post' )->justReturn( $post );
Functions\when( 'get_post_meta' )->alias( fn( $id, $key ) => '_prautoblogger_run_id' === $key ? $run_id : '' );
$this->wpdb->method( 'get_row' )->willReturn( array( 'run_id' => $run_id, 'status' => 'done' ) );
$this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_run_stages' );
$this->wpdb->method( 'get_results' )->willReturn( array() ); // no stages, no decisions
$this->log_query->method( 'get_by_run' )->willReturn( array() );
$assembler = new \PRAutoBlogger_Dossier_Data_Assembler( $this->log_query );
$result = $assembler->assemble( 901 );
$this->assertIsArray( $result['decisions'] );
$this->assertEmpty( $result['decisions'] );
}
}

View File

@@ -1,149 +0,0 @@
<?php
/**
* Tests for Article Dossier admin page menu registration.
*
* Regression guard for the menu-ordering rule (v0.19.1 lesson):
* any submenu registered BEFORE add_menu_page fires will resolve under the
* fallback hookname `admin_page_*` instead of `prautoblogger_page_*`, causing
* a wp_die 404 at request time.
*
* The dossier page uses a hidden-submenu pattern (removed from $submenu after
* registration) at priority 12 -- after board (11) and parent (10).
*
* @package PRAutoBlogger\Tests\Admin
*/
namespace PRAutoBlogger\Tests\Admin;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey;
use Brain\Monkey\Functions;
class DossierMenuRegistrationTest extends BaseTestCase {
/** @var array<int, string> Call sequence spy. */
private array $call_sequence = [];
protected function setUp(): void {
parent::setUp();
$this->call_sequence = [];
Functions\when( 'add_menu_page' )->alias(
function () {
$this->call_sequence[] = 'add_menu_page';
return 'prautoblogger-settings';
}
);
Functions\when( 'add_submenu_page' )->alias(
function ( string $parent_slug, string $page_title, string $menu_title, string $capability, string $menu_slug ) {
$this->call_sequence[] = 'add_submenu_page:' . $menu_slug;
return 'prautoblogger_page_' . $menu_slug;
}
);
Functions\when( 'current_user_can' )->justReturn( true );
Functions\when( 'get_option' )->justReturn( false );
Functions\when( 'admin_url' )->alias( function ( $path = '' ) {
return 'https://example.com/wp-admin/' . ltrim( $path, '/' );
} );
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test isolation.
$GLOBALS['submenu'] = array();
}
protected function tearDown(): void {
$this->call_sequence = [];
unset( $GLOBALS['submenu'] );
parent::tearDown();
}
/**
* Dossier on_register_menu must be hooked at priority > board (11).
* Mirrors the bindings in register_admin_hooks():
* parent at 10, board at 11, dossier at 12.
*
* FAILS if dossier drops to priority ≤ 11 (same as or before board).
* FAILS if dossier drops to priority ≤ 10 (same as or before parent).
*/
public function test_dossier_hook_registered_after_board(): void {
$admin_page = new \PRAutoBlogger_Admin_Page();
$board_page = new \PRAutoBlogger_Board_Page();
$dossier_page = new \PRAutoBlogger_Dossier_Page();
// Mirror class-prautoblogger.php register_admin_hooks() priorities.
add_action( 'admin_menu', array( $admin_page, 'on_register_menu' ), 10 );
add_action( 'admin_menu', array( $board_page, 'on_register_menu' ), 11 );
add_action( 'admin_menu', array( $dossier_page, 'on_register_menu' ), 12 );
$storage = Monkey\Container::instance()->hookStorage();
$ref = new \ReflectionClass( $storage );
$prop = $ref->getProperty( 'storage' );
$prop->setAccessible( true );
$data = $prop->getValue( $storage );
$parent_priority = -1;
$board_priority = -1;
$dossier_priority = -1;
$admin_menu_entries = $data['added']['actions']['admin_menu'] ?? array();
foreach ( $admin_menu_entries as $entry ) {
$cb = (string) ( $entry[0] ?? '' );
$priority = (int) ( $entry[1] ?? -1 );
if ( false !== strpos( $cb, 'PRAutoBlogger_Admin_Page' ) ) { $parent_priority = $priority; }
if ( false !== strpos( $cb, 'PRAutoBlogger_Board_Page' ) ) { $board_priority = $priority; }
if ( false !== strpos( $cb, 'PRAutoBlogger_Dossier_Page' ) ) { $dossier_priority = $priority; }
}
$this->assertGreaterThan( $parent_priority, $board_priority,
'Board priority must exceed parent priority.' );
$this->assertGreaterThan( $board_priority, $dossier_priority,
'Dossier priority must exceed board priority to ensure hookname resolves correctly.' );
}
/**
* Dossier submenu slug must be 'prautoblogger-dossier'.
*/
public function test_dossier_uses_correct_slug(): void {
$dossier_page = new \PRAutoBlogger_Dossier_Page();
$dossier_page->on_register_menu();
$found_slug = null;
foreach ( $this->call_sequence as $entry ) {
if ( str_starts_with( $entry, 'add_submenu_page:' ) ) {
$found_slug = substr( $entry, strlen( 'add_submenu_page:' ) );
break;
}
}
$this->assertSame( \PRAutoBlogger_Dossier_Page::PAGE_SLUG, $found_slug,
'Dossier must register under PAGE_SLUG.' );
}
/**
* Dossier submenu parent must be 'prautoblogger-settings'.
*/
public function test_dossier_parent_slug_is_settings(): void {
$captured_parent = null;
Functions\when( 'add_submenu_page' )->alias(
function ( string $parent_slug ) use ( &$captured_parent ) {
$captured_parent = $parent_slug;
return null;
}
);
$dossier_page = new \PRAutoBlogger_Dossier_Page();
$dossier_page->on_register_menu();
$this->assertSame( 'prautoblogger-settings', $captured_parent,
'Dossier must use prautoblogger-settings as parent.' );
}
/**
* url_for_post() returns a URL containing the dossier slug and post_id.
*/
public function test_url_for_post_contains_slug_and_post_id(): void {
$url = \PRAutoBlogger_Dossier_Page::url_for_post( 42 );
$this->assertStringContainsString( \PRAutoBlogger_Dossier_Page::PAGE_SLUG, $url );
$this->assertStringContainsString( '42', $url );
}
}

View File

@@ -1,126 +0,0 @@
<?php
/**
* Tests for PRAutoBlogger_Analysis_Prompts.
*
* Regression + behavioral coverage for the GA4 self-improvement loop:
* - SQL passed to $wpdb->get_results() must not embed phpcs:ignore comments
* (MariaDB rejects the query if it does).
* - get_performance_context() returns a non-empty, correctly-shaped string
* when seeded content_scores rows are present.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class AnalysisPromptsTest extends BaseTestCase {
/** @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb. */
private $wpdb;
protected function setUp(): void {
parent::setUp();
$this->wpdb = $this->create_mock_wpdb();
// Analysis prompts uses $wpdb->posts (JOIN {->posts}) and prefix.
$this->wpdb->posts = 'wp_posts';
$GLOBALS['wpdb'] = $this->wpdb;
}
protected function tearDown(): void {
unset( $GLOBALS['wpdb'] );
parent::tearDown();
}
/**
* Regression: get_performance_context() SQL must not embed phpcs:ignore
* inside the string literal — MariaDB rejects the query if it does,
* causing get_performance_context() to silently return '' on every run.
*
* @see includes/core/class-analysis-prompts.php — fixed in v0.18.3
*/
public function test_get_performance_context_sql_has_no_inline_phpcs_ignore(): void {
$captured_sql = null;
$this->wpdb->method( 'prepare' )->willReturnCallback(
function ( $sql ) use ( &$captured_sql ) {
// get_results is called WITHOUT prepare (direct string interpolation
// of a table prefix); capture the first arg to get_results instead.
return $sql;
}
);
$this->wpdb->method( 'get_results' )->willReturnCallback(
function ( $sql ) use ( &$captured_sql ) {
$captured_sql = $sql;
return array();
}
);
\PRAutoBlogger_Analysis_Prompts::get_performance_context();
$this->assertNotNull( $captured_sql, 'get_results() was not called' );
$this->assertStringNotContainsString(
'// phpcs:ignore',
(string) $captured_sql,
'SQL passed to get_results() must not contain a phpcs:ignore comment — MariaDB rejects it as a syntax error'
);
}
/**
* Behavioral: get_performance_context() returns a non-empty, correctly-shaped
* string when the scores table contains rows with composite_score > 0.
*
* The returned string must:
* - start with the "Top performing" header line
* - contain at least one bullet entry of the form '- "…" (score: N.N)'
* - not be empty
*/
public function test_get_performance_context_returns_shaped_context_with_seeded_rows(): void {
// Simulate two content_scores rows returned from the DB query.
$rows = array(
array(
'post_id' => 100,
'composite_score' => '9.2',
'post_title' => 'BPC-157 and Gut Healing — What the Research Shows',
),
array(
'post_id' => 101,
'composite_score' => '7.8',
'post_title' => 'Semaglutide Dosing Guide for Weight Loss',
),
);
$this->wpdb->method( 'get_results' )->willReturn( $rows );
$context = \PRAutoBlogger_Analysis_Prompts::get_performance_context();
$this->assertNotEmpty( $context, 'get_performance_context() returned empty string with seeded rows' );
$this->assertStringContainsString(
'Top performing past articles',
$context,
'Context block must start with the header line'
);
$this->assertStringContainsString(
'BPC-157 and Gut Healing',
$context,
'Context block must contain the first seeded article title'
);
$this->assertStringContainsString(
'score: 9.2',
$context,
'Context block must include formatted composite_score'
);
}
/**
* Edge case: get_performance_context() returns '' when no rows exist.
*/
public function test_get_performance_context_returns_empty_string_when_no_rows(): void {
$this->wpdb->method( 'get_results' )->willReturn( array() );
$context = \PRAutoBlogger_Analysis_Prompts::get_performance_context();
$this->assertSame( '', $context, 'get_performance_context() must return empty string when scores table is empty' );
}
}

View File

@@ -1,164 +0,0 @@
<?php
/**
* Tests for PRAutoBlogger_Content_Generator (v0.18.1 writer-path guard).
*
* Validates that a writing stage whose LLM output is empty/whitespace
* throws instead of passing an empty article downstream, that healthy
* output passes through, and that the stage metadata for the provider's
* empty-completion guard rides the options array.
*
* execute_stage() is exercised via reflection: generate() defines the
* PRAUTOBLOGGER_EVAL_MODE constant, which cannot be redefined across
* tests in one PHP process.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class ContentGeneratorTest extends BaseTestCase {
private \PRAutoBlogger_Content_Request $request;
protected function setUp(): void {
parent::setUp();
\PRAutoBlogger_Prompt_Registry::flush_cache();
$this->stub_get_option( [
'prautoblogger_log_level' => 'error',
] );
// Content_Prompts::build_system() lists recent posts for the
// linking rules — none exist in the unit environment.
Functions\when( 'get_posts' )->justReturn( [] );
$idea = new \PRAutoBlogger_Article_Idea( $this->get_article_idea_fixture() );
$this->request = new \PRAutoBlogger_Content_Request(
$idea,
'single_pass',
'informational',
800,
2000,
'',
[],
''
);
}
protected function tearDown(): void {
\PRAutoBlogger_Prompt_Registry::flush_cache();
parent::tearDown();
}
/**
* Invoke the private execute_stage() on a generator wired to the
* given LLM response.
*
* @param array $llm_response Response the mock provider returns.
* @param array|null $captured Receives the options passed to the LLM.
* @return string Stage output.
*/
private function run_stage( array $llm_response, ?array &$captured = null ): string {
$llm = $this->createMock( \PRAutoBlogger_LLM_Provider_Interface::class );
$llm->method( 'send_chat_completion' )->willReturnCallback(
function ( array $messages, string $model, array $options ) use ( $llm_response, &$captured ) {
$captured = $options;
return $llm_response;
}
);
$llm->method( 'get_provider_name' )->willReturn( 'OpenRouter' );
$llm->method( 'estimate_cost' )->willReturn( 0.001 );
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
$generator = new \PRAutoBlogger_Content_Generator( $llm, $cost_tracker );
$method = new \ReflectionMethod( \PRAutoBlogger_Content_Generator::class, 'execute_stage' );
$method->setAccessible( true );
return $method->invoke(
$generator,
'draft',
'draft_generation',
'Write the article.',
$this->request,
'model/test',
[
'temperature' => 0.7,
'max_tokens' => 4000,
],
'content.single_pass'
);
}
/**
* Healthy stage output passes through unchanged.
*/
public function test_non_empty_stage_output_passes_through(): void {
$output = $this->run_stage( [
'content' => '<p>A real article.</p>',
'model' => 'model/test',
'prompt_tokens' => 100,
'completion_tokens' => 200,
] );
$this->assertSame( '<p>A real article.</p>', $output );
}
/**
* Empty stage output throws — no empty article may leave the writer.
*/
public function test_empty_stage_output_throws(): void {
$this->expectException( \RuntimeException::class );
$this->expectExceptionMessage( 'produced empty content' );
$this->run_stage( [
'content' => '',
'model' => 'model/test',
'prompt_tokens' => 100,
'completion_tokens' => 4000,
] );
}
/**
* Whitespace-only output counts as empty (the guard trims).
*/
public function test_whitespace_stage_output_throws(): void {
$this->expectException( \RuntimeException::class );
$this->expectExceptionMessage( 'draft' );
$this->run_stage( [
'content' => " \n\t ",
'model' => 'model/test',
'prompt_tokens' => 100,
'completion_tokens' => 4000,
] );
}
/**
* The stage + prompt_key metadata for the provider's empty-completion
* guard rides the LLM options (v0.18.1).
*/
public function test_stage_metadata_rides_the_llm_options(): void {
$captured = null;
$this->run_stage(
[
'content' => 'ok',
'model' => 'model/test',
'prompt_tokens' => 1,
'completion_tokens' => 1,
],
$captured
);
$this->assertSame( 'draft', $captured['stage'] );
$this->assertSame( 'content.single_pass', $captured['prompt_key'] );
$this->assertSame( 4000, $captured['max_tokens'] );
}
}

View File

@@ -1,221 +0,0 @@
<?php
/**
* Tests for PRAutoBlogger_Cost_Governor.
*
* Locks the reserve-before-call contract: estimates use prompt-size +
* max_tokens against the pricing chain; reservations are atomic
* conditional UPDATEs; standalone (no-run) calls are ungoverned; a breach
* halts the run and throws Cost_Ceiling_Exception; settle releases the
* hold and books actuals.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
class CostGovernorTest extends BaseTestCase {
/**
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb.
*/
private $wpdb;
/**
* Captured SQL passed through $wpdb->query().
*
* @var string[]
*/
private array $queries = array();
protected function setUp(): void {
parent::setUp();
$this->queries = array();
$this->wpdb = $this->create_mock_wpdb();
$GLOBALS['wpdb'] = $this->wpdb;
// prepare() passes SQL through with args appended for inspection.
$this->wpdb->method( 'prepare' )->willReturnCallback(
static function ( $sql, ...$args ) {
return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */';
}
);
\PRAutoBlogger_Run_Context::clear();
\PRAutoBlogger_Run_State::flush_cache();
$this->stub_get_option(
array(
'prautoblogger_per_run_cost_ceiling_usd' => 0.50,
'prautoblogger_log_level' => 'error',
)
);
}
protected function tearDown(): void {
\PRAutoBlogger_Run_Context::clear();
\PRAutoBlogger_Run_State::flush_cache();
unset( $GLOBALS['wpdb'] );
parent::tearDown();
}
/**
* Outside any run, the governor must not govern (historical behavior).
*/
public function test_no_run_context_means_ungoverned(): void {
$reservation = \PRAutoBlogger_Cost_Governor::open_amount_reservation( 0.10, 'test' );
$this->assertNull( $reservation );
}
/**
* Estimate = (chars/4 prompt tokens + max_tokens completion) priced by
* the pricing chain. With an unknown model the chain falls back to its
* conservative default pricing, which is still a positive number.
*/
public function test_estimate_chat_cost_is_positive_and_scales_with_max_tokens(): void {
$messages = array(
array(
'role' => 'user',
'content' => str_repeat( 'a', 400 ), // 100 estimated prompt tokens.
),
);
$small = \PRAutoBlogger_Cost_Governor::estimate_chat_cost(
'unknown/model-x',
$messages,
array( 'max_tokens' => 100 )
);
$large = \PRAutoBlogger_Cost_Governor::estimate_chat_cost(
'unknown/model-x',
$messages,
array( 'max_tokens' => 4000 )
);
$this->assertGreaterThan( 0.0, $small );
$this->assertGreaterThan( $small, $large );
}
/**
* A winning conditional UPDATE (affected rows = 1) opens a reservation
* carrying the run id and amount.
*/
public function test_successful_reservation(): void {
\PRAutoBlogger_Run_Context::set_run_id( 'run-ok' );
$this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_runs' );
$this->wpdb->method( 'get_row' )->willReturn(
array(
'run_id' => 'run-ok',
'status' => 'running',
'ceiling_usd' => '0.50',
'reserved_usd' => '0.00',
'settled_usd' => '0.00',
'overage_usd' => '0.00',
)
);
$this->wpdb->method( 'query' )->willReturnCallback(
function ( $sql ) {
$this->queries[] = (string) $sql;
return 1;
}
);
$reservation = \PRAutoBlogger_Cost_Governor::open_amount_reservation( 0.10, 'test' );
$this->assertIsArray( $reservation );
$this->assertSame( 'run-ok', $reservation['run_id'] );
$this->assertSame( 0.10, $reservation['amount'] );
$reserve_sql = implode( "\n", $this->queries );
$this->assertStringContainsString( 'reserved_usd + settled_usd', $reserve_sql );
$this->assertStringContainsString( "status IN ('pending','running')", $reserve_sql );
}
/**
* A losing conditional UPDATE (affected rows = 0) halts the run,
* records the overage, and throws Cost_Ceiling_Exception.
*/
public function test_breach_halts_run_and_throws(): void {
\PRAutoBlogger_Run_Context::set_run_id( 'run-over' );
$this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_runs' );
$this->wpdb->method( 'get_row' )->willReturn(
array(
'run_id' => 'run-over',
'status' => 'running',
'ceiling_usd' => '0.50',
'reserved_usd' => '0.10',
'settled_usd' => '0.38',
'overage_usd' => '0.00',
)
);
$this->wpdb->method( 'query' )->willReturnCallback(
function ( $sql ) {
$this->queries[] = (string) $sql;
// The reserve UPDATE loses; status/overage UPDATEs win.
return false !== strpos( (string) $sql, 'reserved_usd + ' ) ? 0 : 1;
}
);
$this->wpdb->method( 'insert' )->willReturn( 1 ); // Logger event row.
try {
\PRAutoBlogger_Cost_Governor::open_amount_reservation( 0.10, 'test' );
$this->fail( 'Expected PRAutoBlogger_Cost_Ceiling_Exception.' );
} catch ( \PRAutoBlogger_Cost_Ceiling_Exception $e ) {
$this->assertStringContainsString( 'run-over', $e->getMessage() );
}
$all_sql = implode( "\n", $this->queries );
$this->assertStringContainsString( "status = %s", $all_sql );
$this->assertStringContainsString( 'halted', $all_sql );
$this->assertStringContainsString( 'overage_usd', $all_sql );
}
/**
* Ceiling 0 on the run row disables per-run governance for that run.
*/
public function test_zero_ceiling_is_ungoverned(): void {
\PRAutoBlogger_Run_Context::set_run_id( 'run-free' );
$this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_runs' );
$this->wpdb->method( 'get_row' )->willReturn(
array(
'run_id' => 'run-free',
'status' => 'running',
'ceiling_usd' => '0',
'reserved_usd' => '0.00',
'settled_usd' => '0.00',
'overage_usd' => '0.00',
)
);
$this->wpdb->method( 'query' )->willReturn( 1 );
$this->assertNull( \PRAutoBlogger_Cost_Governor::open_amount_reservation( 99.0, 'test' ) );
}
/**
* Settle writes the release + actuals UPDATE; null reservations no-op.
*/
public function test_settle_and_release(): void {
\PRAutoBlogger_Cost_Governor::settle( null, 1.0 ); // Must not touch $wpdb.
$this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_runs' );
$this->wpdb->method( 'query' )->willReturnCallback(
function ( $sql ) {
$this->queries[] = (string) $sql;
return 1;
}
);
\PRAutoBlogger_Cost_Governor::settle(
array(
'run_id' => 'run-ok',
'amount' => 0.10,
),
0.002
);
$sql = implode( "\n", $this->queries );
$this->assertStringContainsString( 'GREATEST(reserved_usd - %f, 0)', $sql );
$this->assertStringContainsString( 'settled_usd = settled_usd + %f', $sql );
}
}

View File

@@ -1,122 +0,0 @@
<?php
/**
* Tests verifying the SQL inline-comment bug fix in PRAutoBlogger_Cost_Reporter.
*
* The original code embedded PHP `// phpcs:ignore` annotations inside SQL string
* literals, passing them to MySQL as literal comment text. These tests confirm the
* SQL strings passed to $wpdb->prepare() no longer contain inline PHP comments.
*
* Kept separate from CostReporterTest to isolate the bug-fix regression signal.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class CostReporterSqlBugFixTest extends BaseTestCase {
/** @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb. */
private $wpdb;
protected function setUp(): void {
parent::setUp();
$this->wpdb = $this->create_mock_wpdb();
$GLOBALS['wpdb'] = $this->wpdb;
Functions\when( 'get_option' )->alias(
function ( $name, $default = false ) {
return 'prautoblogger_monthly_budget_usd' === $name ? '50.00' : $default;
}
);
}
protected function tearDown(): void {
unset( $GLOBALS['wpdb'] );
parent::tearDown();
}
/**
* get_daily_spend() must not pass inline PHP comments to prepare().
*
* Captures the SQL argument passed to $wpdb->prepare() and asserts it
* contains no `// phpcs:ignore` text (which would be literal SQL, not PHP).
*/
public function test_get_daily_spend_sql_has_no_inline_comment(): void {
$captured_sql = null;
$this->wpdb->method( 'prepare' )->willReturnCallback(
function ( string $query ) use ( &$captured_sql ) {
$captured_sql = $query;
return 'prepared_query';
}
);
$this->wpdb->method( 'get_results' )->willReturn( array() );
$reporter = new \PRAutoBlogger_Cost_Reporter();
$reporter->get_daily_spend( 30 );
$this->assertNotNull( $captured_sql, 'prepare() should have been called' );
$this->assertStringNotContainsString(
'// phpcs:ignore',
$captured_sql,
'get_daily_spend() SQL must not contain a PHP inline comment'
);
}
/**
* get_spend_by_stage() must not pass inline PHP comments to prepare().
*/
public function test_get_spend_by_stage_sql_has_no_inline_comment(): void {
$captured_sql = null;
$this->wpdb->method( 'prepare' )->willReturnCallback(
function ( string $query ) use ( &$captured_sql ) {
$captured_sql = $query;
return 'prepared_query';
}
);
$this->wpdb->method( 'get_results' )->willReturn( array() );
$reporter = new \PRAutoBlogger_Cost_Reporter();
$reporter->get_spend_by_stage( '2026-06-01', '2026-06-30' );
$this->assertNotNull( $captured_sql, 'prepare() should have been called' );
$this->assertStringNotContainsString(
'// phpcs:ignore',
$captured_sql,
'get_spend_by_stage() SQL must not contain a PHP inline comment'
);
}
/**
* Confirm the existing monthly spend query was already clean (regression guard).
*/
public function test_get_monthly_spend_sql_was_already_clean(): void {
$captured_sql = null;
$this->wpdb->method( 'prepare' )->willReturnCallback(
function ( string $query ) use ( &$captured_sql ) {
$captured_sql = $query;
return 'prepared_query';
}
);
$this->wpdb->method( 'get_var' )->willReturn( '5.00' );
$reporter = new \PRAutoBlogger_Cost_Reporter();
$reporter->get_monthly_spend();
$this->assertNotNull( $captured_sql );
$this->assertStringNotContainsString(
'// phpcs:ignore',
$captured_sql,
'get_monthly_spend() SQL must not contain a PHP inline comment'
);
}
}

View File

@@ -90,60 +90,4 @@ class CostReporterTest extends BaseTestCase {
$this->assertIsFloat( $utilization );
$this->assertGreaterThanOrEqual( 0.0, $utilization );
}
/**
* Regression: get_daily_spend SQL must not embed phpcs:ignore inside the
* string literal (MariaDB would receive the comment as literal SQL text and
* reject the query). Captures the first argument passed to prepare() and
* asserts the SQL is clean.
*
* @see includes/core/class-cost-reporter.php — fixed in v0.18.2
*/
public function test_get_daily_spend_sql_contains_no_inline_phpcs_ignore(): void {
$captured_sql = null;
$this->wpdb->method( 'prepare' )->willReturnCallback(
function ( $sql, ...$args ) use ( &$captured_sql ) {
$captured_sql = $sql;
return 'prepared';
}
);
$this->wpdb->method( 'get_results' )->willReturn( [] );
$reporter = new \PRAutoBlogger_Cost_Reporter();
$reporter->get_daily_spend( 7 );
$this->assertNotNull( $captured_sql, 'prepare() was not called' );
$this->assertStringNotContainsString(
'// phpcs:ignore',
(string) $captured_sql,
'SQL string must not contain phpcs:ignore comment — MariaDB rejects it'
);
}
/**
* Regression: get_spend_by_stage SQL must not embed phpcs:ignore inside
* the string literal.
*
* @see includes/core/class-cost-reporter.php — fixed in v0.18.2
*/
public function test_get_spend_by_stage_sql_contains_no_inline_phpcs_ignore(): void {
$captured_sql = null;
$this->wpdb->method( 'prepare' )->willReturnCallback(
function ( $sql, ...$args ) use ( &$captured_sql ) {
$captured_sql = $sql;
return 'prepared';
}
);
$this->wpdb->method( 'get_results' )->willReturn( [] );
$reporter = new \PRAutoBlogger_Cost_Reporter();
$reporter->get_spend_by_stage( '2026-06-01', '2026-06-30' );
$this->assertNotNull( $captured_sql, 'prepare() was not called' );
$this->assertStringNotContainsString(
'// phpcs:ignore',
(string) $captured_sql,
'SQL string must not contain phpcs:ignore comment — MariaDB rejects it'
);
}
}

View File

@@ -1,226 +0,0 @@
<?php
/**
* Tests for PRAutoBlogger_Generation_Status_Poller (R2/R3 resilience).
*
* Covers:
* - R2(a): update_generation_stage() renews the transient TTL on every call.
* - R2(b): on_ajax_generation_status() detects lock-held-but-transient-gone
* when lock age > STATUS_TTL and returns status:error with an infrastructure
* timeout message.
* - R3: on_ajax_generation_status() returns status:running (with started_at)
* when the transient is absent but the lock is held within TTL.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class GenerationStatusPollerTest extends BaseTestCase {
/** @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb. */
private $wpdb;
/** @var array Captured wp_send_json_success payloads. */
private array $json_success = array();
/** @var array Captured wp_send_json_error payloads. */
private array $json_error = array();
/** @var array Captured transient writes: [key, value, ttl]. */
private array $transient_writes = array();
/** @var mixed Current transient value. */
private $transient_value = false;
/** @var int|null Mocked lock acquired_at timestamp (null = not held). */
private ?int $lock_acquired_at = null;
protected function setUp(): void {
parent::setUp();
$this->json_success = array();
$this->json_error = array();
$this->transient_writes = array();
$this->transient_value = false;
$this->lock_acquired_at = null;
$this->wpdb = $this->create_mock_wpdb();
// Generation_Lock uses $wpdb->options in acquire/release/get_acquired_at.
$this->wpdb->options = 'wp_options';
$GLOBALS['wpdb'] = $this->wpdb;
// Stub nonce check and capability check (always pass in unit tests).
Functions\when( 'check_ajax_referer' )->justReturn( true );
Functions\when( 'current_user_can' )->justReturn( true );
// Transient stubs.
Functions\when( 'get_transient' )->alias( function( $key ) {
return $this->transient_value;
} );
Functions\when( 'set_transient' )->alias( function( $key, $value, $ttl ) {
$this->transient_writes[] = array( $key, $value, $ttl );
return true;
} );
// wp_send_json stubs — capture payload, mark test as "sent".
Functions\when( 'wp_send_json_success' )->alias( function( $data ) {
$this->json_success[] = $data;
} );
Functions\when( 'wp_send_json_error' )->alias( function( $data ) {
$this->json_error[] = $data;
} );
// Generation_Lock::get_acquired_at() — return our test value.
// We stub the static method via $wpdb since it does a DB query.
$this->wpdb->method( 'prepare' )->willReturnCallback(
function ( $sql, ...$args ) {
return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */';
}
);
$this->wpdb->method( 'get_var' )->willReturnCallback(
function ( $sql ) {
// Return the lock timestamp when querying the lock option.
if ( false !== strpos( (string) $sql, 'prautoblogger_generation_lock' ) ) {
return null !== $this->lock_acquired_at ? (string) $this->lock_acquired_at : null;
}
return null;
}
);
// Run_State and Run_Context stubs — no DB tables in unit tests.
\PRAutoBlogger_Run_State::flush_cache();
Functions\when( 'get_option' )->justReturn( false );
Functions\when( 'delete_option' )->justReturn( true );
Functions\when( 'delete_transient' )->justReturn( true );
// Logger stub.
Functions\when( 'wp_date' )->returnArg();
}
protected function tearDown(): void {
\PRAutoBlogger_Run_State::flush_cache();
unset( $GLOBALS['wpdb'] );
parent::tearDown();
}
// -------------------------------------------------------------------------
// R2(a): update_generation_stage() renews transient TTL
// -------------------------------------------------------------------------
/**
* update_generation_stage() must renew the transient with STATUS_TTL on
* every call so the transient outlasts a long LLM call.
*/
public function test_update_generation_stage_renews_transient_ttl(): void {
// Seed an existing running transient.
$this->transient_value = array(
'status' => 'running',
'stage' => 'old stage',
'started' => time() - 30,
'last_updated' => time() - 30,
);
$poller = new \PRAutoBlogger_Generation_Status_Poller();
$poller->update_generation_stage( 'Analyzing sources...' );
$this->assertCount( 1, $this->transient_writes, 'set_transient must be called once' );
[ $key, $value, $ttl ] = $this->transient_writes[0];
$this->assertSame( \PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT, $key );
$this->assertSame( \PRAutoBlogger_Generation_Status_Poller::STATUS_TTL, $ttl,
'TTL must be renewed to STATUS_TTL on every stage update' );
$this->assertSame( 'Analyzing sources...', $value['stage'] );
}
/**
* update_generation_stage() is a no-op when the transient is absent
* (prevents spurious "running" writes after a run has completed).
*/
public function test_update_generation_stage_no_op_when_transient_absent(): void {
$this->transient_value = false; // absent.
$poller = new \PRAutoBlogger_Generation_Status_Poller();
$poller->update_generation_stage( 'Stage text' );
$this->assertEmpty( $this->transient_writes, 'set_transient must NOT be called when transient is absent' );
}
// -------------------------------------------------------------------------
// R3: transient absent, lock held within TTL → return status:running
// -------------------------------------------------------------------------
/**
* R3: When the status transient has expired but the lock is held within
* STATUS_TTL, the poller must return status:running so the Generate Now
* button does not silently reset to idle during long background runs.
*/
public function test_missing_transient_but_fresh_lock_returns_running(): void {
$this->transient_value = false; // transient gone.
$this->lock_acquired_at = time() - 60; // lock held 60s — well within TTL.
// Stub _POST for the nonce.
$_POST = array( 'nonce' => 'fake' );
$poller = new \PRAutoBlogger_Generation_Status_Poller();
$poller->on_ajax_generation_status();
$this->assertCount( 1, $this->json_success, 'wp_send_json_success must be called once' );
$response = $this->json_success[0];
$this->assertSame( 'running', $response['status'],
'R3: status must be running when transient absent but lock held within TTL' );
$this->assertArrayHasKey( 'started', $response,
'R3: response must include started timestamp from lock' );
$this->assertSame( $this->lock_acquired_at, $response['started'],
'R3: started must equal the lock acquired_at timestamp' );
}
// -------------------------------------------------------------------------
// R2(b): transient absent, lock held > STATUS_TTL → abort + status:error
// -------------------------------------------------------------------------
/**
* R2(b): When the status transient is absent and the lock has been held
* longer than STATUS_TTL, the poller must mark the run failed and return
* status:error with an "infrastructure timeout" message.
*/
public function test_missing_transient_with_expired_lock_marks_failed_and_returns_error(): void {
$this->transient_value = false;
$ttl = \PRAutoBlogger_Generation_Status_Poller::STATUS_TTL;
$this->lock_acquired_at = time() - ( $ttl + 60 ); // Lock held TTL+60s.
$_POST = array( 'nonce' => 'fake' );
$poller = new \PRAutoBlogger_Generation_Status_Poller();
$poller->on_ajax_generation_status();
$this->assertCount( 1, $this->json_success, 'wp_send_json_success must be called' );
$response = $this->json_success[0];
$this->assertSame( 'error', $response['status'],
'R2(b): status must be error when lock is expired-stale' );
$this->assertStringContainsString(
'infrastructure timeout',
strtolower( $response['message'] ?? '' ),
'R2(b): error message must mention infrastructure timeout'
);
}
// -------------------------------------------------------------------------
// Idle path: no transient, no lock
// -------------------------------------------------------------------------
/**
* When both the transient and the lock are absent, the poller must
* return status:idle (normal rest state between runs).
*/
public function test_no_transient_no_lock_returns_idle(): void {
$this->transient_value = false;
$this->lock_acquired_at = null; // no lock.
$_POST = array( 'nonce' => 'fake' );
$poller = new \PRAutoBlogger_Generation_Status_Poller();
$poller->on_ajax_generation_status();
$this->assertCount( 1, $this->json_success );
$this->assertSame( 'idle', $this->json_success[0]['status'] );
}
}

View File

@@ -1,238 +0,0 @@
<?php
/**
* Tests for PRAutoBlogger_Prompt_Registry (read side).
*
* Locks the two load-bearing contracts of the Phase-1 prompt registry:
* (1) the in-code fallback renders BYTE-IDENTICAL output to the historical
* hardcoded prompt builders when the table is unavailable, and (2) token
* substitution uses the exact `{{ name }}` syntax.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
class PromptRegistryTest extends BaseTestCase {
protected function setUp(): void {
parent::setUp();
// No $wpdb in scope: the registry must read as "unavailable" and
// fall back to the in-code defaults without touching the database.
unset( $GLOBALS['wpdb'] );
\PRAutoBlogger_Prompt_Registry::flush_cache();
}
protected function tearDown(): void {
\PRAutoBlogger_Prompt_Registry::flush_cache();
parent::tearDown();
}
/**
* Token filling: exact `{{ name }}` placeholders, multiple occurrences,
* unknown tokens left intact.
*/
public function test_fill_substitutes_tokens(): void {
$body = 'A {{ one }} B {{ two }} C {{ one }} D {{ missing }}';
$out = \PRAutoBlogger_Prompt_Registry::fill(
$body,
array(
'one' => 'X',
'two' => 'Y',
)
);
$this->assertSame( 'A X B Y C X D {{ missing }}', $out );
}
/**
* With no table available, render() must produce the same single-pass
* prompt the v0.16.0 sprintf produced, byte for byte.
*/
public function test_single_pass_fallback_matches_historical_output(): void {
$rendered = \PRAutoBlogger_Prompt_Registry::render(
'content.single_pass',
array(
'title' => 'My Title',
'topic' => 'My Topic',
'article_type' => 'guide',
'key_points' => implode( "\n- ", array( 'p1', 'p2' ) ),
'keywords' => implode( ', ', array( 'k1', 'k2' ) ),
'min_words' => '800',
'max_words' => '2000',
)
);
$historical = sprintf(
"Write a complete blog post in HTML format.\n\n" .
"Title: %s\nTopic: %s\nType: %s\n\nKey points:\n- %s\n\n" .
"Keywords: %s\n\n" .
"Requirements:\n" .
"- %d-%d words\n" .
"- Proper HTML (h2, h3, p, ul/li)\n" .
"- Engaging intro, strong conclusion with CTA\n" .
"- Do NOT include the title or <html>/<body> tags\n" .
"- Output HTML only, no markdown or commentary\n" .
'- Follow EVERY formatting and structural requirement from your system prompt style guide',
'My Title',
'My Topic',
'guide',
implode( "\n- ", array( 'p1', 'p2' ) ),
implode( ', ', array( 'k1', 'k2' ) ),
800,
2000
);
$this->assertSame( $historical, $rendered );
}
/**
* Outline fallback matches the historical sprintf output byte for byte.
*/
public function test_outline_fallback_matches_historical_output(): void {
$rendered = \PRAutoBlogger_Prompt_Registry::render(
'content.outline',
array(
'title' => 'T',
'topic' => 'Top',
'article_type' => 'guide',
'key_points' => implode( "\n- ", array( 'a', 'b' ) ),
'keywords' => 'k1, k2',
'min_words' => '800',
'max_words' => '2000',
)
);
$historical = sprintf(
"Create a detailed outline for a blog post titled: \"%s\"\n\n" .
"Topic: %s\nArticle type: %s\n\nKey points to cover:\n%s\n\n" .
"Target keywords: %s\n\n" .
'The outline should have 4-6 main sections with bullet points under each. ' .
'Include an introduction hook and a conclusion with a call to action. ' .
"Word count target: %d-%d words.\n\n" .
'Plan the structure to satisfy EVERY requirement in your system prompt style guide.',
'T',
'Top',
'guide',
implode( "\n- ", array( 'a', 'b' ) ),
'k1, k2',
800,
2000
);
$this->assertSame( $historical, $rendered );
}
/**
* Polish fallback matches the historical concatenation byte for byte.
*/
public function test_polish_fallback_matches_historical_output(): void {
$rendered = \PRAutoBlogger_Prompt_Registry::render(
'content.polish',
array( 'draft' => '<p>Hello</p>' )
);
$historical = "Review and polish this blog post draft. Improve:\n" .
"1. Flow and readability\n" .
"2. SEO optimization (headings, keyword placement)\n" .
"3. Engagement (hooks, transitions, call-to-action)\n" .
"4. Accuracy and clarity\n" .
"5. Remove any filler or redundant sentences\n\n" .
'IMPORTANT: Preserve all bullet points, numbered lists, hyperlinks, and ' .
'structural elements from the draft. Do NOT flatten lists into prose or ' .
'remove links. Ensure every requirement from your system prompt style ' .
"guide is satisfied in the final output.\n\n" .
"Return the polished HTML content only. Do not add commentary.\n\n" .
"DRAFT:\n" . '<p>Hello</p>';
$this->assertSame( $historical, $rendered );
}
/**
* Editor system fallback matches the historical builder for both the
* empty and the populated instructions paths.
*/
public function test_editor_system_fallback_matches_historical_output(): void {
$expected_plain = 'You are a senior blog editor specializing in peptides content'
. ". Review article drafts before publication.\n\n"
. "Evaluate on: QUALITY, ACCURACY, SEO, COMPLETENESS, READABILITY.\n"
. "Respond with JSON: {\n"
. ' "verdict": "approved" | "revised" | "rejected",' . "\n"
. ' "quality_score": 0.0-1.0,' . "\n"
. ' "seo_score": 0.0-1.0,' . "\n"
. ' "issues": ["issue1", "issue2"],' . "\n"
. ' "notes": "Editorial notes",' . "\n"
. ' "revised_content": "Full revised HTML if revised, null otherwise"' . "\n"
. "}\n\n"
. "Rules:\n"
. "- APPROVE if quality_score >= 0.7 and seo_score >= 0.6\n"
. "- REVISE if fixable issues exist — provide full revised HTML\n"
. "- REJECT if fundamentally flawed\n"
. "- Preserve formatting, links, lists when revising\n";
$rendered = \PRAutoBlogger_Prompt_Registry::render(
'editor.system',
array(
'niche_clause' => ' specializing in peptides content',
'instructions_block' => '',
)
);
$this->assertSame( $expected_plain, $rendered );
$rendered_with = \PRAutoBlogger_Prompt_Registry::render(
'editor.system',
array(
'niche_clause' => '',
'instructions_block' => "\nAdditional instructions:\nBe terse.\n",
)
);
$this->assertStringEndsWith(
"- Preserve formatting, links, lists when revising\n\nAdditional instructions:\nBe terse.\n",
$rendered_with
);
}
/**
* Every registry key advertised in the plan has a default body, and the
* stage map's prompt keys all resolve to a known key.
*/
public function test_defs_cover_all_planned_keys(): void {
$defs = \PRAutoBlogger_Prompt_Registry::defs();
$keys = array(
'content.system',
'content.single_pass',
'content.outline',
'content.draft',
'content.polish',
'analysis.system',
'analysis.user',
'editor.system',
'editor.review',
'research.system',
'image.rewriter_system',
'image.style_template',
);
foreach ( $keys as $key ) {
$this->assertArrayHasKey( $key, $defs, "Missing registry def for '{$key}'" );
$this->assertNotSame( '', $defs[ $key ]['body'] );
}
foreach ( \PRAutoBlogger_Stage_Display_Map::all() as $stage => $def ) {
if ( null !== $def['prompt_key'] ) {
$this->assertArrayHasKey(
$def['prompt_key'],
$defs,
"Stage '{$stage}' maps to unknown prompt key '{$def['prompt_key']}'"
);
}
}
}
/**
* pins_for_run is empty (not an error) when no run / table is present.
*/
public function test_pins_empty_without_table(): void {
$this->assertSame( array(), \PRAutoBlogger_Prompt_Registry::pins_for_run( null ) );
$this->assertSame( array(), \PRAutoBlogger_Prompt_Registry::pins_for_run( 'run-x' ) );
}
}

View File

@@ -1,183 +0,0 @@
<?php
/**
* Side-effect tests for PRAutoBlogger_Publisher::publish().
*
* Validates the post-creation side effects: taxonomy assignment
* (category from article_type, tags from keywords), generation-log
* linking by run_id, and the public action/filter hooks. Split from
* PublisherTest.php to keep both files under the 300-line rule;
* post-creation/status/metadata/guard tests stay in PublisherTest.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
use Brain\Monkey\Actions;
use Brain\Monkey\Filters;
class PublisherSideEffectsTest extends BaseTestCase {
private \PRAutoBlogger_Article_Idea $idea;
private \PRAutoBlogger_Editorial_Review $review;
protected function setUp(): void {
parent::setUp();
$this->idea = new \PRAutoBlogger_Article_Idea( $this->get_article_idea_fixture() );
$this->review = new \PRAutoBlogger_Editorial_Review( $this->get_editorial_review_fixture() );
// Common stubs needed by Publisher.
$this->stub_get_option( [
'prautoblogger_writing_pipeline' => 'multi_step',
'prautoblogger_writing_model' => 'anthropic/claude-sonnet-4',
'prautoblogger_default_author' => 1,
] );
Functions\when( 'sanitize_text_field' )->returnArg();
Functions\when( 'wp_kses_post' )->returnArg();
// v0.18.1 empty-content guard strips tags before the emptiness check.
Functions\when( 'wp_strip_all_tags' )->alias( function ( $text ) {
return trim( strip_tags( (string) $text ) );
} );
Functions\when( 'is_wp_error' )->justReturn( false );
Functions\when( 'apply_filters' )->returnArg( 2 );
Functions\when( 'absint' )->alias( function ( $val ) { return abs( (int) $val ); } );
Functions\when( 'get_users' )->justReturn( [ 1 ] );
Functions\when( 'get_term_by' )->justReturn( false );
Functions\when( 'wp_insert_term' )->justReturn( [ 'term_id' => 5 ] );
Functions\when( 'wp_set_post_categories' )->justReturn( true );
Functions\when( 'wp_set_post_tags' )->justReturn( true );
}
/**
* Test that taxonomy terms are assigned based on article_type from the idea.
*/
public function test_publish_assigns_category_from_article_type(): void {
Functions\when( 'wp_insert_post' )->justReturn( 46 );
// get_term_by returns false (category doesn't exist), so wp_insert_term is called.
$captured_category = null;
Functions\when( 'wp_insert_term' )->alias( function ( $name, $taxonomy ) use ( &$captured_category ) {
$captured_category = $name;
return [ 'term_id' => 10 ];
} );
$captured_category_ids = null;
Functions\when( 'wp_set_post_categories' )->alias( function ( $post_id, $cat_ids ) use ( &$captured_category_ids ) {
$captured_category_ids = $cat_ids;
return true;
} );
$wpdb = $this->create_mock_wpdb();
$wpdb->method( 'query' )->willReturn( 0 );
$wpdb->method( 'prepare' )->willReturn( '' );
$GLOBALS['wpdb'] = $wpdb;
$publisher = new \PRAutoBlogger_Publisher();
$publisher->publish( '<p>Content</p>', $this->idea, $this->review );
// Article type is 'guide' → category should be 'Guides'.
$this->assertSame( 'Guides', $captured_category );
$this->assertSame( [ 10 ], $captured_category_ids );
}
/**
* Test that target keywords are set as post tags.
*/
public function test_publish_sets_tags_from_keywords(): void {
Functions\when( 'wp_insert_post' )->justReturn( 47 );
$captured_tags = null;
Functions\when( 'wp_set_post_tags' )->alias( function ( $post_id, $tags, $append ) use ( &$captured_tags ) {
$captured_tags = $tags;
return true;
} );
$wpdb = $this->create_mock_wpdb();
$wpdb->method( 'query' )->willReturn( 0 );
$wpdb->method( 'prepare' )->willReturn( '' );
$GLOBALS['wpdb'] = $wpdb;
$publisher = new \PRAutoBlogger_Publisher();
$publisher->publish( '<p>Content</p>', $this->idea, $this->review );
$this->assertSame( [ 'test', 'keyword', 'example' ], $captured_tags );
}
/**
* Test that generation log entries are linked via run_id.
*/
public function test_publish_links_generation_logs_by_run_id(): void {
Functions\when( 'wp_insert_post' )->justReturn( 48 );
// Create an idea fixture without source_ids to avoid triggering SourceCollector
// queries that would overwrite captured_query in the mock.
$fixture = $this->get_article_idea_fixture();
$fixture['source_ids'] = [];
$idea = new \PRAutoBlogger_Article_Idea( $fixture );
$captured_query = null;
$wpdb = $this->create_mock_wpdb();
$wpdb->method( 'prepare' )->willReturnCallback( function ( $sql, ...$args ) use ( &$captured_query ) {
$captured_query = $sql;
return $sql;
} );
$wpdb->method( 'query' )->willReturn( 1 );
$GLOBALS['wpdb'] = $wpdb;
$publisher = new \PRAutoBlogger_Publisher();
$publisher->publish( '<p>Content</p>', $idea, $this->review, 'run_link_test' );
// Should use run_id-based query, not timestamp-based.
$this->assertStringContainsString( 'run_id', $captured_query );
$this->assertStringNotContainsString( 'created_at', $captured_query );
}
/**
* Test that the prautoblogger_post_created action is fired after publishing.
*/
public function test_publish_fires_post_created_action(): void {
// TODO(peptiderepo): this test is latently broken — Mockery expects do_action_prautoblogger_post_created
// to be called, but Publisher::publish() is not firing it in the current mock setup. Track in a follow-up
// PR; skipping for now so CI parity can land. The bug is likely a mis-mocked do_action or a refactor
// that removed the action fire.
$this->markTestSkipped( 'Latent bug — see TODO; tracked for follow-up PR.' );
Functions\when( 'wp_insert_post' )->justReturn( 49 );
Functions\when( 'do_action' )->alias( function () {} );
Actions\expectDone( 'prautoblogger_post_created' )
->once()
->with( 49, 'publish', $this->idea, $this->review );
$wpdb = $this->create_mock_wpdb();
$wpdb->method( 'query' )->willReturn( 0 );
$wpdb->method( 'prepare' )->willReturn( '' );
$GLOBALS['wpdb'] = $wpdb;
$publisher = new \PRAutoBlogger_Publisher();
$publisher->publish( '<p>Content</p>', $this->idea, $this->review );
}
/**
* Test that the prautoblogger_filter_post_data filter is applied.
*/
public function test_publish_applies_post_data_filter(): void {
// TODO(peptiderepo): latently broken alongside test_publish_fires_post_created_action.
// apply_filters('prautoblogger_post_data', ...) expected but not called. Follow-up PR.
$this->markTestSkipped( 'Latent bug — see TODO; tracked for follow-up PR.' );
Functions\when( 'wp_insert_post' )->justReturn( 50 );
Filters\expectApplied( 'prautoblogger_filter_post_data' )->once();
$wpdb = $this->create_mock_wpdb();
$wpdb->method( 'query' )->willReturn( 0 );
$wpdb->method( 'prepare' )->willReturn( '' );
$GLOBALS['wpdb'] = $wpdb;
$publisher = new \PRAutoBlogger_Publisher();
$publisher->publish( '<p>Content</p>', $this->idea, $this->review );
}
}

View File

@@ -13,6 +13,8 @@ namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
use Brain\Monkey\Actions;
use Brain\Monkey\Filters;
class PublisherTest extends BaseTestCase {
@@ -34,10 +36,6 @@ class PublisherTest extends BaseTestCase {
Functions\when( 'sanitize_text_field' )->returnArg();
Functions\when( 'wp_kses_post' )->returnArg();
// v0.18.1 empty-content guard strips tags before the emptiness check.
Functions\when( 'wp_strip_all_tags' )->alias( function ( $text ) {
return trim( strip_tags( (string) $text ) );
} );
Functions\when( 'is_wp_error' )->justReturn( false );
Functions\when( 'apply_filters' )->returnArg( 2 );
Functions\when( 'absint' )->alias( function ( $val ) { return abs( (int) $val ); } );
@@ -170,33 +168,131 @@ class PublisherTest extends BaseTestCase {
}
/**
* v0.18.1 belt-and-braces: save_as_draft() with empty content throws
* and never reaches wp_insert_post — no empty draft post can be
* created (the 2026-06-11 post-921 incident shape).
* Test that taxonomy terms are assigned based on article_type from the idea.
*/
public function test_save_as_draft_refuses_empty_content(): void {
Functions\expect( 'wp_insert_post' )->never();
public function test_publish_assigns_category_from_article_type(): void {
Functions\when( 'wp_insert_post' )->justReturn( 46 );
// get_term_by returns false (category doesn't exist), so wp_insert_term is called.
$captured_category = null;
Functions\when( 'wp_insert_term' )->alias( function ( $name, $taxonomy ) use ( &$captured_category ) {
$captured_category = $name;
return [ 'term_id' => 10 ];
} );
$captured_category_ids = null;
Functions\when( 'wp_set_post_categories' )->alias( function ( $post_id, $cat_ids ) use ( &$captured_category_ids ) {
$captured_category_ids = $cat_ids;
return true;
} );
$wpdb = $this->create_mock_wpdb();
$wpdb->method( 'query' )->willReturn( 0 );
$wpdb->method( 'prepare' )->willReturn( '' );
$GLOBALS['wpdb'] = $wpdb;
$publisher = new \PRAutoBlogger_Publisher();
$publisher->publish( '<p>Content</p>', $this->idea, $this->review );
$this->expectException( \RuntimeException::class );
$this->expectExceptionMessage( 'generated content is empty' );
$publisher->save_as_draft( '', $this->idea, $this->review, 'run_empty_draft' );
// Article type is 'guide' → category should be 'Guides'.
$this->assertSame( 'Guides', $captured_category );
$this->assertSame( [ 10 ], $captured_category_ids );
}
/**
* v0.18.1 belt-and-braces: publish() refuses content that is empty
* once tags and whitespace are stripped (an empty HTML shell).
* Test that target keywords are set as post tags.
*/
public function test_publish_refuses_whitespace_only_html_content(): void {
Functions\expect( 'wp_insert_post' )->never();
public function test_publish_sets_tags_from_keywords(): void {
Functions\when( 'wp_insert_post' )->justReturn( 47 );
$captured_tags = null;
Functions\when( 'wp_set_post_tags' )->alias( function ( $post_id, $tags, $append ) use ( &$captured_tags ) {
$captured_tags = $tags;
return true;
} );
$wpdb = $this->create_mock_wpdb();
$wpdb->method( 'query' )->willReturn( 0 );
$wpdb->method( 'prepare' )->willReturn( '' );
$GLOBALS['wpdb'] = $wpdb;
$publisher = new \PRAutoBlogger_Publisher();
$publisher->publish( '<p>Content</p>', $this->idea, $this->review );
$this->expectException( \RuntimeException::class );
$this->expectExceptionMessage( 'generated content is empty' );
$this->assertSame( [ 'test', 'keyword', 'example' ], $captured_tags );
}
$publisher->publish( "<p> \n\t </p>", $this->idea, $this->review );
/**
* Test that generation log entries are linked via run_id.
*/
public function test_publish_links_generation_logs_by_run_id(): void {
Functions\when( 'wp_insert_post' )->justReturn( 48 );
// Create an idea fixture without source_ids to avoid triggering SourceCollector
// queries that would overwrite captured_query in the mock.
$fixture = $this->get_article_idea_fixture();
$fixture['source_ids'] = [];
$idea = new \PRAutoBlogger_Article_Idea( $fixture );
$captured_query = null;
$wpdb = $this->create_mock_wpdb();
$wpdb->method( 'prepare' )->willReturnCallback( function ( $sql, ...$args ) use ( &$captured_query ) {
$captured_query = $sql;
return $sql;
} );
$wpdb->method( 'query' )->willReturn( 1 );
$GLOBALS['wpdb'] = $wpdb;
$publisher = new \PRAutoBlogger_Publisher();
$publisher->publish( '<p>Content</p>', $idea, $this->review, 'run_link_test' );
// Should use run_id-based query, not timestamp-based.
$this->assertStringContainsString( 'run_id', $captured_query );
$this->assertStringNotContainsString( 'created_at', $captured_query );
}
/**
* Test that the prautoblogger_post_created action is fired after publishing.
*/
public function test_publish_fires_post_created_action(): void {
// TODO(peptiderepo): this test is latently broken — Mockery expects do_action_prautoblogger_post_created
// to be called, but Publisher::publish() is not firing it in the current mock setup. Track in a follow-up
// PR; skipping for now so CI parity can land. The bug is likely a mis-mocked do_action or a refactor
// that removed the action fire.
$this->markTestSkipped( 'Latent bug — see TODO; tracked for follow-up PR.' );
Functions\when( 'wp_insert_post' )->justReturn( 49 );
Functions\when( 'do_action' )->alias( function () {} );
Actions\expectDone( 'prautoblogger_post_created' )
->once()
->with( 49, 'publish', $this->idea, $this->review );
$wpdb = $this->create_mock_wpdb();
$wpdb->method( 'query' )->willReturn( 0 );
$wpdb->method( 'prepare' )->willReturn( '' );
$GLOBALS['wpdb'] = $wpdb;
$publisher = new \PRAutoBlogger_Publisher();
$publisher->publish( '<p>Content</p>', $this->idea, $this->review );
}
/**
* Test that the prautoblogger_filter_post_data filter is applied.
*/
public function test_publish_applies_post_data_filter(): void {
// TODO(peptiderepo): latently broken alongside test_publish_fires_post_created_action.
// apply_filters('prautoblogger_post_data', ...) expected but not called. Follow-up PR.
$this->markTestSkipped( 'Latent bug — see TODO; tracked for follow-up PR.' );
Functions\when( 'wp_insert_post' )->justReturn( 50 );
Filters\expectApplied( 'prautoblogger_filter_post_data' )->once();
$wpdb = $this->create_mock_wpdb();
$wpdb->method( 'query' )->willReturn( 0 );
$wpdb->method( 'prepare' )->willReturn( '' );
$GLOBALS['wpdb'] = $wpdb;
$publisher = new \PRAutoBlogger_Publisher();
$publisher->publish( '<p>Content</p>', $this->idea, $this->review );
}
}

View File

@@ -1,115 +0,0 @@
<?php
/**
* Tests for PRAutoBlogger_Run_Reaper.
*
* Locks the retention contract (R days from the SETTING, 0 = keep
* forever) and the stuck-sweep SQL shapes (2x expected wall-clock,
* running-only).
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class RunReaperTest extends BaseTestCase {
/**
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb.
*/
private $wpdb;
/**
* Captured SQL passed through $wpdb->query().
*
* @var string[]
*/
private array $queries = array();
protected function setUp(): void {
parent::setUp();
$this->queries = array();
// Local mock: the reaper also uses get_col(), which the shared
// BaseTestCase mock does not declare.
$this->wpdb = $this->getMockBuilder( \stdClass::class )
->addMethods( array( 'prepare', 'get_var', 'get_results', 'get_col', 'insert', 'query', 'get_row', 'update' ) )
->getMock();
$this->wpdb->prefix = 'wp_';
$this->wpdb->insert_id = 0;
$this->wpdb->last_error = '';
$GLOBALS['wpdb'] = $this->wpdb;
$this->wpdb->method( 'prepare' )->willReturnCallback(
static function ( $sql, ...$args ) {
return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */';
}
);
Functions\when( 'apply_filters' )->returnArg( 2 );
\PRAutoBlogger_Run_State::flush_cache();
\PRAutoBlogger_Run_Stage_State::flush_cache();
}
protected function tearDown(): void {
\PRAutoBlogger_Run_State::flush_cache();
\PRAutoBlogger_Run_Stage_State::flush_cache();
unset( $GLOBALS['wpdb'] );
parent::tearDown();
}
/**
* Retention 0 (or negative) means "keep forever": the pruner must not
* issue any UPDATE.
*/
public function test_zero_retention_keeps_payloads_forever(): void {
$this->stub_get_option(
array(
'prautoblogger_request_json_retention_days' => 0,
)
);
$this->wpdb->method( 'get_var' )->willReturn( null ); // No state tables.
$this->wpdb->method( 'get_col' )->willReturn( array() );
$this->wpdb->expects( $this->never() )->method( 'query' );
$stats = \PRAutoBlogger_Run_Reaper::reap();
$this->assertSame( 0, $stats['payloads_pruned'] );
}
/**
* With the default setting the pruner NULLs request_json older than R
* days on the generation log (and stage payloads when that table
* exists). The sweeps only touch 'running'/open rows.
*/
public function test_reap_prunes_and_sweeps_with_setting_backed_window(): void {
$this->stub_get_option(
array(
'prautoblogger_request_json_retention_days' => PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS,
)
);
// Both state tables exist.
$this->wpdb->method( 'get_var' )->willReturnCallback(
static function ( $sql ) {
if ( false !== strpos( (string) $sql, 'run_stages' ) ) {
return 'wp_prautoblogger_run_stages';
}
return 'wp_prautoblogger_runs';
}
);
$this->wpdb->method( 'get_col' )->willReturn( array() ); // No stuck runs.
$this->wpdb->method( 'query' )->willReturnCallback(
function ( $sql ) {
$this->queries[] = (string) $sql;
return 2;
}
);
$stats = \PRAutoBlogger_Run_Reaper::reap();
$all_sql = implode( "\n", $this->queries );
$this->assertStringContainsString( 'request_json = NULL', $all_sql );
$this->assertStringContainsString( 'meta_json = NULL', $all_sql );
$this->assertStringContainsString( "status = 'failed'", $all_sql );
$this->assertStringContainsString( "WHERE status = 'running' AND updated_at <", $all_sql );
$this->assertSame( 4, $stats['payloads_pruned'] ); // 2 rows x 2 tables.
}
}

View File

@@ -1,212 +0,0 @@
<?php
/**
* Tests for PRAutoBlogger_Run_Stage_State.
*
* Locks the idempotency-key contract (item scoping for multi-article
* runs), the sticky-done upsert semantics, and the self-healing
* no-table degradation.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
class RunStageStateTest extends BaseTestCase {
/**
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb.
*/
private $wpdb;
/**
* Captured SQL passed through $wpdb->query().
*
* @var string[]
*/
private array $queries = array();
protected function setUp(): void {
parent::setUp();
$this->queries = array();
$this->wpdb = $this->create_mock_wpdb();
$GLOBALS['wpdb'] = $this->wpdb;
$this->wpdb->method( 'prepare' )->willReturnCallback(
static function ( $sql, ...$args ) {
return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */';
}
);
\PRAutoBlogger_Run_Stage_State::flush_cache();
}
protected function tearDown(): void {
\PRAutoBlogger_Run_Stage_State::flush_cache();
unset( $GLOBALS['wpdb'] );
parent::tearDown();
}
/**
* Two different ideas in the same run must get different item keys
* (one run_id spans all N articles of a batch run); the same idea must
* hash stably across processes (queue serialization round-trip).
*/
public function test_item_keys_scope_articles_within_a_run(): void {
$idea_a = new \PRAutoBlogger_Article_Idea(
array(
'topic' => 'BPC-157 dosing',
'article_type' => 'guide',
'suggested_title' => 'BPC-157 Dosing Guide',
'summary' => 's',
)
);
$idea_b = new \PRAutoBlogger_Article_Idea(
array(
'topic' => 'TB-500 storage',
'article_type' => 'question',
'suggested_title' => 'How To Store TB-500',
'summary' => 's',
)
);
$idea_a_again = new \PRAutoBlogger_Article_Idea( $idea_a->to_array() );
$key_a = \PRAutoBlogger_Run_Stage_State::item_key_for_idea( $idea_a );
$key_b = \PRAutoBlogger_Run_Stage_State::item_key_for_idea( $idea_b );
$this->assertNotSame( $key_a, $key_b );
$this->assertSame( $key_a, \PRAutoBlogger_Run_Stage_State::item_key_for_idea( $idea_a_again ) );
$this->assertStringStartsWith( 'idea:', $key_a );
$this->assertLessThanOrEqual( 64, strlen( $key_a ) );
$this->assertSame(
\PRAutoBlogger_Run_Stage_State::idea_hash( $idea_a ),
substr( $key_a, 5 )
);
}
/**
* start() upserts must never demote a done stage and must bump attempt
* on re-entry; done() must be sticky.
*/
public function test_start_upsert_protects_done_status(): void {
$this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_run_stages' );
$this->wpdb->method( 'query' )->willReturnCallback(
function ( $sql ) {
$this->queries[] = (string) $sql;
return 1;
}
);
\PRAutoBlogger_Run_Stage_State::start( 'run-1', 'draft', '', 'idea:abc' );
$sql = implode( "\n", $this->queries );
$this->assertStringContainsString( 'ON DUPLICATE KEY UPDATE', $sql );
$this->assertStringContainsString( "IF(status = 'done', attempt, attempt + 1)", $sql );
$this->assertStringContainsString( "IF(status = 'done', 'done', 'running')", $sql );
}
/**
* fail() and fail_open_for_item() must never touch done rows.
*/
public function test_fail_paths_exclude_done_rows(): void {
$this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_run_stages' );
$this->wpdb->method( 'query' )->willReturnCallback(
function ( $sql ) {
$this->queries[] = (string) $sql;
return 1;
}
);
\PRAutoBlogger_Run_Stage_State::fail( 'run-1', 'draft', '', 'idea:abc' );
\PRAutoBlogger_Run_Stage_State::fail_open_for_item( 'run-1', 'idea:abc' );
$this->assertStringContainsString( "status != 'done'", $this->queries[0] );
$this->assertStringContainsString( "status IN ('pending','running')", $this->queries[1] );
}
/**
* Without the table (half-migrated site) every method degrades to a
* harmless no-op/false/null — never a fatal.
*/
public function test_no_table_degrades_to_noops(): void {
$this->wpdb->method( 'get_var' )->willReturn( null ); // SHOW TABLES miss.
$this->wpdb->expects( $this->never() )->method( 'query' );
\PRAutoBlogger_Run_Stage_State::start( 'run-1', 'draft' );
\PRAutoBlogger_Run_Stage_State::done( 'run-1', 'draft', '', '', 'output' );
\PRAutoBlogger_Run_Stage_State::fail( 'run-1', 'draft' );
$this->assertFalse( \PRAutoBlogger_Run_Stage_State::is_done( 'run-1', 'draft' ) );
$this->assertNull( \PRAutoBlogger_Run_Stage_State::get_output( 'run-1', 'draft' ) );
}
/**
* start(role) → done(role) → is_done(role) must be true; is_done('') must
* be false — this catches the v0.18.2 regression where done() passed ''
* while start() passed a real role, so done() addressed a different row.
*
* In this test the wpdb mock records inserts via query() and simulates
* the SELECT via get_row():
* is_done('review','editor') → get_row finds the done row → true.
* is_done('review','') → resolve_role→'editor'; primary: miss;
* legacy fallback (role=''): miss → false.
*/
public function test_role_tagged_start_and_done_are_symmetric(): void {
$get_row_results = array(
array( 'status' => 'done', 'meta_json' => null ), // primary hit for 'editor'.
null, // primary miss for legacy check (role='editor' resolved, not '').
null, // legacy fallback (role='') also not found.
);
$call_index = 0;
$this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_run_stages' );
$this->wpdb->method( 'query' )->willReturn( 1 );
$this->wpdb->method( 'get_row' )->willReturnCallback(
static function () use ( &$get_row_results, &$call_index ) {
$result = $get_row_results[ $call_index ] ?? null;
++$call_index;
return $result;
}
);
\PRAutoBlogger_Run_Stage_State::start( 'run-2', 'review', 'editor', 'idea:xyz' );
\PRAutoBlogger_Run_Stage_State::done( 'run-2', 'review', 'editor', 'idea:xyz' );
// Explicit role finds the done row.
$this->assertTrue(
\PRAutoBlogger_Run_Stage_State::is_done( 'run-2', 'review', 'editor', 'idea:xyz' ),
'is_done with explicit role should be true'
);
// '' resolves to 'editor'; primary misses; legacy '' fallback also misses.
$this->assertFalse(
\PRAutoBlogger_Run_Stage_State::is_done( 'run-2', 'review', '', 'idea:xyz' ),
'is_done with legacy empty role should be false (no legacy row exists)'
);
}
/**
* Mid-run-upgrade resume: a row created by v0.18.1 (agent_role='') must
* still be found by is_done('') under v0.18.2 via the legacy fallback.
* resolve_role('review','') → 'editor'; primary query misses; legacy
* fallback (role='') hits → is_done returns true.
*/
public function test_legacy_empty_role_row_is_found_on_fallback(): void {
$call_index = 0;
$get_row_results = array(
null, // primary query (role='editor'): miss.
array( 'status' => 'done', 'meta_json' => null ), // legacy fallback (role=''): hit.
);
$this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_run_stages' );
$this->wpdb->method( 'get_row' )->willReturnCallback(
static function () use ( &$get_row_results, &$call_index ) {
$result = $get_row_results[ $call_index ] ?? null;
++$call_index;
return $result;
}
);
// is_done('') resolves to 'editor', misses, falls back to role='',
// finds the v0.18.1 row → true.
$this->assertTrue(
\PRAutoBlogger_Run_Stage_State::is_done( 'run-3', 'review', '', 'idea:abc' ),
'v0.18.1 empty-role row must be found via legacy fallback'
);
}
}

View File

@@ -1,98 +0,0 @@
<?php
/**
* Tests for PRAutoBlogger_Stage_Display_Map.
*
* Locks the stage vocabulary contract from the Pipeline v2 Phase 1 brief:
* every historical prod stage value (incl. the image stages) and every
* Pipeline v2 stage value must render, carry a default agent role, and —
* where applicable — map to a prompt-registry key. Unknown values must
* humanize, never render blank.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
class StageDisplayMapTest extends BaseTestCase {
/**
* Every historical stage value observed on prod (90d window, 2026-06-11)
* plus the code-only opik_eval_judge must be known to the map.
*/
public function test_all_historical_stages_are_known(): void {
$historical = array(
'analysis',
'outline',
'draft',
'polish',
'review',
'llm_research',
'image_a',
'image_b',
'image_prompt_rewrite',
'opik_eval_judge',
);
foreach ( $historical as $stage ) {
$this->assertTrue(
\PRAutoBlogger_Stage_Display_Map::is_known( $stage ),
"Historical stage '{$stage}' must be in the display map."
);
$this->assertNotSame( '', \PRAutoBlogger_Stage_Display_Map::label( $stage ) );
}
}
/**
* The Pipeline v2 vocabulary must be known to the map.
*/
public function test_all_v2_stages_are_known(): void {
foreach ( array( 'research', 'curate', 'draft', 'editorial', 'seo', 'publish' ) as $stage ) {
$this->assertTrue( \PRAutoBlogger_Stage_Display_Map::is_known( $stage ) );
}
}
/**
* Unknown stages humanize instead of rendering blank.
*/
public function test_unknown_stage_humanizes(): void {
$this->assertSame(
'Some Future Stage',
\PRAutoBlogger_Stage_Display_Map::label( 'some_future_stage' )
);
$this->assertFalse( \PRAutoBlogger_Stage_Display_Map::is_known( 'some_future_stage' ) );
$this->assertNull( \PRAutoBlogger_Stage_Display_Map::default_agent_role( 'some_future_stage' ) );
$this->assertNull( \PRAutoBlogger_Stage_Display_Map::default_prompt_key( 'some_future_stage' ) );
}
/**
* Stage → default agent role mapping (stamped on generation_log rows
* when the call site does not pass a role).
*/
public function test_default_agent_roles(): void {
$this->assertSame( 'analyst', \PRAutoBlogger_Stage_Display_Map::default_agent_role( 'analysis' ) );
$this->assertSame( 'writer', \PRAutoBlogger_Stage_Display_Map::default_agent_role( 'draft' ) );
$this->assertSame( 'writer', \PRAutoBlogger_Stage_Display_Map::default_agent_role( 'polish' ) );
$this->assertSame( 'editor', \PRAutoBlogger_Stage_Display_Map::default_agent_role( 'review' ) );
$this->assertSame( 'researcher', \PRAutoBlogger_Stage_Display_Map::default_agent_role( 'llm_research' ) );
$this->assertSame( 'illustrator', \PRAutoBlogger_Stage_Display_Map::default_agent_role( 'image_a' ) );
$this->assertSame( 'illustrator', \PRAutoBlogger_Stage_Display_Map::default_agent_role( 'image_prompt_rewrite' ) );
}
/**
* Stage → primary prompt-registry key (resolves the pinned
* prompt_version stamped on rows). The image keys are the composer-PR
* seam and must not change without coordinating on the thread.
*/
public function test_default_prompt_keys(): void {
$this->assertSame( 'analysis.system', \PRAutoBlogger_Stage_Display_Map::default_prompt_key( 'analysis' ) );
$this->assertSame( 'content.outline', \PRAutoBlogger_Stage_Display_Map::default_prompt_key( 'outline' ) );
$this->assertSame( 'content.draft', \PRAutoBlogger_Stage_Display_Map::default_prompt_key( 'draft' ) );
$this->assertSame( 'content.polish', \PRAutoBlogger_Stage_Display_Map::default_prompt_key( 'polish' ) );
$this->assertSame( 'editor.system', \PRAutoBlogger_Stage_Display_Map::default_prompt_key( 'review' ) );
$this->assertSame( 'research.system', \PRAutoBlogger_Stage_Display_Map::default_prompt_key( 'llm_research' ) );
$this->assertSame( 'image.style_template', \PRAutoBlogger_Stage_Display_Map::default_prompt_key( 'image_a' ) );
$this->assertSame( 'image.style_template', \PRAutoBlogger_Stage_Display_Map::default_prompt_key( 'image_b' ) );
$this->assertSame( 'image.rewriter_system', \PRAutoBlogger_Stage_Display_Map::default_prompt_key( 'image_prompt_rewrite' ) );
}
}

View File

@@ -1,240 +0,0 @@
<?php
/**
* Tests for PRAutoBlogger_OpenRouter_Completion_Guard (v0.18.1).
*
* Validates the empty-completion verdict matrix: healthy pass-through,
* the single reasoning-disabled retry on length/reasoning empties, the
* never-retry-twice rule, and the hard failure for unexplained empties.
*
* @package PRAutoBlogger\Tests\Providers
*/
namespace PRAutoBlogger\Tests\Providers;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class OpenRouterCompletionGuardTest extends BaseTestCase {
protected function setUp(): void {
parent::setUp();
\PRAutoBlogger_Run_Context::clear();
\PRAutoBlogger_Audit_Writer::flush_cache();
$this->stub_get_option( [
'prautoblogger_log_level' => 'error',
] );
// Cost_Tracker::log_api_call() (failed-attempt booking) inserts a
// generation_log row and prices the burn via the pricing chain.
$wpdb = $this->create_mock_wpdb();
$wpdb->method( 'insert' )->willReturn( 1 );
$wpdb->insert_id = 7;
$GLOBALS['wpdb'] = $wpdb;
}
protected function tearDown(): void {
unset( $GLOBALS['wpdb'] );
\PRAutoBlogger_Run_Context::clear();
\PRAutoBlogger_Audit_Writer::flush_cache();
parent::tearDown();
}
/**
* Build a parsed-response fixture in the Response_Parser shape.
*
* @param string $content Visible content.
* @param string $finish finish_reason value.
* @return array
*/
private function parsed( string $content, string $finish ): array {
return [
'content' => $content,
'model' => 'deepseek/deepseek-v4-flash',
'prompt_tokens' => 1200,
'completion_tokens' => 4000,
'reasoning_tokens' => 4000,
'total_tokens' => 5200,
'finish_reason' => $finish,
'reasoning_content' => 'thinking…',
];
}
/**
* Healthy completion passes through untouched; the retry callable is
* never invoked.
*/
public function test_healthy_completion_passes_through(): void {
$parsed = $this->parsed( 'A full article.', 'stop' );
$invoked = 0;
$result = ( new \PRAutoBlogger_OpenRouter_Completion_Guard() )->finalize(
$parsed,
[ 'stage' => 'draft' ],
false,
function () use ( &$invoked ) {
++$invoked;
return [];
}
);
$this->assertSame( $parsed, $result );
$this->assertSame( 0, $invoked );
}
/**
* The incident shape — empty content + finish_reason=length — retries
* exactly once with reasoning disabled and the empty_retry marker set,
* and returns the retry's result.
*/
public function test_empty_plus_length_retries_once_with_reasoning_disabled(): void {
$recovered = $this->parsed( 'Recovered article.', 'stop' );
$captured_opts = null;
$result = ( new \PRAutoBlogger_OpenRouter_Completion_Guard() )->finalize(
$this->parsed( '', 'length' ),
[ 'stage' => 'draft', 'prompt_key' => 'content.single_pass' ],
true,
function ( array $retry_options ) use ( &$captured_opts, $recovered ) {
$captured_opts = $retry_options;
return $recovered;
}
);
$this->assertSame( $recovered, $result );
$this->assertSame( [ 'enabled' => false ], $captured_opts['reasoning'] );
$this->assertTrue( $captured_opts['empty_retry'] );
$this->assertSame( 'draft', $captured_opts['stage'] );
}
/**
* Whitespace-only content counts as empty (the guard trims).
*/
public function test_whitespace_only_content_counts_as_empty(): void {
$invoked = 0;
( new \PRAutoBlogger_OpenRouter_Completion_Guard() )->finalize(
$this->parsed( " \n\t ", 'length' ),
[ 'stage' => 'polish' ],
true,
function ( array $retry_options ) use ( &$invoked ) {
++$invoked;
return $this->parsed( 'ok', 'stop' );
}
);
$this->assertSame( 1, $invoked );
}
/**
* An attempt already marked empty_retry is never retried again — the
* guard fails the call instead (no infinite retry loops).
*/
public function test_already_retried_empty_fails_without_second_retry(): void {
$invoked = 0;
try {
( new \PRAutoBlogger_OpenRouter_Completion_Guard() )->finalize(
$this->parsed( '', 'length' ),
[ 'stage' => 'draft', 'empty_retry' => true ],
false,
function () use ( &$invoked ) {
++$invoked;
return [];
}
);
$this->fail( 'Expected RuntimeException for retried-and-still-empty completion.' );
} catch ( \RuntimeException $e ) {
$this->assertStringContainsString( 'empty content', $e->getMessage() );
$this->assertStringContainsString( 'after a reasoning-disabled retry', $e->getMessage() );
}
$this->assertSame( 0, $invoked );
}
/**
* Empty content with finish_reason=stop and no reasoning active: a
* reasoning-disabled retry cannot plausibly help — fail immediately.
*/
public function test_unexplained_empty_fails_without_retry(): void {
$invoked = 0;
try {
( new \PRAutoBlogger_OpenRouter_Completion_Guard() )->finalize(
$this->parsed( '', 'stop' ),
[ 'stage' => 'review' ],
false,
function () use ( &$invoked ) {
++$invoked;
return [];
}
);
$this->fail( 'Expected RuntimeException for unexplained empty completion.' );
} catch ( \RuntimeException $e ) {
$this->assertStringContainsString( 'stage "review"', $e->getMessage() );
$this->assertStringContainsString( 'finish_reason=stop', $e->getMessage() );
}
$this->assertSame( 0, $invoked );
}
/**
* Empty content with reasoning active retries even when finish_reason
* is not 'length' (reasoning can eat the budget without a length flag).
*/
public function test_empty_with_reasoning_active_retries_on_any_finish_reason(): void {
$invoked = 0;
( new \PRAutoBlogger_OpenRouter_Completion_Guard() )->finalize(
$this->parsed( '', 'stop' ),
[ 'stage' => 'draft' ],
true,
function ( array $retry_options ) use ( &$invoked ) {
++$invoked;
return $this->parsed( 'ok', 'stop' );
}
);
$this->assertSame( 1, $invoked );
}
/**
* The failed attempt is booked to generation_log with its REAL status:
* response_status='error', the stage, the model, and the token burn.
*/
public function test_failed_attempt_books_generation_log_row_with_error_status(): void {
$captured_row = null;
$wpdb = $this->create_mock_wpdb();
$wpdb->method( 'insert' )->willReturnCallback(
function ( $table, $row ) use ( &$captured_row ) {
if ( false !== strpos( (string) $table, 'generation_log' ) ) {
$captured_row = $row;
}
return 1;
}
);
$wpdb->insert_id = 9;
$GLOBALS['wpdb'] = $wpdb;
try {
( new \PRAutoBlogger_OpenRouter_Completion_Guard() )->finalize(
$this->parsed( '', 'stop' ),
[ 'stage' => 'draft' ],
false,
function () {
return [];
}
);
$this->fail( 'Expected RuntimeException.' );
} catch ( \RuntimeException $e ) {
$this->assertIsArray( $captured_row );
$this->assertSame( 'error', $captured_row['response_status'] );
$this->assertSame( 'draft', $captured_row['stage'] );
$this->assertSame( 'deepseek/deepseek-v4-flash', $captured_row['model'] );
$this->assertSame( 4000, $captured_row['completion_tokens'] );
$this->assertStringContainsString( 'Empty completion', $captured_row['error_message'] );
}
}
}

View File

@@ -177,89 +177,4 @@ class OpenRouterPricingTest extends BaseTestCase {
// At least some models should be present.
$this->assertGreaterThan( 0, count( $models ) );
}
/**
* Test a snapshot-suffixed response model ID resolves to the base model's price.
*
* OpenRouter sometimes returns a dated snapshot ID as the response model
* (e.g. 'deepseek/deepseek-v4-flash-20260423' for a
* 'deepseek/deepseek-v4-flash' request). Pricing must strip the date
* suffix and bill at the base model's rate, not the conservative fallback.
*/
public function test_estimate_cost_snapshot_suffix_resolves_to_base_model_price(): void {
Functions\when( 'get_transient' )->alias(
function ( string $key ) {
if ( 'prautoblogger_openrouter_models' === $key ) {
return [
[
'id' => 'deepseek/deepseek-v4-flash',
'name' => 'DeepSeek V4 Flash',
'context_length' => 131072,
'pricing' => [ 'prompt' => 0.05, 'completion' => 0.20 ],
],
];
}
return false;
}
);
$pricing = new \PRAutoBlogger_OpenRouter_Pricing();
$base_cost = $pricing->estimate_cost( 'deepseek/deepseek-v4-flash', 1000, 500 );
$snapshot_cost = $pricing->estimate_cost( 'deepseek/deepseek-v4-flash-20260423', 1000, 500 );
// Billed exactly like the base model: (1000 * 0.05 + 500 * 0.20) / 1M.
$this->assertSame( $base_cost, $snapshot_cost );
$this->assertEqualsWithDelta( 0.00015, $snapshot_cost, 1e-12 );
// And not at the conservative fallback rate (10/30 per M = 0.025 here).
$this->assertNotEquals( 0.025, $snapshot_cost );
}
/**
* Test an unknown model with no date suffix still gets the conservative estimate.
*/
public function test_estimate_cost_unknown_model_without_suffix_uses_conservative_estimate(): void {
$pricing = new \PRAutoBlogger_OpenRouter_Pricing();
$cost = $pricing->estimate_cost( 'unknown/fake-model', 1000, 500 );
// Conservative fallback: (1000 * $10 + 500 * $30) / 1M.
$this->assertEqualsWithDelta( 0.025, $cost, 1e-12 );
}
/**
* Test a trailing digit run that is not an 8-digit date is never stripped.
*
* 'foo-202612345' has nine trailing digits — even with 'foo' and 'foo-2'
* registered, no stripping may occur and the conservative estimate applies.
*/
public function test_estimate_cost_non_date_suffix_is_not_stripped(): void {
Functions\when( 'get_transient' )->alias(
function ( string $key ) {
if ( 'prautoblogger_openrouter_models' === $key ) {
return [
[
'id' => 'foo',
'name' => 'Foo',
'context_length' => 8192,
'pricing' => [ 'prompt' => 0.01, 'completion' => 0.02 ],
],
[
'id' => 'foo-2',
'name' => 'Foo 2',
'context_length' => 8192,
'pricing' => [ 'prompt' => 0.01, 'completion' => 0.02 ],
],
];
}
return false;
}
);
$pricing = new \PRAutoBlogger_OpenRouter_Pricing();
$cost = $pricing->estimate_cost( 'foo-202612345', 1000, 500 );
// Nine trailing digits is not a snapshot date — conservative estimate applies.
$this->assertEqualsWithDelta( 0.025, $cost, 1e-12 );
}
}

View File

@@ -1,223 +0,0 @@
<?php
/**
* Retry-path integration tests for PRAutoBlogger_OpenRouter_Provider.
*
* Wires the full send_chat_completion() path (decryptable API key,
* scripted HTTP responses, wpdb mock for failure bookkeeping) to verify
* the v0.18.1 empty-completion guard end to end: the incident-shape
* single retry with reasoning disabled, and the reasoning token cap
* shaping the outgoing request. Split from OpenRouterProviderTest.php
* to keep both files under the 300-line rule. All HTTP calls are
* mocked — no real API calls.
*
* @package PRAutoBlogger\Tests\Providers
*/
namespace PRAutoBlogger\Tests\Providers;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class OpenRouterProviderRetryTest extends BaseTestCase {
protected function setUp(): void {
parent::setUp();
// Stub get_option — provider calls get_option('prautoblogger_openrouter_api_key', '')
// for API key retrieval via private get_api_key() method.
// Return empty string so decryption is skipped (no key = empty return).
// Also include prautoblogger_log_level for Logger singleton.
$this->stub_get_option( [
'prautoblogger_openrouter_api_key' => '',
'prautoblogger_log_level' => 'info',
] );
// Stub wp_salt — called by PRAutoBlogger_Encryption during API key decryption.
Functions\when( 'wp_salt' )->justReturn( 'test_salt_key_for_unit_tests' );
// Stub get_transient/set_transient — used by OpenRouter_Pricing (called internally).
Functions\when( 'get_transient' )->alias(
function ( string $key ) {
if ( 'prautoblogger_openrouter_models' === $key ) {
return [
[
'id' => 'model/test',
'name' => 'Test Model',
'context_length' => 4096,
'pricing' => [ 'prompt' => 1.00, 'completion' => 2.00 ],
],
];
}
return false;
}
);
Functions\when( 'set_transient' )->justReturn( true );
// Stub URL parsing.
Functions\when( 'wp_parse_url' )->alias( 'parse_url' );
// Stub HTTP functions for API calls.
Functions\when( 'wp_remote_post' )->justReturn( [
'body' => '{"choices":[{"message":{"content":"test"},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}',
'response' => [ 'code' => 200 ],
] );
Functions\when( 'wp_remote_get' )->justReturn( [
'body' => '{}',
'response' => [ 'code' => 200 ],
] );
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
Functions\when( 'wp_remote_retrieve_body' )->justReturn(
'{"choices":[{"message":{"content":"test"},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}'
);
Functions\when( 'is_wp_error' )->justReturn( false );
}
/**
* Wire the provider for a full send_chat_completion run: decryptable
* API key, reasoning settings, HTTP stubs that replay scripted
* responses, and a wpdb mock for the guard's failure bookkeeping.
*
* @param array $option_overrides Extra get_option values.
* @param array $responses Scripted JSON-decodable response bodies.
* @param array $requests (by ref) captures decoded request bodies.
*/
private function wire_full_provider( array $option_overrides, array $responses, array &$requests ): void {
\PRAutoBlogger_Run_Context::clear();
$encrypted_key = \PRAutoBlogger_Encryption::encrypt( 'sk-or-v1-unit-test-key-0000' );
$this->stub_get_option( array_merge( [
'prautoblogger_openrouter_api_key' => $encrypted_key,
'prautoblogger_log_level' => 'error',
], $option_overrides ) );
Functions\when( 'absint' )->alias( function ( $val ) {
return abs( (int) $val );
} );
Functions\when( 'wp_remote_post' )->alias(
function ( $url, $args ) use ( &$requests, $responses ) {
$requests[] = json_decode( $args['body'], true );
$body = $responses[ count( $requests ) - 1 ];
return [
'body' => $body,
'response' => [ 'code' => 200 ],
];
}
);
Functions\when( 'wp_remote_retrieve_response_code' )->alias(
function ( $response ) {
return $response['response']['code'];
}
);
Functions\when( 'wp_remote_retrieve_body' )->alias(
function ( $response ) {
return $response['body'];
}
);
// The guard books failed attempts through Cost_Tracker + Logger.
$wpdb = $this->create_mock_wpdb();
$wpdb->method( 'insert' )->willReturn( 1 );
$wpdb->insert_id = 11;
$GLOBALS['wpdb'] = $wpdb;
}
/**
* v0.18.1 incident shape, end to end: reasoning enabled (uncapped
* effort mode), the first response burns the whole completion budget
* on thinking and returns empty content with finish_reason=length —
* the provider retries exactly once with reasoning disabled and
* returns the recovered completion.
*/
public function test_empty_length_completion_retries_once_with_reasoning_disabled(): void {
$requests = [];
$empty_response = json_encode( [
'model' => 'deepseek/deepseek-v4-flash',
'choices' => [ [ 'message' => [ 'content' => '', 'reasoning' => 'endless thinking…' ], 'finish_reason' => 'length' ] ],
'usage' => [ 'prompt_tokens' => 1200, 'completion_tokens' => 4000, 'reasoning_tokens' => 4000, 'total_tokens' => 5200 ],
] );
$good_response = json_encode( [
'model' => 'deepseek/deepseek-v4-flash',
'choices' => [ [ 'message' => [ 'content' => 'Recovered article.' ], 'finish_reason' => 'stop' ] ],
'usage' => [ 'prompt_tokens' => 1200, 'completion_tokens' => 900, 'total_tokens' => 2100 ],
] );
$this->wire_full_provider(
[
'prautoblogger_reasoning_enabled' => '1',
'prautoblogger_reasoning_effort' => 'xhigh',
'prautoblogger_reasoning_max_tokens' => 0,
],
[ $empty_response, $good_response ],
$requests
);
try {
$provider = new \PRAutoBlogger_OpenRouter_Provider();
$result = $provider->send_chat_completion(
[ [ 'role' => 'user', 'content' => 'Write the article.' ] ],
'deepseek/deepseek-v4-flash',
[ 'temperature' => 0.7, 'max_tokens' => 4000, 'stage' => 'draft' ]
);
$this->assertSame( 'Recovered article.', $result['content'] );
$this->assertCount( 2, $requests );
// First attempt: global reasoning, uncapped effort mode.
$this->assertSame( [ 'enabled' => true, 'effort' => 'xhigh' ], $requests[0]['reasoning'] );
$this->assertSame( 4000, $requests[0]['max_tokens'] );
// Retry: reasoning disabled per call.
$this->assertSame( [ 'enabled' => false ], $requests[1]['reasoning'] );
// Caller metadata never reaches the HTTP body.
$this->assertArrayNotHasKey( 'stage', $requests[0] );
$this->assertArrayNotHasKey( 'empty_retry', $requests[1] );
} finally {
unset( $GLOBALS['wpdb'] );
\PRAutoBlogger_Run_Context::clear();
}
}
/**
* v0.18.1 reasoning budget: with the token cap active (default), the
* outgoing request caps thinking via reasoning.max_tokens and raises
* max_tokens by the cap — and a healthy completion needs no retry.
*/
public function test_reasoning_cap_shapes_the_outgoing_request(): void {
$requests = [];
$good_response = json_encode( [
'model' => 'deepseek/deepseek-v4-flash',
'choices' => [ [ 'message' => [ 'content' => 'Article text.' ], 'finish_reason' => 'stop' ] ],
'usage' => [ 'prompt_tokens' => 1200, 'completion_tokens' => 900, 'total_tokens' => 2100 ],
] );
$this->wire_full_provider(
[
'prautoblogger_reasoning_enabled' => '1',
'prautoblogger_reasoning_effort' => 'xhigh',
'prautoblogger_reasoning_max_tokens' => 2048,
],
[ $good_response ],
$requests
);
try {
$provider = new \PRAutoBlogger_OpenRouter_Provider();
$result = $provider->send_chat_completion(
[ [ 'role' => 'user', 'content' => 'Write the article.' ] ],
'deepseek/deepseek-v4-flash',
[ 'temperature' => 0.7, 'max_tokens' => 4000, 'stage' => 'draft' ]
);
$this->assertSame( 'Article text.', $result['content'] );
$this->assertCount( 1, $requests );
$this->assertSame( 2048, $requests[0]['reasoning']['max_tokens'] );
$this->assertArrayNotHasKey( 'effort', $requests[0]['reasoning'] );
$this->assertSame( 6048, $requests[0]['max_tokens'] );
} finally {
unset( $GLOBALS['wpdb'] );
\PRAutoBlogger_Run_Context::clear();
}
}
}

View File

@@ -1,189 +0,0 @@
<?php
/**
* Tests for PRAutoBlogger_OpenRouter_Request_Builder::build_body() (v0.18.1).
*
* Validates body assembly, the global-vs-per-call reasoning precedence, the
* reasoning token cap + completion headroom, and that caller-metadata keys
* never leak into the HTTP body.
*
* @package PRAutoBlogger\Tests\Providers
*/
namespace PRAutoBlogger\Tests\Providers;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class OpenRouterRequestBuilderTest extends BaseTestCase {
private const MESSAGES = [
[ 'role' => 'system', 'content' => 'sys' ],
[ 'role' => 'user', 'content' => 'usr' ],
];
protected function setUp(): void {
parent::setUp();
Functions\when( 'absint' )->alias( function ( $val ) {
return abs( (int) $val );
} );
}
/**
* Reasoning enabled globally + cap set: thinking is capped via
* reasoning.max_tokens, effort is dropped, and the completion budget
* is raised by the cap so content keeps its full allowance.
*/
public function test_reasoning_cap_replaces_effort_and_raises_headroom(): void {
$this->stub_get_option( [
'prautoblogger_reasoning_enabled' => '1',
'prautoblogger_reasoning_effort' => 'xhigh',
'prautoblogger_reasoning_max_tokens' => 2048,
] );
$body = ( new \PRAutoBlogger_OpenRouter_Request_Builder() )
->build_body( self::MESSAGES, 'deepseek/deepseek-v4-flash', [ 'max_tokens' => 4000 ] );
$this->assertSame( 2048, $body['reasoning']['max_tokens'] );
$this->assertArrayNotHasKey( 'effort', $body['reasoning'] );
$this->assertSame( 6048, $body['max_tokens'] );
$this->assertTrue( $body['reasoning']['enabled'] );
}
/**
* Cap setting 0 = pure effort mode: the historical body shape is
* preserved exactly (effort kept, no reasoning.max_tokens, no headroom).
*/
public function test_cap_zero_keeps_pure_effort_mode(): void {
$this->stub_get_option( [
'prautoblogger_reasoning_enabled' => '1',
'prautoblogger_reasoning_effort' => 'xhigh',
'prautoblogger_reasoning_max_tokens' => 0,
] );
$body = ( new \PRAutoBlogger_OpenRouter_Request_Builder() )
->build_body( self::MESSAGES, 'deepseek/deepseek-v4-flash', [ 'max_tokens' => 4000 ] );
$this->assertSame( [ 'enabled' => true, 'effort' => 'xhigh' ], $body['reasoning'] );
$this->assertSame( 4000, $body['max_tokens'] );
}
/**
* Per-call reasoning override (the guard's retry) beats the global
* setting; a disabled override gets no cap and no headroom.
*/
public function test_per_call_disabled_override_beats_global_setting(): void {
$this->stub_get_option( [
'prautoblogger_reasoning_enabled' => '1',
'prautoblogger_reasoning_effort' => 'xhigh',
'prautoblogger_reasoning_max_tokens' => 2048,
] );
$body = ( new \PRAutoBlogger_OpenRouter_Request_Builder() )
->build_body(
self::MESSAGES,
'deepseek/deepseek-v4-flash',
[
'max_tokens' => 4000,
'reasoning' => [ 'enabled' => false ],
]
);
$this->assertSame( [ 'enabled' => false ], $body['reasoning'] );
$this->assertSame( 4000, $body['max_tokens'] );
}
/**
* Global reasoning off + no override: no reasoning key at all and no
* budget math (historical default path).
*/
public function test_reasoning_off_produces_no_reasoning_key(): void {
$this->stub_get_option( [
'prautoblogger_reasoning_enabled' => '0',
] );
$body = ( new \PRAutoBlogger_OpenRouter_Request_Builder() )
->build_body(
self::MESSAGES,
'google/gemini-2.5-flash-lite',
[
'temperature' => 0.7,
'max_tokens' => 4000,
'response_format' => [ 'type' => 'json_object' ],
]
);
$this->assertArrayNotHasKey( 'reasoning', $body );
$this->assertSame( 0.7, $body['temperature'] );
$this->assertSame( 4000, $body['max_tokens'] );
$this->assertSame( [ 'type' => 'json_object' ], $body['response_format'] );
}
/**
* An explicit caller reasoning config with its own max_tokens is
* respected (no cap overwrite) but still gets completion headroom.
*/
public function test_caller_reasoning_max_tokens_respected_with_headroom(): void {
$this->stub_get_option( [
'prautoblogger_reasoning_enabled' => '0',
'prautoblogger_reasoning_max_tokens' => 2048,
] );
$body = ( new \PRAutoBlogger_OpenRouter_Request_Builder() )
->build_body(
self::MESSAGES,
'deepseek/deepseek-v4-flash',
[
'max_tokens' => 1000,
'reasoning' => [ 'enabled' => true, 'max_tokens' => 512 ],
]
);
$this->assertSame( 512, $body['reasoning']['max_tokens'] );
$this->assertSame( 1512, $body['max_tokens'] );
}
/**
* Caller-metadata keys (stage, prompt_key, empty_retry) must never
* appear in the HTTP body.
*/
public function test_caller_metadata_keys_never_leak_into_body(): void {
$this->stub_get_option( [
'prautoblogger_reasoning_enabled' => '0',
] );
$body = ( new \PRAutoBlogger_OpenRouter_Request_Builder() )
->build_body(
self::MESSAGES,
'google/gemini-2.5-flash-lite',
[
'max_tokens' => 4000,
'stage' => 'draft',
'prompt_key' => 'content.single_pass',
'empty_retry' => true,
]
);
$this->assertArrayNotHasKey( 'stage', $body );
$this->assertArrayNotHasKey( 'prompt_key', $body );
$this->assertArrayNotHasKey( 'empty_retry', $body );
$this->assertSame( [ 'model', 'messages', 'max_tokens' ], array_keys( $body ) );
}
/**
* Reasoning enabled but the call has no completion ceiling: nothing to
* protect, body passes through with effort untouched.
*/
public function test_no_max_tokens_means_no_budget_math(): void {
$this->stub_get_option( [
'prautoblogger_reasoning_enabled' => '1',
'prautoblogger_reasoning_effort' => 'high',
'prautoblogger_reasoning_max_tokens' => 2048,
] );
$body = ( new \PRAutoBlogger_OpenRouter_Request_Builder() )
->build_body( self::MESSAGES, 'deepseek/deepseek-v4-flash', [] );
$this->assertSame( [ 'enabled' => true, 'effort' => 'high' ], $body['reasoning'] );
$this->assertArrayNotHasKey( 'max_tokens', $body );
}
}

View File

@@ -2,9 +2,7 @@
/**
* Tests for PRAutoBlogger_Runware_Model_Catalog.
*
* Validates: normalize_models against real modelSearch fixture; cooldown behavior
* (failure → no retry within window → retry after); fallback served when cache empty;
* success path clears failure timestamp; cache version key v2 is used throughout.
* Validates live sync, fallback logic, cache staleness, and pricing merge.
*
* @package PRAutoBlogger\Tests\Providers
*/
@@ -13,267 +11,47 @@ namespace PRAutoBlogger\Tests\Providers;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
use Brain\Monkey\Actions;
class RunwareModelCatalogTest extends BaseTestCase {
/** Shared in-memory option store reset per test. */
private array $options = array();
protected function setUp(): void {
parent::setUp();
$this->options = array();
$this->stub_options();
// Mock WP options for cache simulation.
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
static $options = array();
return $options[ $key ] ?? $default;
} );
Functions\when( 'update_option' )->alias( function ( $key, $value ) {
static $options = array();
$options[ $key ] = $value;
return true;
} );
Functions\when( 'time' )->justReturn( 1704067200 ); // 2024-01-01 00:00:00
}
// ── Helpers ────────────────────────────────────────────────────────────────
private function stub_options(): void {
Functions\when( 'get_option' )->alias(
function ( $key, $default = false ) {
return $this->options[ $key ] ?? $default;
}
);
Functions\when( 'update_option' )->alias(
function ( $key, $value ) {
$this->options[ $key ] = $value;
return true;
}
);
Functions\when( 'delete_option' )->alias(
function ( $key ) {
unset( $this->options[ $key ] );
return true;
}
);
Functions\when( 'time' )->justReturn( 1704067200 ); // 2024-01-01 00:00:00 UTC
}
/**
* Return the fixture as a decoded page array matching fetch_page() output.
*
* @return array{results: array, totalResults: int}
*/
private function get_fixture_page(): array {
$fixture_path = __DIR__ . '/../../fixtures/runware-model-search-page1.json';
$json = file_get_contents( $fixture_path );
$decoded = json_decode( $json, true );
$item = $decoded['data'][0];
return array(
'results' => $item['results'],
'totalResults' => $item['totalResults'],
);
}
/**
* Build a fake HTTP response array for wp_remote_post stubs.
*
* @param int $status HTTP status code.
* @param string $body Response body JSON.
* @return array
*/
private function make_http_response( int $status, string $body ): array {
return array(
'response' => array( 'code' => $status ),
'body' => $body,
);
}
private function stub_http_helpers(): void {
Functions\when( 'wp_remote_retrieve_response_code' )->alias(
function ( $r ) { return (int) ( $r['response']['code'] ?? 0 ); }
);
Functions\when( 'wp_remote_retrieve_body' )->alias(
function ( $r ) { return (string) ( $r['body'] ?? '' ); }
);
Functions\when( 'is_wp_error' )->justReturn( false );
}
// ── normalize_models tests (via Fetcher) ──────────────────────────────────
/**
* normalize_models filters to io:text-to-image and maps air → id,
* comment → description. Non-t2i models are excluded.
*
* Uses the real modelSearch fixture (3 t2i + 1 text LLM + 1 upscaler).
* Expects only the 3 t2i models to pass through.
*/
public function test_normalize_models_filters_to_text_to_image(): void {
$fixture = $this->get_fixture_page();
$fetcher = new \PRAutoBlogger_Runware_Catalog_Fetcher();
$result = $fetcher->normalize_models( $fixture['results'] );
// Fixture has 3 t2i models (runware:100@1, runware:101@1, runware:400@2)
// and 2 non-t2i (text LLM + upscaler).
$this->assertCount( 3, $result, 'Should include exactly 3 io:text-to-image models' );
$ids = array_column( $result, 'id' );
$this->assertContains( 'runware:100@1', $ids );
$this->assertContains( 'runware:101@1', $ids );
$this->assertContains( 'runware:400@2', $ids );
$this->assertNotContains( 'some-text-model:llm@1', $ids, 'Text LLM must be excluded' );
$this->assertNotContains( 'upscale-model:upscaler@1', $ids, 'Upscaler must be excluded' );
}
/** normalize_models uses air as id, name as name, comment as description. */
public function test_normalize_models_maps_fields_correctly(): void {
$fixture = $this->get_fixture_page();
$fetcher = new \PRAutoBlogger_Runware_Catalog_Fetcher();
$result = $fetcher->normalize_models( $fixture['results'] );
$schnell = null;
foreach ( $result as $m ) {
if ( 'runware:100@1' === $m['id'] ) {
$schnell = $m;
break;
}
}
$this->assertNotNull( $schnell );
$this->assertSame( 'runware:100@1', $schnell['id'] );
$this->assertSame( 'FLUX.1 schnell', $schnell['name'] );
$this->assertSame( 'runware', $schnell['provider'] );
$this->assertNull( $schnell['cost_per_image'], 'Pricing is null before merge_pricing runs' );
$this->assertSame( array( 'image_generation' ), $schnell['capabilities'] );
$this->assertSame( 'Fast 4-step text-to-image model', $schnell['description'] );
}
/** normalize_models skips entries with empty air field. */
public function test_normalize_models_skips_empty_air(): void {
$fetcher = new \PRAutoBlogger_Runware_Catalog_Fetcher();
$raw = array(
array(
'air' => '',
'name' => 'No Air Model',
'capabilities' => array( 'io:text-to-image' ),
'comment' => '',
),
array(
'air' => 'runware:100@1',
'name' => 'Valid',
'capabilities' => array( 'io:text-to-image' ),
'comment' => 'OK',
),
);
$result = $fetcher->normalize_models( $raw );
$this->assertCount( 1, $result );
$this->assertSame( 'runware:100@1', $result[0]['id'] );
}
/** normalize_models returns empty array for empty input. */
public function test_normalize_models_empty_input(): void {
$fetcher = new \PRAutoBlogger_Runware_Catalog_Fetcher();
$this->assertSame( array(), $fetcher->normalize_models( array() ) );
}
// ── Failure cooldown tests ────────────────────────────────────────────────
/** No failure on record → is_in_failure_cooldown returns false. */
public function test_no_failure_cooldown_when_no_failure_recorded(): void {
/** Sync with empty or missing API key returns false. */
public function test_sync_no_api_key(): void {
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$this->assertFalse( $catalog->is_in_failure_cooldown() );
}
/** Failure recorded at t=now → cooldown active within 1h window. */
public function test_cooldown_active_within_window(): void {
$now = 1704067200;
$this->options['prautoblogger_runware_catalog_last_failure_at'] = $now;
// time() is stubbed to $now, so age = 0 < 3600 = cooldown.
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$this->assertTrue( $catalog->is_in_failure_cooldown() );
}
/** Failure recorded > 1h ago → cooldown expired, retry permitted. */
public function test_cooldown_expired_after_window(): void {
$now = 1704067200;
$past = $now - 3601; // More than 1h ago.
$this->options['prautoblogger_runware_catalog_last_failure_at'] = $past;
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$this->assertFalse( $catalog->is_in_failure_cooldown() );
}
/** Custom cooldown from settings is respected. */
public function test_cooldown_uses_setting_value(): void {
$now = 1704067200;
$this->options['prautoblogger_runware_catalog_last_failure_at'] = $now - 100;
// Default 3600s would make this expired (100 < 3600), but set custom = 60s.
// 100s > 60s so it should be expired.
$this->options['prautoblogger_runware_catalog_failure_cooldown_seconds'] = 60;
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$this->assertFalse( $catalog->is_in_failure_cooldown() );
}
/** Sync failure sets last_failure_at and does not clear success timestamp. */
public function test_sync_failure_records_failure_timestamp(): void {
// Mock HTTP 401 to force sync failure.
$this->options['prautoblogger_runware_api_key'] = 'test-api-key-plain';
$this->stub_http_helpers();
Functions\when( 'wp_remote_post' )->justReturn(
$this->make_http_response( 401, '{"errors":[{"message":"Unauthorized"}]}' )
);
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$result = $catalog->sync();
$result = $catalog->sync();
$this->assertFalse( $result );
$this->assertNotNull( $catalog->get_last_failure_at(), 'Failure timestamp should be recorded' );
}
/** After failure, get_models suppresses sync within cooldown window. */
public function test_get_models_suppresses_sync_during_cooldown(): void {
$now = 1704067200;
$this->options['prautoblogger_runware_api_key'] = 'test-api-key-plain';
$this->options['prautoblogger_runware_catalog_last_failure_at'] = $now; // just failed.
$this->options['prautoblogger_runware_model_cache_updated_at'] = $now - 90000; // stale cache.
$this->stub_http_helpers();
// wp_remote_post should NOT be called during cooldown.
Functions\expect( 'wp_remote_post' )->never();
/** get_models returns fallback if no cache and sync fails. */
public function test_get_models_fallback_on_sync_failure(): void {
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$models = $catalog->get_models();
// Should fall back to hardcoded fallback list.
$this->assertNotEmpty( $models );
$ids = array_column( $models, 'id' );
$this->assertContains( 'runware:100@1', $ids, 'Fallback schnell model should be present' );
}
/** Successful sync clears failure timestamp. */
public function test_successful_sync_clears_failure_timestamp(): void {
$now = 1704067200;
$this->options['prautoblogger_runware_api_key'] = 'test-api-key-plain';
$this->options['prautoblogger_runware_catalog_last_failure_at'] = $now - 7200; // old failure.
$fixture_body = file_get_contents( __DIR__ . '/../../fixtures/runware-model-search-page1.json' );
$this->stub_http_helpers();
Functions\when( 'wp_remote_post' )->justReturn(
$this->make_http_response( 200, $fixture_body )
);
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$result = $catalog->sync();
$this->assertTrue( $result, 'Sync should succeed with valid fixture response' );
$this->assertNull( $catalog->get_last_failure_at(), 'Success should clear failure timestamp' );
}
// ── Fallback list tests ───────────────────────────────────────────────────
/** get_models returns fallback when no cache and sync fails. */
public function test_get_models_returns_fallback_when_no_cache(): void {
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$models = $catalog->get_models();
$models = $catalog->get_models();
// Should have the fallback list (15 Runware models).
$this->assertIsArray( $models );
$this->assertNotEmpty( $models );
$this->assertGreaterThan( 0, count( $models ) );
// Check at least one expected fallback model exists.
$schnell_exists = false;
foreach ( $models as $model ) {
if ( 'runware:100@1' === ( $model['id'] ?? '' ) ) {
$schnell_exists = true;
$this->assertSame( 'FLUX.1 schnell (Runware)', $model['name'] );
$this->assertSame( 'runware', $model['provider'] );
break;
}
@@ -281,12 +59,28 @@ class RunwareModelCatalogTest extends BaseTestCase {
$this->assertTrue( $schnell_exists, 'Fallback should include FLUX.1 schnell' );
}
/** Fallback list is never empty (even with no key configured). */
/** is_stale returns true if never synced. */
public function test_is_stale_never_synced(): void {
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$this->assertTrue( $catalog->is_stale() );
}
/** get_last_synced_at returns null if never synced. */
public function test_get_last_synced_at_never(): void {
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$this->assertNull( $catalog->get_last_synced_at() );
}
/** Fallback list is never empty. */
public function test_get_models_never_empty(): void {
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$models = $catalog->get_models();
// Even with no cache and failed sync, get_models should return the fallback.
$models = $catalog->get_models();
$this->assertIsArray( $models );
$this->assertNotEmpty( $models );
// All models should have required fields.
foreach ( $models as $model ) {
$this->assertArrayHasKey( 'id', $model );
$this->assertArrayHasKey( 'name', $model );
@@ -295,24 +89,13 @@ class RunwareModelCatalogTest extends BaseTestCase {
}
}
/** All fallback models carry the image_generation capability. */
public function test_fallback_models_have_image_generation_capability(): void {
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$models = $catalog->get_models();
foreach ( $models as $model ) {
$this->assertIsArray( $model['capabilities'] );
$this->assertContains( 'image_generation', $model['capabilities'] );
}
}
/** Fallback schnell and dev have correct pricing. */
/** Fallback models have correct pricing. */
public function test_fallback_models_have_pricing(): void {
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$models = $catalog->get_models();
$models = $catalog->get_models();
$schnell = null;
$dev = null;
$dev = null;
foreach ( $models as $model ) {
if ( 'runware:100@1' === $model['id'] ) {
$schnell = $model;
@@ -324,98 +107,83 @@ class RunwareModelCatalogTest extends BaseTestCase {
$this->assertNotNull( $schnell );
$this->assertEqualsWithDelta( 0.0006, $schnell['cost_per_image'], 0.000001 );
$this->assertNotNull( $dev );
$this->assertEqualsWithDelta( 0.02, $dev['cost_per_image'], 0.000001 );
}
// ── Happy path (sync → cache → get_models) ───────────────────────────────
/** Fallback models all have 'image_generation' capability. */
public function test_fallback_models_have_capabilities(): void {
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$models = $catalog->get_models();
/** Successful sync caches models; get_models returns them from cache. */
foreach ( $models as $model ) {
$this->assertIsArray( $model['capabilities'] );
$this->assertContains( 'image_generation', $model['capabilities'] );
}
}
/** Happy path: successful sync caches models and get_models returns them. */
public function test_sync_success_caches_and_returns_models(): void {
$fixture_body = file_get_contents( __DIR__ . '/../../fixtures/runware-model-search-page1.json' );
$this->options['prautoblogger_runware_api_key'] = 'test-api-key-plain';
$this->stub_http_helpers();
Functions\when( 'wp_remote_post' )->justReturn(
$this->make_http_response( 200, $fixture_body )
// The mocked API response body — models must have taskType=imageInference
// so normalize_models() doesn't filter them out.
$fake_body = json_encode(
array(
'data' => array(
array(
'id' => 'test-model-1',
'name' => 'Test Model 1',
'taskType' => 'imageInference',
),
array(
'id' => 'test-model-2',
'name' => 'Test Model 2',
'taskType' => 'imageInference',
),
),
)
);
$fake_response = array(
'response' => array( 'code' => 200 ),
'body' => $fake_body,
);
Functions\when( 'wp_remote_post' )->justReturn( $fake_response );
// Alias the WP response-parsing helpers so they work against our array.
Functions\when( 'wp_remote_retrieve_response_code' )->alias(
function ( $r ) { return (int) ( $r['response']['code'] ?? 0 ); }
);
Functions\when( 'wp_remote_retrieve_body' )->alias(
function ( $r ) { return (string) ( $r['body'] ?? '' ); }
);
Functions\when( 'is_wp_error' )->justReturn( false );
// Shared options store so get_option/update_option stay in sync
// within this test. The API key must be non-empty for sync() to proceed.
$store = array( 'prautoblogger_runware_api_key' => 'test-api-key-plain' );
Functions\when( 'get_option' )->alias(
function ( $key, $default = false ) use ( &$store ) {
return $store[ $key ] ?? $default;
}
);
Functions\when( 'update_option' )->alias(
function ( $key, $value ) use ( &$store ) {
$store[ $key ] = $value;
return true;
}
);
// Sync should succeed: API key present, HTTP 200, 2 imageInference models.
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$result = $catalog->sync();
$this->assertTrue( $result, 'Sync should return true on HTTP 200 with valid fixture' );
$result = $catalog->sync();
$this->assertTrue( $result, 'Sync should return true on HTTP 200 with valid models' );
// get_models() should now return the synced list from cache.
$models = $catalog->get_models();
$this->assertIsArray( $models );
// Fixture has 3 t2i models (schnell, dev, klein 9B).
$this->assertCount( 3, $models, 'get_models should return the 3 cached t2i models' );
$ids = array_column( $models, 'id' );
$this->assertContains( 'runware:100@1', $ids );
$this->assertContains( 'runware:101@1', $ids );
$this->assertContains( 'runware:400@2', $ids );
}
/** Synced models have pricing merged from the pricing class. */
public function test_synced_models_have_pricing_merged(): void {
$fixture_body = file_get_contents( __DIR__ . '/../../fixtures/runware-model-search-page1.json' );
$this->options['prautoblogger_runware_api_key'] = 'test-api-key-plain';
$this->stub_http_helpers();
Functions\when( 'wp_remote_post' )->justReturn(
$this->make_http_response( 200, $fixture_body )
);
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$catalog->sync();
$models = $catalog->get_models();
$schnell = null;
foreach ( $models as $m ) {
if ( 'runware:100@1' === $m['id'] ) {
$schnell = $m;
}
}
$this->assertNotNull( $schnell );
$this->assertEqualsWithDelta( 0.0006, (float) $schnell['cost_per_image'], 0.000001 );
}
// ── Stale / absent cache tests ────────────────────────────────────────────
/** is_stale returns true if never synced. */
public function test_is_stale_never_synced(): void {
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$this->assertTrue( $catalog->is_stale() );
}
/** is_stale returns false for a fresh cache (synced < 24h ago). */
public function test_is_stale_fresh_cache(): void {
$now = 1704067200;
$this->options['prautoblogger_runware_model_cache_updated_at'] = $now - 3600; // 1h ago.
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$this->assertFalse( $catalog->is_stale() );
}
/** is_stale returns true when cache is older than 24h. */
public function test_is_stale_old_cache(): void {
$now = 1704067200;
$this->options['prautoblogger_runware_model_cache_updated_at'] = $now - 90000; // > 24h ago.
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$this->assertTrue( $catalog->is_stale() );
}
/** get_last_synced_at returns null if never synced. */
public function test_get_last_synced_at_never(): void {
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$this->assertNull( $catalog->get_last_synced_at() );
}
/** Sync with no API key returns false without recording a failure. */
public function test_sync_no_api_key(): void {
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$result = $catalog->sync();
$this->assertFalse( $result );
// No key → not a transient failure, no cooldown recorded.
$this->assertNull( $catalog->get_last_failure_at() );
$this->assertCount( 2, $models );
$this->assertSame( 'test-model-1', $models[0]['id'] );
$this->assertSame( 'test-model-2', $models[1]['id'] );
}
}

17
uninstall.php Normal file → Executable file
View File

@@ -31,12 +31,6 @@ $tables = array(
$prefix . 'generation_log',
$prefix . 'content_scores',
$prefix . 'event_log',
// Pipeline v2 substrate (v0.18.0 / db 1.2.0).
$prefix . 'prompts',
$prefix . 'run_sources',
$prefix . 'run_decisions',
$prefix . 'runs',
$prefix . 'run_stages',
);
foreach ( $tables as $table ) {
@@ -87,14 +81,11 @@ $wpdb->query(
$hooks = array(
'prautoblogger_daily_generation',
'prautoblogger_collect_metrics',
'prautoblogger_reap_orphan_research_rows',
'prautoblogger_sync_runware_models',
'prautoblogger_manual_generation',
'prautoblogger_generate_queued_article',
'prautoblogger_generate_from_idea',
'prautoblogger_opik_dispatch',
);
foreach ( $hooks as $hook ) {
wp_clear_scheduled_hook( $hook );
$timestamp = wp_next_scheduled( $hook );
if ( false !== $timestamp ) {
wp_unschedule_event( $timestamp, $hook );
}
}