Compare commits
15 Commits
claude/ima
...
claude/pra
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da1ccf394e | ||
|
|
5a62347bd1 | ||
|
|
1e77aff7c9 | ||
|
|
2bdc763dc0 | ||
|
|
f5658f1c97 | ||
|
|
c5e2a73e42 | ||
|
|
50da840553 | ||
|
|
c6d051c87a | ||
|
|
c723af927b | ||
|
|
1704d7f3a6 | ||
|
|
6de8cf2031 | ||
|
|
d559fc58ab | ||
|
|
a18aa9f316 | ||
|
|
d00e746727 | ||
|
|
a4f3b1832e |
17
.github/workflows/cto-review.yml
vendored
17
.github/workflows/cto-review.yml
vendored
@@ -123,17 +123,18 @@ jobs:
|
||||
git fetch origin ${{ github.event.pull_request.base.ref }} --depth=50
|
||||
BASE_SHA="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
|
||||
DIFF=$(git diff --no-color "$BASE_SHA" "$HEAD_SHA" | head -c 400000)
|
||||
# Write the diff to a file, not a step output / env var. Passing a
|
||||
# ~400KB diff through the process env (execve argv+envp) exceeds the
|
||||
# runner ARG_MAX and fails the next step with "Argument list too long"
|
||||
# (issue #110). File transport also sidesteps the ~1MB step-output cap.
|
||||
git diff --no-color "$BASE_SHA" "$HEAD_SHA" | head -c 400000 > /tmp/cto-review-diff.txt
|
||||
FILES=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")
|
||||
SIZE=$(echo -n "$DIFF" | wc -c)
|
||||
SIZE=$(wc -c < /tmp/cto-review-diff.txt)
|
||||
{
|
||||
echo "size=$SIZE"
|
||||
echo "files<<EOF_FILES"
|
||||
echo "$FILES"
|
||||
echo "EOF_FILES"
|
||||
echo "diff<<EOF_DIFF"
|
||||
echo "$DIFF"
|
||||
echo "EOF_DIFF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Load repo conventions
|
||||
@@ -161,7 +162,7 @@ jobs:
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO_NAME: ${{ github.event.repository.name }}
|
||||
DIFF: ${{ steps.diff.outputs.diff }}
|
||||
DIFF_PATH: /tmp/cto-review-diff.txt
|
||||
DIFF_SIZE: ${{ steps.diff.outputs.size }}
|
||||
CHANGED_FILES: ${{ steps.diff.outputs.files }}
|
||||
CONVENTIONS: ${{ steps.conventions.outputs.content }}
|
||||
@@ -229,7 +230,9 @@ jobs:
|
||||
user_blocks.append(f"**Changed files:**\n```\n{os.environ['CHANGED_FILES']}\n```\n")
|
||||
if os.environ.get("CONVENTIONS", "").strip():
|
||||
user_blocks.append(f"# Repo CONVENTIONS.md\n{os.environ['CONVENTIONS']}\n")
|
||||
user_blocks.append(f"# Diff\n```diff\n{os.environ['DIFF']}\n```\n")
|
||||
with open(os.environ["DIFF_PATH"], encoding="utf-8", errors="replace") as _df:
|
||||
_diff_text = _df.read()
|
||||
user_blocks.append(f"# Diff\n```diff\n{_diff_text}\n```\n")
|
||||
user_message = "\n\n".join(user_blocks)
|
||||
|
||||
tools = [{
|
||||
|
||||
94
.github/workflows/deploy.yml
vendored
94
.github/workflows/deploy.yml
vendored
@@ -28,92 +28,10 @@ jobs:
|
||||
-not -path './.github/*' \
|
||||
-print0 | xargs -0 -n1 -P4 php -l > /dev/null
|
||||
|
||||
deploy-staging:
|
||||
name: Deploy to Staging
|
||||
deploy:
|
||||
name: Deploy (shared pipeline)
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
environment: staging
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
ssh-keyscan -p ${{ secrets.SSH_PORT }} ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
- name: Deploy via rsync → staging
|
||||
run: |
|
||||
rsync -avz --delete \
|
||||
--exclude '.git' --exclude '.github' \
|
||||
--exclude 'tests' --exclude 'phpunit.xml.dist' \
|
||||
--exclude 'composer.json' --exclude 'composer.lock' \
|
||||
--exclude 'vendor' --exclude '.distignore' \
|
||||
--exclude 'ARCHITECTURE.md' --exclude 'CONVENTIONS.md' \
|
||||
-e "ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }}" \
|
||||
./ ${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_HOST }}:~/domains/peptiderepo.com/public_html/staging/wp-content/plugins/prautoblogger/
|
||||
- name: Staging health check
|
||||
run: |
|
||||
sleep 5
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://staging.peptiderepo.com/ || echo "000")
|
||||
echo "Staging responded: HTTP $STATUS"
|
||||
[ "$STATUS" = "000" ] && echo "Warning: staging DNS may still be propagating" || true
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/deploy_key
|
||||
|
||||
smoke:
|
||||
name: Smoke tests (staging)
|
||||
needs: deploy-staging
|
||||
continue-on-error: true
|
||||
uses: peptiderepo/peptide-e2e/.github/workflows/smoke.yml@main
|
||||
secrets:
|
||||
STAGING_WP_ADMIN_USER: ${{ secrets.STAGING_WP_ADMIN_USER }}
|
||||
STAGING_WP_ADMIN_PASSWORD: ${{ secrets.STAGING_WP_ADMIN_PASSWORD }}
|
||||
|
||||
deploy-production:
|
||||
name: Deploy to Production
|
||||
needs: [validate, deploy-staging]
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
ssh-keyscan -p ${{ secrets.SSH_PORT }} ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
- name: Deploy via rsync → production
|
||||
run: |
|
||||
rsync -avz --delete \
|
||||
--exclude '.git' --exclude '.github' \
|
||||
--exclude 'tests' --exclude 'phpunit.xml.dist' \
|
||||
--exclude 'composer.json' --exclude 'composer.lock' \
|
||||
--exclude 'vendor' --exclude '.distignore' \
|
||||
--exclude 'ARCHITECTURE.md' --exclude 'CONVENTIONS.md' \
|
||||
-e "ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }}" \
|
||||
./ ${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_HOST }}:~/domains/peptiderepo.com/public_html/wp-content/plugins/prautoblogger/
|
||||
- name: Purge LiteSpeed Cache
|
||||
run: |
|
||||
ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
|
||||
${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_HOST }} \
|
||||
"cd ~/domains/peptiderepo.com/public_html && \
|
||||
wp litespeed-purge all 2>/dev/null || \
|
||||
(find wp-content/litespeed/ -type f -delete 2>/dev/null; touch .lscache_purge 2>/dev/null) || true"
|
||||
- name: Production health check
|
||||
run: |
|
||||
sleep 5
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://peptiderepo.com)
|
||||
if [ "$STATUS" != "200" ] && [ "$STATUS" != "403" ]; then
|
||||
echo "ERROR: Site returned unexpected HTTP $STATUS after deploy"
|
||||
exit 1
|
||||
fi
|
||||
echo "Production healthy (HTTP $STATUS)"
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/deploy_key
|
||||
- name: Deploy summary
|
||||
run: |
|
||||
echo "## Deployment Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Commit:** \`${GITHUB_SHA::7}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Deployed to:** peptiderepo.com" >> $GITHUB_STEP_SUMMARY
|
||||
uses: peptiderepo/peptide-e2e/.github/workflows/deploy-app.yml@main
|
||||
with:
|
||||
target_path: wp-content/plugins/prautoblogger
|
||||
secrets: inherit
|
||||
|
||||
22
.github/workflows/main-push-audit.yml
vendored
22
.github/workflows/main-push-audit.yml
vendored
@@ -29,12 +29,22 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const sha = context.sha;
|
||||
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: sha,
|
||||
});
|
||||
const merged = prs.find(p => p.merged_at);
|
||||
// Squash-merge -> PR association is eventually-consistent; poll briefly
|
||||
// before concluding this was a direct push (avoids false positives on merges).
|
||||
async function findMergedPR() {
|
||||
for (let attempt = 1; attempt <= 5; attempt++) {
|
||||
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: sha,
|
||||
});
|
||||
const m = prs.find(p => p.merged_at);
|
||||
if (m) return m;
|
||||
if (attempt < 5) await new Promise(r => setTimeout(r, 10000));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const merged = await findMergedPR();
|
||||
if (merged) {
|
||||
core.info(`OK: commit ${sha.substring(0,7)} came from PR #${merged.number}`);
|
||||
return;
|
||||
|
||||
238
ARCHITECTURE.md
Executable file → Normal file
238
ARCHITECTURE.md
Executable file → Normal file
@@ -75,6 +75,11 @@ PRAutoBlogger is a WordPress plugin that monitors social media (starting with Re
|
||||
│ │ retried once with a rule-based fallback
|
||||
│ │ prompt via Image_NSFW_Retry; a second block
|
||||
│ │ logs WARNING and publishes without that image.
|
||||
│ │ v0.17.0: results then pass through the local
|
||||
│ │ deterministic Image_Composer (corner mark +
|
||||
│ │ branded OG/square variants for Image A; mark
|
||||
│ │ only for Image B) with a capability ladder
|
||||
│ │ (imagick → GD resize-only → pass-through).
|
||||
│ │ Sideloads to media library, logs costs.
|
||||
└────────────┬────────────┘
|
||||
│
|
||||
@@ -103,6 +108,15 @@ prautoblogger/
|
||||
├── CHANGELOG.md # Semantic versioning changelog
|
||||
│
|
||||
├── assets/
|
||||
│ ├── brand/ # Vendored brand PNGs rasterized from peptide-repo-brand SVGs (composer overlays)
|
||||
│ │ ├── logo-mark-small-56.png # Corner mark, 1x
|
||||
│ │ ├── logo-mark-small-112.png # Corner mark / OG band logo, 2x
|
||||
│ │ ├── logo-horizontal-reverse-128.png # Square footer lockup, 1x
|
||||
│ │ └── logo-horizontal-reverse-256.png # Square footer lockup, 2x
|
||||
│ ├── fonts/ # Bundled OFL faces for deterministic text rendering
|
||||
│ │ ├── Poppins-Bold.ttf # Square-card caption face
|
||||
│ │ ├── Poppins-SemiBold.ttf # OG band caption face
|
||||
│ │ └── OFL.txt # SIL Open Font License
|
||||
│ ├── css/
|
||||
│ │ ├── admin.css # Admin page styles (wp-admin conventions)
|
||||
│ │ └── posts-widget.css # Frontend posts widget styles (uses theme CSS vars)
|
||||
@@ -112,6 +126,8 @@ prautoblogger/
|
||||
│
|
||||
├── includes/
|
||||
│ ├── class-prautoblogger.php # Main orchestrator — registers all hooks, delegates execution
|
||||
│ ├── class-pipeline-schema-installer.php # v0.18.0 substrate tables (prompts, run_sources, run_decisions, runs, run_stages)
|
||||
│ ├── class-migrate-prompt-seed-v0180.php # One-shot v0.18.0 prompt-registry seed migration
|
||||
│ ├── class-executor.php # Cron handlers, generation AJAX (start + status), model registry
|
||||
│ ├── class-ajax-handlers.php # Non-generation AJAX: images, models, test connections
|
||||
│ ├── class-generation-lock.php # DB-level atomic mutex for single-writer generation
|
||||
@@ -148,8 +164,28 @@ prautoblogger/
|
||||
│ │ ├── class-image-pipeline.php # Orchestrates A/B image generation (parallel via batch)
|
||||
│ │ ├── class-image-prompt-builder.php # Generates visual prompts from article/source data
|
||||
│ │ ├── class-image-template-filler.php # Fills the editorial style template's {{ topic_summary }} slot (v0.16.0)
|
||||
│ │ ├── interface-image-composer.php # Contract: provider bytes → featured-first variant list (v0.17.0)
|
||||
│ │ ├── class-image-composer.php # Composer orchestrator: cached capability probe + degradation ladder
|
||||
│ │ ├── class-image-composer-imagick.php # Imagick renderer: corner mark, OG band, square card (WP-free)
|
||||
│ │ ├── class-image-composer-canvas.php # Imagick primitives: smoke test, cover-crop, logo, deterministic PNG (WP-free)
|
||||
│ │ ├── class-image-composer-editor.php # Resize-only rung via wp_get_image_editor (GD path)
|
||||
│ │ ├── class-image-composer-layout.php # Geometry defaults + caption clamp (pure, WP-free)
|
||||
│ │ ├── 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
|
||||
@@ -159,6 +195,7 @@ prautoblogger/
|
||||
│ ├── providers/
|
||||
│ │ ├── interface-llm-provider.php # Contract for any LLM provider
|
||||
│ │ ├── class-open-router-provider.php # OpenRouter API implementation (chat completions)
|
||||
│ │ ├── class-open-router-completion-guard.php # Empty-completion guard: warn on finish_reason=length, one reasoning-off retry, audited failure (v0.18.1)
|
||||
│ │ ├── class-open-router-embedding-provider.php # Text embeddings for semantic dedup
|
||||
│ │ ├── class-open-router-pricing.php # Model pricing lookup and cost estimation
|
||||
│ │ ├── interface-source-provider.php # Contract for any social media source
|
||||
@@ -171,7 +208,7 @@ prautoblogger/
|
||||
│ │ ├── class-open-router-image-support.php # API key, response parsing, retry/backoff helpers
|
||||
│ │ ├── class-open-router-image-pricing.php # Model resolution + per-image cost estimation
|
||||
│ │ ├── class-open-router-config.php # API base URL (direct vs AI Gateway)
|
||||
│ │ ├── class-open-router-request-builder.php # Header building + Hostinger cURL auth workaround
|
||||
│ │ ├── class-open-router-request-builder.php # Request body assembly + reasoning token cap/headroom (v0.18.1) + headers + Hostinger cURL auth workaround
|
||||
│ │ ├── class-runware-image-provider.php # Runware FLUX.1 (default v0.9.0+)
|
||||
│ │ ├── class-runware-image-pricing.php # FLUX schnell/dev cost table + resolver
|
||||
│ │ ├── class-runware-image-support.php # Key, response parsing, retry, dimension snap
|
||||
@@ -358,7 +395,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) | 'analysis', 'outline', 'draft', 'edit', 'review' |
|
||||
| stage | VARCHAR(50) | See "Stage vocabulary" below. Canonical edit-stage name is **'polish'** (this doc previously said 'edit'; the code always wrote 'polish') |
|
||||
| provider | VARCHAR(50) | 'openrouter' |
|
||||
| model | VARCHAR(100) | Model identifier used |
|
||||
| prompt_tokens | INT | Input tokens consumed |
|
||||
@@ -367,6 +404,8 @@ 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`
|
||||
@@ -374,6 +413,18 @@ 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 |
|
||||
@@ -406,6 +457,97 @@ All tables use `$wpdb->prefix` + `prautoblogger_` prefix.
|
||||
- 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 for Phase-2 quorum ('' default) |
|
||||
| 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_`:
|
||||
@@ -420,11 +562,14 @@ 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) |
|
||||
@@ -448,6 +593,10 @@ All prefixed with `prautoblogger_`:
|
||||
| `prautoblogger_image_style_suffix` | DEPRECATED (v0.16.0). Former comic style suffix appended to every image prompt; no longer read by the builder. Mirrored to `prautoblogger_image_style_suffix_deprecated` for one cycle and not deleted yet |
|
||||
| `prautoblogger_openrouter_model_registry` | Normalized OpenRouter model list (JSON array, daily refresh, serves as durable cache) |
|
||||
| `prautoblogger_openrouter_model_registry_fetched_at` | Unix timestamp of last successful model registry refresh |
|
||||
| `prautoblogger_image_compose_enabled` | Toggle: run the deterministic image composer (default '1'; auto-degrades, never blocks publishing) |
|
||||
| `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 |
|
||||
|
||||
### Post Meta
|
||||
|
||||
@@ -466,6 +615,15 @@ 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) |
|
||||
|
||||
Composed-variant **attachment** meta (v0.17.0): every pipeline attachment gets
|
||||
`_prautoblogger_image_role` (`featured`/`og`/`square`); OG/square variants also
|
||||
get `_prautoblogger_base_attachment_id` pointing at the featured attachment
|
||||
they derive from. All keys keep the `_prautoblogger_` prefix, so the uninstall
|
||||
prefix-sweep purges them.
|
||||
|
||||
---
|
||||
|
||||
@@ -587,6 +745,42 @@ Commit 1 of the in-plugin editorial image pipeline (brief `docs/proposals/2026-0
|
||||
|
||||
Provider/model are unchanged — only the prompt text changes, so production keeps emitting via Runware FLUX.1 schnell but now produces text-free editorial base images.
|
||||
|
||||
### #21: Deterministic PHP image composer with capability ladder (v0.17.0, Jun 2026)
|
||||
|
||||
Commit 2 (and final scope) of the in-plugin editorial image pipeline (#20). Small diffusion
|
||||
models cannot render clean text (2026-04-21 A/B finding), so base images are text-free —
|
||||
but the one image that travels *without* its HTML caption is the social/OG share image.
|
||||
A **deterministic local composer** (`PRAutoBlogger_Image_Composer` behind
|
||||
`PRAutoBlogger_Image_Composer_Interface`) now runs between provider bytes and sideload:
|
||||
|
||||
1. **Variants.** Image A → corner-marked featured (1200×632) + branded OG (1200×630, teal
|
||||
band, brand mark, baked caption in Poppins SemiBold) + square card (1080×1080: full base
|
||||
downscaled into a 1080×569 top slice — no upscaling — cream caption panel in Poppins
|
||||
Bold, teal footer with the horizontal reverse lockup). Image B → corner mark only. The
|
||||
variant set is config-driven (`prautoblogger_image_compose_variants`, default `og,square`;
|
||||
square has no consumer yet — CEO wants it rendered + stored now). Variants are
|
||||
meta-linked via `_prautoblogger_image_role`, `_prautoblogger_base_attachment_id`,
|
||||
`_prautoblogger_og_image_id`, `_prautoblogger_square_image_id`. The og key is **frozen**:
|
||||
the rebuilt SEO stage (pipeline-redesign §4.8) emits `og:image` from it.
|
||||
2. **Capability ladder, never fatal.** Imagick is probed once (PNG format + TTF annotate
|
||||
smoke test) and cached in `prautoblogger_image_compose_capability`, fingerprinted by
|
||||
PHP version + imagick/gd presence so host flips (hPanel/cagefs — it has happened)
|
||||
auto-invalidate the cache. Rungs: imagick → `wp_get_image_editor()` resize-only
|
||||
(unbranded, square skipped because it would upscale) → pass-through. Every render is
|
||||
try/caught; degradation logs one WARNING per run; the publish always proceeds with the
|
||||
editable HTML caption intact. Test/ops override: `prautoblogger_image_compose_capability`
|
||||
filter.
|
||||
3. **Determinism.** Bundled OFL fonts (no system-font queries), vendored brand PNGs (no
|
||||
runtime SVG delegate variance), `stripImage()` + PNG date/time chunks excluded + fixed
|
||||
compression → byte-stable output **per environment** (not across ImageMagick versions).
|
||||
Composition is local CPU, $0, logged as a duration-only stage on the `image_composer`
|
||||
channel — no cost-tracker rows.
|
||||
4. **Layout tuning.** Geometry/opacity are constants in
|
||||
`PRAutoBlogger_Image_Composer_Layout::defaults()` behind the
|
||||
`prautoblogger_image_compose_layout` filter — deliberately not settings; promote only if
|
||||
the CEO asks to tune them. The caption is hard-clamped (word-boundary wrap + ellipsis,
|
||||
multibyte-safe) even though the rewriter LLM is already constrained to <15 words.
|
||||
|
||||
### #17: Runware as default image backend (v0.9.0, Apr 2026)
|
||||
|
||||
After a comic-style A/B round in Apr 2026, FLUX.1 schnell via Runware was chosen as the default image backend. Cost is ~$0.0006/image vs ~$0.039/image for Gemini 2.5 Flash Image via OpenRouter (≈65× cheaper), and the looser schnell fidelity reads as editorial-cartoon style — a feature, not a bug, for our single-panel comic aesthetic. FLUX.1 dev (~$0.02/image, 28 steps) remains available as an opt-in for higher-fidelity runs. The Runware layer mirrors the OpenRouter split (interface + provider + support + pricing + batch), all under the 300-line cap. The batch class dispatches `imageInference` POSTs via `curl_multi_init/exec/select` — wall-clock time for the A/B pair drops to ≈ the slowest single image. A v0.9.0 one-time migration (`prautoblogger_migrated_default_image_v090`) flips sites still on the legacy default; explicit user selections are preserved. Cloudflare Workers AI was removed as an image provider in v0.10.0 (see ADR #16 note below).
|
||||
@@ -600,6 +794,46 @@ 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 itself is Phase 2 — the schema just
|
||||
reserves its dimensions.
|
||||
|
||||
### #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.
|
||||
|
||||
197
CHANGELOG.md
197
CHANGELOG.md
@@ -5,6 +5,203 @@ All notable changes to PRAutoBlogger will be documented in this file.
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project uses [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [0.18.1] - 2026-06-11
|
||||
|
||||
Empty-draft hotfix (CTO diagnosis: convo thread `2026-06-pipeline-v2-phase1` seq 12; prod run
|
||||
`acf24029`, draft post 921). A true reasoning model at a high reasoning effort could spend the
|
||||
entire `max_tokens` completion budget on thinking, return zero visible content with
|
||||
`finish_reason=length`, and the empty article flowed writer → editor (reject: "draft text is
|
||||
missing") → empty draft post in the Review Queue, with the cost booked as `success`.
|
||||
|
||||
### Fixed
|
||||
- **Empty-completion guard at the call seam** (`PRAutoBlogger_OpenRouter_Completion_Guard`):
|
||||
a chat completion whose visible content is empty/whitespace is now a **failure**, never a
|
||||
success. When the empty result co-occurs with `finish_reason=length` or an active reasoning
|
||||
request, the call is retried **once** with reasoning disabled (per-call `reasoning`
|
||||
override) before the stage fails. Failed/retried attempts land in `generation_log` with
|
||||
`response_status='error'`, the failure note, and the real token burn; inside a v0.18.0 run a
|
||||
`run_decisions` row records the verdict (`retry_reasoning_disabled` /
|
||||
`failed_empty_completion`).
|
||||
- **Writer-path guard**: a writing stage (outline/draft/polish/single-pass) whose output is
|
||||
empty throws instead of passing an empty article to the editor (belt over the seam guard,
|
||||
covers any future non-OpenRouter provider).
|
||||
- **Publisher belt-and-braces**: `Publisher::create_post()` refuses to create any post —
|
||||
publish or draft — whose content is empty after tag stripping. No empty draft post can ever
|
||||
be created again.
|
||||
- `finish_reason=length` is now always warning-logged with model, stage, and
|
||||
prompt/completion/reasoning token counts (previously silent — the truncated `llm_research`
|
||||
call in the incident run was invisible).
|
||||
|
||||
### Added
|
||||
- **Reasoning token cap + completion headroom**: when a request enables reasoning, the
|
||||
thinking budget is capped via OpenRouter's `reasoning.max_tokens` and the request's
|
||||
`max_tokens` is **raised** by the same amount, so no reasoning effort (incl. `xhigh`) can
|
||||
consume the visible-content budget. New setting `prautoblogger_reasoning_max_tokens`
|
||||
(Settings → AI Models, default `PRAUTOBLOGGER_DEFAULT_REASONING_MAX_TOKENS` = 2048; 0
|
||||
restores the previous uncapped effort mode; the option is sanitized server-side as
|
||||
numeric). When the cap is active it replaces `effort`
|
||||
(OpenRouter treats them as alternative budget controls); the `xhigh`/`high` effort options
|
||||
stay available but are bounded by default. Cost-governor reservations use the effective
|
||||
(raised) ceiling.
|
||||
- LLM call sites now pass their pipeline stage to the provider (`stage` option, never sent
|
||||
upstream) so guard warnings and failure rows are attributable: `analysis`, `review`,
|
||||
`llm_research`, `image_prompt_rewrite`, `opik_eval_judge`, and the writer stages (which
|
||||
also pass `prompt_key`).
|
||||
|
||||
### Notes
|
||||
- `OpenRouter_Provider::send_chat_completion()` body assembly moved to
|
||||
`OpenRouter_Request_Builder::build_body()` (300-line cap; behavior unchanged apart from the
|
||||
reasoning budget above).
|
||||
|
||||
## [0.18.0] - 2026-06-12
|
||||
|
||||
Pipeline v2 — Phase 1 substrate (plan of record: CPO thread
|
||||
`2026-06-prautoblogger-pipeline-redesign`, seq 3). Built on the current pipeline with **no
|
||||
behavior change** to the Economy (single-pass) and multi-step publish paths.
|
||||
|
||||
### Added
|
||||
- **Schema v1.2.0** (`prautoblogger_db_version` 1.1.0 → 1.2.0, idempotent dbDelta
|
||||
migrations, self-healing on version mismatch): new tables
|
||||
`wp_prautoblogger_prompts` (versioned prompt registry),
|
||||
`wp_prautoblogger_run_sources` + `wp_prautoblogger_run_decisions` (audit child tables),
|
||||
`wp_prautoblogger_runs` (per-run cost ledger + lifecycle) and
|
||||
`wp_prautoblogger_run_stages` (per-run per-stage state machine, idempotency key
|
||||
`run_id + stage + agent_role + item_key`); additive nullable columns `agent_role` and
|
||||
`prompt_version` on `wp_prautoblogger_generation_log` (historical rows stay valid).
|
||||
- `PRAutoBlogger_Stage_Display_Map`: single PHP vocabulary for historical stages
|
||||
(`analysis`, `outline`, `draft`, `polish`, `review`, `llm_research`, `image_a`, `image_b`,
|
||||
`image_prompt_rewrite`, `opik_eval_judge`), Pipeline v2 stages (`research`, `curate`,
|
||||
`draft`, `editorial`, `seo`, `publish`) and a humanizing fallback for unknown values, plus
|
||||
per-stage default agent role and primary prompt-registry key.
|
||||
- Uninstall now drops the five substrate tables and clears every plugin cron hook
|
||||
(previously only two of eight were cleared).
|
||||
- **Versioned prompt registry** (`PRAutoBlogger_Prompt_Registry` + `_Writer`): all pipeline
|
||||
prompt copy — content system/single-pass/outline/draft/polish, analysis system/user,
|
||||
editor system/review, research system, plus the **illustration rewriter prompt and the
|
||||
image Style Template** (the image-composer PR seam) — is seeded as immutable version 1
|
||||
from the v0.16.0 hardcoded texts (`prautoblogger_migrated_prompt_seed_v0180`). One active
|
||||
version per key; changes create new versions, never mutate. Call sites render through the
|
||||
registry with a **byte-identical in-code fallback** when the table is missing/empty
|
||||
(self-healing, no fatals on half-migrated state). No admin UI in Phase 1.
|
||||
- **Prompt-version pinning**: `Cost_Tracker::set_run_id()` pins the active version of every
|
||||
key on the run's row; every generation_log row (text and image stages) is stamped with the
|
||||
pinned `prompt_version` plus a stage-derived `agent_role`. Single-pass rows keep stage
|
||||
`draft` and stamp `content.single_pass` explicitly.
|
||||
- `PRAutoBlogger_Run_State` (runs-table ledger row: ceiling/reserved/settled/overage, pins,
|
||||
sticky lifecycle states) and `PRAutoBlogger_Run_Context` (per-process run id, mirrors the
|
||||
Opik trace-context pattern).
|
||||
|
||||
- **Per-run cost governor** (`PRAutoBlogger_Cost_Governor`, net-new enforcement — previously
|
||||
only the monthly cap was enforced): every `send_chat_completion()` reserves its worst-case
|
||||
estimate (prompt chars/4 + max_tokens, priced via the #18 chain) against the run ledger
|
||||
with a single atomic conditional UPDATE (the #10 lock discipline) BEFORE dispatch; the
|
||||
image `curl_multi` batch reserves its summed estimate before dispatch; reservations settle
|
||||
to actuals after each response (a failed call releases its hold). Breach → run `halted`
|
||||
(sticky), overage recorded, `Cost_Ceiling_Exception` aborts the call, remaining queued
|
||||
articles abort, and the run is surfaced in a "Halted runs" notice on the Review Queue.
|
||||
New setting **Per-Run Cost Ceiling (USD)** (`prautoblogger_per_run_cost_ceiling_usd`,
|
||||
default `PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD` = $0.50, snapshotted at run start,
|
||||
0 = disabled). Calls outside a run, the monthly budget, and the Cloudflare AI Gateway
|
||||
path behave exactly as before.
|
||||
|
||||
- **Run state machine + idempotent resume** (`PRAutoBlogger_Run_Stage_State`): per-run
|
||||
per-stage rows keyed `run_id + stage + agent_role + item_key` (item_key scopes
|
||||
article-level stages — one run_id spans all N articles of a batch; agent_role is the
|
||||
Phase-2 quorum dimension, schema-only for now). Done stages snapshot their output and are
|
||||
REUSED on re-entry — never re-run, never re-charged (writer stages via
|
||||
`Content_Generator::set_run_item()`, the editorial review via its stored verdict, a fully
|
||||
published item is skipped outright). **Post creation is keyed by `_prautoblogger_run_id`
|
||||
+ new `_prautoblogger_idea_hash` meta (check-before-insert)** so a retried run cannot
|
||||
create a duplicate post. Run-level research/analysis checkpoints ride Pipeline_Runner;
|
||||
completion/failure marks land on the runs row (Executor catch paths, orphan aborts,
|
||||
monthly-budget aborts).
|
||||
- **Reaper extension** (`PRAutoBlogger_Run_Reaper`, same daily cron as the #19 research
|
||||
reaper — no new schedule): stages stuck `running` > 2× expected stage wall-clock and runs
|
||||
silent > 2× expected run wall-clock are marked `failed` (filterable expectations); their
|
||||
open stages are reaped; such runs are queryable as **"incomplete"** via
|
||||
`Run_Reaper::incomplete_runs()`. Also enforces **retention**: `request_json` on the
|
||||
generation log and stage output snapshots are NULLed after R days — R from the new
|
||||
**Audit Payload Retention** setting (`prautoblogger_request_json_retention_days`, default
|
||||
`PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS` = 14, 0 = keep forever).
|
||||
- **Audit writes** (`PRAutoBlogger_Audit_Writer`): every reviewed article records its
|
||||
editorial verdict in `run_decisions`; every published article records the sources that
|
||||
fed it in `run_sources` (the source_ids_json / `_prautoblogger_research_sources`
|
||||
consolidation for new runs; no backfill).
|
||||
|
||||
### Changed
|
||||
- `Content_Generator`'s four stage methods now share one `execute_stage()` helper (the Opik
|
||||
span + dispatch + cost-log boilerplate was identical); file drops from 301 to ~220 lines.
|
||||
No behavior change: same prompts, models, params, span names, stage values.
|
||||
- `Cost_Tracker::get_avg_tokens_for_stages()` body moved to `Cost_Reporter` (read-only
|
||||
reporting query); the Cost_Tracker method remains as a delegating wrapper.
|
||||
- `OpenRouter_Provider`'s API-key fetch + format validation moved to
|
||||
`OpenRouter_Request_Builder::resolve_api_key()` (300-line cap); identical messages/behavior.
|
||||
- Pipeline_Runner's and Article_Worker's duplicated status-transient/summary helpers moved
|
||||
verbatim to the new `PRAutoBlogger_Pipeline_Status` (Pipeline_Runner back under the
|
||||
300-line cap at 271).
|
||||
|
||||
### Fixed
|
||||
- ARCHITECTURE.md documented the multi-step edit stage as `edit`; the code has always
|
||||
written `polish`. Canonicalized to **`polish`** everywhere.
|
||||
- Half-migrated-schema hardening: generation_log inserts retry without the new audit
|
||||
columns when the columns are missing (a cron run right after deploy no longer loses cost
|
||||
rows), and the db-version self-heal now also runs on cron requests (`init` +
|
||||
`wp_doing_cron()`), not only on `admin_init`.
|
||||
- OpenRouter pricing now resolves dated snapshot model IDs returned by the API
|
||||
(e.g. response model `deepseek/deepseek-v4-flash-20260423` for a
|
||||
`deepseek/deepseek-v4-flash` request) to the base model's price before the
|
||||
conservative-estimate fallback applies. Such responses were previously booked
|
||||
at the conservative $10/$30 per-M rate (~30x real cost on flash-tier models) —
|
||||
phantom spend that ate the per-run cost ceiling and the monthly budget gate.
|
||||
|
||||
## [0.17.0] - 2026-06-11
|
||||
|
||||
### Added
|
||||
- **Deterministic PHP image composer (Commit 2 of the in-plugin editorial image
|
||||
pipeline).** Every generated image now passes through a local, $0,
|
||||
deterministic composer between the provider's bytes and the media sideloader.
|
||||
Image A yields a corner-marked featured image (1200×632) plus a branded
|
||||
**OG/social variant** (1200×630, teal band, brand mark, baked editorial
|
||||
caption in Poppins SemiBold) and a **square card variant** (1080×1080, image
|
||||
slice + cream caption panel in Poppins Bold + teal footer with the horizontal
|
||||
lockup). Image B gets the corner mark only. Variants are sideloaded with a
|
||||
role filename suffix and meta-linked: attachment meta
|
||||
`_prautoblogger_image_role` + `_prautoblogger_base_attachment_id`, post meta
|
||||
`_prautoblogger_og_image_id` + `_prautoblogger_square_image_id` (the rebuilt
|
||||
SEO stage will emit `og:image` from the frozen og key).
|
||||
- **Capability ladder with cached probe.** Imagick (full compositing) →
|
||||
`wp_get_image_editor()` GD path (resize-only, unbranded, single WARNING) →
|
||||
pass-through (single WARNING). The probe result is cached in
|
||||
`prautoblogger_image_compose_capability`, fingerprinted by PHP version +
|
||||
imagick/gd presence so it auto-invalidates when the host changes. Override
|
||||
filter: `prautoblogger_image_compose_capability`. Composition failure can
|
||||
never block a publish (per-render try/catch + pipeline safety net).
|
||||
- New Images settings: `prautoblogger_image_compose_enabled` (default on),
|
||||
`prautoblogger_image_compose_variants` (default `og,square`),
|
||||
`prautoblogger_image_featured_mark_enabled` (default on). Geometry ships as
|
||||
layout-class constants behind the `prautoblogger_image_compose_layout`
|
||||
filter. All keys keep the `prautoblogger_`/`_prautoblogger_` prefixes, so
|
||||
the existing uninstall prefix-sweep purges them (no uninstall.php changes).
|
||||
- Vendored brand rasters (`assets/brand/`, from peptide-repo-brand SVG masters)
|
||||
and bundled OFL fonts (`assets/fonts/`: Poppins Bold + Poppins SemiBold +
|
||||
OFL.txt) — no system-font or runtime-SVG dependency, byte-stable output per
|
||||
environment (metadata stripped, PNG date/time chunks excluded).
|
||||
|
||||
### Fixed
|
||||
- Featured/Image B **alt text is now the editorial caption** instead of the
|
||||
generation model name (`sideload_and_log()` passed the model as `$alt_text`,
|
||||
contradicting the sideloader docblock). The model keeps persisting in
|
||||
`_prautoblogger_image_model`.
|
||||
|
||||
### Changed
|
||||
- `PRAutoBlogger_Image_Media_Sideloader::sideload_image()` /
|
||||
`generate_filename()` accept an optional filename role suffix
|
||||
(back-compat default `''`).
|
||||
- `PRAutoBlogger_Image_Pipeline` delegates sideload/cost-log, caption
|
||||
prepending, and variant persistence to the new
|
||||
`PRAutoBlogger_Image_Attacher` (300-line compliance); also declares the
|
||||
previously-dynamic `$trace_context` property (PHP 8.2 deprecation).
|
||||
|
||||
## [0.16.0] - 2026-05-29
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -150,6 +150,38 @@ interface PRAutoBlogger_Image_Provider_Interface {
|
||||
|
||||
---
|
||||
|
||||
## How To: Tune the Image Composer (v0.17.0+)
|
||||
|
||||
The deterministic composer (ARCHITECTURE.md #21) renders branded variants between
|
||||
provider bytes and sideload. Three rules when touching it:
|
||||
|
||||
1. **Geometry lives in code, not settings.** All sizes/colors/opacities come from
|
||||
`PRAutoBlogger_Image_Composer_Layout::defaults()` and can be overridden via the
|
||||
`prautoblogger_image_compose_layout` filter:
|
||||
```php
|
||||
add_filter( 'prautoblogger_image_compose_layout', function ( array $layout ): array {
|
||||
$layout['featured']['mark_opacity'] = 0.40;
|
||||
return $layout;
|
||||
} );
|
||||
```
|
||||
Promote a value to a settings field only on explicit CEO request.
|
||||
2. **Keep renderer classes WP-free.** `Image_Composer_Imagick`, `Image_Composer_Canvas`,
|
||||
and `Image_Composer_Layout` must not call WordPress functions — they are exercised by
|
||||
a standalone render harness (and the determinism test) without a WP bootstrap. Only
|
||||
the orchestrator (`Image_Composer`) and the GD rung (`Image_Composer_Editor`) may
|
||||
touch WP APIs.
|
||||
3. **Never fatal a publish.** Any new render path needs a try/catch that degrades to
|
||||
pass-through and logs at most one WARNING per run (`warn_once()`). Capability checks
|
||||
go through the cached probe (`prautoblogger_image_compose_capability` option +
|
||||
same-named override filter) — never re-probe per image.
|
||||
|
||||
Adding a new variant role: extend `Layout::defaults()` + the renderer, whitelist the
|
||||
role in `Image_Composer::configured_variants()` and in
|
||||
`Image_Attacher::ROLE_POST_META` (new `_prautoblogger_{role}_image_id` post meta keeps
|
||||
the uninstall prefix-sweep effective), then cover it in the unit ladder tests.
|
||||
|
||||
---
|
||||
|
||||
## How To: Add a New Admin Setting
|
||||
|
||||
1. Define the option key in the settings array in `admin/class-admin-page.php` → `get_settings_fields()` method
|
||||
@@ -172,13 +204,18 @@ interface PRAutoBlogger_Image_Provider_Interface {
|
||||
|
||||
## How To: Add a New Pipeline Stage
|
||||
|
||||
The writing pipeline is an ordered array of stages. Each stage is a class method on `Content_Generator`.
|
||||
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).
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
---
|
||||
|
||||
@@ -288,6 +325,38 @@ 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.
|
||||
|
||||
BIN
assets/brand/logo-horizontal-reverse-128.png
Normal file
BIN
assets/brand/logo-horizontal-reverse-128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
BIN
assets/brand/logo-horizontal-reverse-256.png
Normal file
BIN
assets/brand/logo-horizontal-reverse-256.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/brand/logo-mark-small-112.png
Normal file
BIN
assets/brand/logo-mark-small-112.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
BIN
assets/brand/logo-mark-small-56.png
Normal file
BIN
assets/brand/logo-mark-small-56.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
93
assets/fonts/OFL.txt
Normal file
93
assets/fonts/OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The Poppins Project Authors (https://github.com/itfoundry/Poppins)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
BIN
assets/fonts/Poppins-Bold.ttf
Normal file
BIN
assets/fonts/Poppins-Bold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Poppins-SemiBold.ttf
Normal file
BIN
assets/fonts/Poppins-SemiBold.ttf
Normal file
Binary file not shown.
@@ -323,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' );
|
||||
$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_reasoning_max_tokens' );
|
||||
if ( in_array( $option_name, $numeric, true ) ) {
|
||||
return is_numeric( $value ) ? $value : 0;
|
||||
}
|
||||
|
||||
13
includes/admin/class-review-queue.php
Executable file → Normal file
13
includes/admin/class-review-queue.php
Executable file → Normal file
@@ -51,9 +51,22 @@ 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.
|
||||
*
|
||||
|
||||
@@ -78,7 +78,17 @@ class PRAutoBlogger_Settings_Fields_Extended {
|
||||
'low' => __( 'Low — light reasoning, lower cost', 'prautoblogger' ),
|
||||
'minimal' => __( 'Minimal — barely any reasoning', 'prautoblogger' ),
|
||||
),
|
||||
'description' => __( 'How much effort the model spends reasoning before answering. Higher effort = more reasoning tokens = higher cost. Only applies when reasoning is enabled above.', 'prautoblogger' ),
|
||||
'description' => __( 'How much effort the model spends reasoning before answering. Higher effort = more reasoning tokens = higher cost. Only applies when reasoning is enabled above. When the Reasoning Token Cap below is set (default), the cap is sent instead of the effort level — set the cap to 0 to use effort only.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_reasoning_max_tokens',
|
||||
'label' => __( 'Reasoning Token Cap', 'prautoblogger' ),
|
||||
'type' => 'number',
|
||||
'section' => 'prautoblogger_models',
|
||||
'default' => PRAUTOBLOGGER_DEFAULT_REASONING_MAX_TOKENS,
|
||||
'min' => 0,
|
||||
'max' => 32000,
|
||||
'description' => __( 'Hard ceiling on thinking tokens per call when reasoning is enabled. The completion budget is raised by this amount so reasoning can never crowd out the article text — an uncapped high effort can spend the entire budget thinking and return an empty draft. 0 = no cap (uncapped effort mode, not recommended).', 'prautoblogger' ),
|
||||
),
|
||||
|
||||
// ── Schedule & Budget ───────────────────────────────────────
|
||||
@@ -113,6 +123,26 @@ class PRAutoBlogger_Settings_Fields_Extended {
|
||||
'step' => '0.01',
|
||||
'description' => __( 'Hard stop when reached. 0 = unlimited.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_request_json_retention_days',
|
||||
'label' => __( 'Audit Payload Retention (days)', 'prautoblogger' ),
|
||||
'type' => 'number',
|
||||
'section' => 'prautoblogger_schedule',
|
||||
'default' => PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS,
|
||||
'min' => 0,
|
||||
'step' => '1',
|
||||
'description' => __( 'Heavy audit payloads (request_json on the generation log, stage output snapshots) are emptied after this many days by the daily cleanup cron. Cost/token rows are kept forever. 0 = keep payloads forever.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_per_run_cost_ceiling_usd',
|
||||
'label' => __( 'Per-Run Cost Ceiling (USD)', 'prautoblogger' ),
|
||||
'type' => 'number',
|
||||
'section' => 'prautoblogger_schedule',
|
||||
'default' => PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD,
|
||||
'min' => 0,
|
||||
'step' => '0.01',
|
||||
'description' => __( 'Hard per-run stop: every API call reserves its worst-case cost against this ceiling before dispatch; a breach halts the run and routes it to the Review Queue. Snapshotted at run start. 0 = disabled.', 'prautoblogger' ),
|
||||
),
|
||||
|
||||
// ── Publishing ──────────────────────────────────────────────
|
||||
array(
|
||||
@@ -258,6 +288,30 @@ class PRAutoBlogger_Settings_Fields_Extended {
|
||||
'default' => '1',
|
||||
'description' => __( 'When the provider rejects an image prompt as NSFW, retry once with a generic fallback scene built from the article title. Disable to fail fast if the filter gets trigger-happy.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_image_compose_enabled',
|
||||
'label' => __( 'Compose Branded Variants', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => '1',
|
||||
'description' => __( 'Render branded social variants (teal band, logo, baked caption) from each generated image via the local deterministic composer. Auto-degrades to plain resizing or pass-through if the server lacks an image library — publishing is never blocked.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_image_compose_variants',
|
||||
'label' => __( 'Composed Variant Set', 'prautoblogger' ),
|
||||
'type' => 'text',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => 'og,square',
|
||||
'description' => __( 'Comma-separated variants to render for Image A. Supported: og (1200×630 social share), square (1080×1080 card). Unknown values are ignored.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_image_featured_mark_enabled',
|
||||
'label' => __( 'Featured Image Corner Mark', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => '1',
|
||||
'description' => __( 'Overlay a small semi-transparent Peptide Repo mark in the bottom-right corner of featured images, so scraped or re-shared images carry attribution.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_runware_model_catalog',
|
||||
'label' => __( 'Runware Model Catalog', 'prautoblogger' ),
|
||||
|
||||
12
includes/class-activator.php
Executable file → Normal file
12
includes/class-activator.php
Executable file → Normal file
@@ -31,14 +31,22 @@ 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 `PRAutoBlogger_Schema_Installer::install()` — see that class. */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private static function create_tables(): void {
|
||||
PRAutoBlogger_Schema_Installer::install();
|
||||
PRAutoBlogger_Pipeline_Schema_Installer::install();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,6 +68,8 @@ 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,
|
||||
|
||||
@@ -53,10 +53,25 @@ class PRAutoBlogger_Executor {
|
||||
sprintf( 'Daily generation %s: %s', get_class( $e ), $e->getMessage() ),
|
||||
'scheduler'
|
||||
);
|
||||
$this->mark_current_run_failed();
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark this process's run failed after a fatal pipeline error
|
||||
* (no-op outside a run; a governor-halted run stays halted — final
|
||||
* states are sticky).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function mark_current_run_failed(): void {
|
||||
$run_id = PRAutoBlogger_Run_Context::current_run_id();
|
||||
if ( null !== $run_id ) {
|
||||
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the metrics collection cron event.
|
||||
* Side effects: API calls to GA4, database writes to ab_content_scores.
|
||||
@@ -209,6 +224,7 @@ class PRAutoBlogger_Executor {
|
||||
),
|
||||
self::STATUS_TTL
|
||||
);
|
||||
$this->mark_current_run_failed();
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
}
|
||||
}
|
||||
@@ -286,6 +302,12 @@ class PRAutoBlogger_Executor {
|
||||
|
||||
/** Clean up an orphaned generation run and report error. */
|
||||
private function abort_orphaned_run( string $message ): void {
|
||||
// v0.18.0: the dead run renders as failed/"incomplete" in audit
|
||||
// queries instead of silently lingering as 'running'.
|
||||
$queue = get_option( 'prautoblogger_article_queue' );
|
||||
if ( is_array( $queue ) && ! empty( $queue['run_id'] ) ) {
|
||||
PRAutoBlogger_Run_State::mark_status( (string) $queue['run_id'], 'failed' );
|
||||
}
|
||||
delete_transient( self::STATUS_TRANSIENT );
|
||||
delete_option( 'prautoblogger_article_queue' );
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
|
||||
59
includes/class-migrate-prompt-seed-v0180.php
Normal file
59
includes/class-migrate-prompt-seed-v0180.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?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' );
|
||||
}
|
||||
}
|
||||
147
includes/class-pipeline-schema-installer.php
Normal file
147
includes/class-pipeline-schema-installer.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?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 );
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,10 @@ class PRAutoBlogger {
|
||||
$this->ajax_handlers = new PRAutoBlogger_Ajax_Handlers( $this->executor->get_model_registry() );
|
||||
|
||||
add_action( 'admin_init', array( $this, 'on_check_db_version' ) );
|
||||
// v0.18.0: cron requests must also self-heal the schema — after a
|
||||
// deploy, scheduled generation can run before any admin page load,
|
||||
// and new-column inserts would silently fail on the old schema.
|
||||
add_action( 'init', array( $this, 'on_check_db_version_for_cron' ) );
|
||||
add_filter( 'cron_schedules', array( $this, 'filter_add_cron_schedules' ) );
|
||||
|
||||
if ( is_admin() ) {
|
||||
@@ -150,6 +154,10 @@ class PRAutoBlogger {
|
||||
// pipeline dies before amortize_research_costs runs).
|
||||
add_action( 'prautoblogger_reap_orphan_research_rows', array( 'PRAutoBlogger_Research_Reaper', 'on_cron' ) );
|
||||
|
||||
// v0.18.0: stuck-run sweep + audit-payload retention rides the SAME
|
||||
// existing daily cron (no new schedule). See class-run-reaper.php.
|
||||
add_action( 'prautoblogger_reap_orphan_research_rows', array( 'PRAutoBlogger_Run_Reaper', 'on_cron' ) );
|
||||
|
||||
// v0.8.1: WP-CLI manual trigger for the reaper. Only registers when
|
||||
// WP-CLI is present; no-op in normal HTTP requests.
|
||||
if ( defined( 'WP_CLI' ) && WP_CLI ) {
|
||||
@@ -302,6 +310,21 @@ class PRAutoBlogger {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the db-version check on cron requests (admin_init never fires
|
||||
* there). Gated to wp_doing_cron() so normal frontend requests pay
|
||||
* nothing beyond one autoloaded option read.
|
||||
*
|
||||
* Side effects: may run the same migrations as on_check_db_version().
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function on_check_db_version_for_cron(): void {
|
||||
if ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ) {
|
||||
$this->on_check_db_version();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add custom cron schedules (six-hourly for metrics collection).
|
||||
*
|
||||
|
||||
@@ -77,6 +77,9 @@ 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,
|
||||
@@ -90,6 +93,8 @@ 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),
|
||||
|
||||
@@ -9,7 +9,8 @@ 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: WordPress $wpdb (for performance lookup), no external APIs.
|
||||
* Dependencies: PRAutoBlogger_Prompt_Registry (versioned prompt bodies),
|
||||
* 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.
|
||||
@@ -25,54 +26,26 @@ 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 {
|
||||
$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";
|
||||
}
|
||||
// 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" : '';
|
||||
|
||||
$recent_context = self::get_recent_articles_context();
|
||||
if ( '' !== $recent_context ) {
|
||||
$prompt .= $recent_context . "\n\n";
|
||||
}
|
||||
$recent_block = '' !== $recent_context ? $recent_context . "\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";
|
||||
}
|
||||
$instructions = trim( (string) get_option( 'prautoblogger_analysis_instructions', '' ) );
|
||||
$instructions_block = '' !== $instructions ? "Additional instructions:\n" . $instructions . "\n\n" : '';
|
||||
|
||||
$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;
|
||||
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,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,9 +56,13 @@ class PRAutoBlogger_Analysis_Prompts {
|
||||
* @return string User prompt text.
|
||||
*/
|
||||
public static function build_user_prompt( string $summary, int $target_count = 6 ): string {
|
||||
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;
|
||||
return PRAutoBlogger_Prompt_Registry::render(
|
||||
'analysis.user',
|
||||
array(
|
||||
'target_count' => (string) $target_count,
|
||||
'summary' => $summary,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,8 +9,15 @@ 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.
|
||||
* Dependencies: Content_Generator, Chief_Editor, Publisher, Cost_Tracker,
|
||||
* Run_Stage_State, Audit_Writer.
|
||||
*
|
||||
* Opik instrumentation: Initializes trace context at start, creates spans
|
||||
* for content generation and editorial review. Feature-flag gated (default off).
|
||||
@@ -48,6 +55,27 @@ 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 ) {
|
||||
@@ -56,6 +84,7 @@ 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();
|
||||
|
||||
@@ -65,22 +94,16 @@ class PRAutoBlogger_Article_Worker {
|
||||
true
|
||||
);
|
||||
|
||||
$result = array(
|
||||
'generated' => 0,
|
||||
'published' => 0,
|
||||
'rejected' => 0,
|
||||
'cost' => 0.0,
|
||||
);
|
||||
|
||||
try {
|
||||
$this->broadcast_stage( __( 'Generating article draft via AI…', 'prautoblogger' ) );
|
||||
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Generating article draft via AI…', 'prautoblogger' ) );
|
||||
$content = $generator->generate( $idea );
|
||||
$result['generated'] = 1;
|
||||
|
||||
$this->broadcast_stage( __( 'Running editorial pass…', 'prautoblogger' ) );
|
||||
$review = $editor->review( $content, $idea );
|
||||
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Running editorial pass…', 'prautoblogger' ) );
|
||||
$review = $this->review_with_state( $editor, $content, $idea, $run_id, $item );
|
||||
|
||||
$this->broadcast_stage( __( 'Saving and publishing…', 'prautoblogger' ) );
|
||||
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Saving and publishing…', 'prautoblogger' ) );
|
||||
PRAutoBlogger_Run_Stage_State::start( $run_id, 'publish', '', $item );
|
||||
$this->publish_or_draft(
|
||||
$content,
|
||||
$idea,
|
||||
@@ -89,7 +112,13 @@ 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',
|
||||
@@ -111,6 +140,63 @@ 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', '', $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.
|
||||
*
|
||||
@@ -153,21 +239,6 @@ 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.
|
||||
*
|
||||
|
||||
132
includes/core/class-audit-writer.php
Normal file
132
includes/core/class-audit-writer.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?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
Executable file → Normal file
74
includes/core/class-chief-editor.php
Executable file → Normal 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.
|
||||
* Dependencies: LLM_Provider_Interface, Cost_Tracker, Prompt_Registry.
|
||||
*/
|
||||
class PRAutoBlogger_Chief_Editor {
|
||||
|
||||
@@ -64,6 +64,7 @@ class PRAutoBlogger_Chief_Editor {
|
||||
'temperature' => 0.3,
|
||||
'max_tokens' => 5000,
|
||||
'response_format' => array( 'type' => 'json_object' ),
|
||||
'stage' => 'review',
|
||||
)
|
||||
);
|
||||
|
||||
@@ -92,54 +93,47 @@ 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 {
|
||||
$prompt = 'You are a senior blog editor';
|
||||
if ( '' !== $niche ) {
|
||||
$prompt .= " specializing in {$niche} content";
|
||||
}
|
||||
$prompt .= ". Review article drafts before publication.\n\n";
|
||||
$instructions = trim( (string) get_option( 'prautoblogger_editor_instructions', '' ) );
|
||||
$instructions_block = '' !== $instructions ? "\nAdditional instructions:\n" . $instructions . "\n" : '';
|
||||
|
||||
$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;
|
||||
return PRAutoBlogger_Prompt_Registry::render(
|
||||
'editor.system',
|
||||
array(
|
||||
'niche_clause' => '' !== $niche ? " specializing in {$niche} content" : '',
|
||||
'instructions_block' => $instructions_block,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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
|
||||
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,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ class PRAutoBlogger_Content_Analyzer {
|
||||
'temperature' => 0.5,
|
||||
'max_tokens' => 8000,
|
||||
'response_format' => array( 'type' => 'json_object' ),
|
||||
'stage' => 'analysis',
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
305
includes/core/class-content-generator.php
Executable file → Normal file
305
includes/core/class-content-generator.php
Executable file → Normal file
@@ -6,6 +6,10 @@ 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.
|
||||
@@ -15,6 +19,12 @@ 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
|
||||
@@ -23,10 +33,30 @@ 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 PRAutoBlogger_Article_Idea $idea The scored idea to generate content for.
|
||||
* @param bool $eval_mode True to suppress publish/image side effects.
|
||||
* @return string Generated HTML content.
|
||||
*/
|
||||
public function generate( PRAutoBlogger_Article_Idea $idea, bool $eval_mode = false ): string {
|
||||
@@ -52,101 +82,47 @@ class PRAutoBlogger_Content_Generator {
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-pass: one LLM call produces complete article.
|
||||
* 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.
|
||||
*/
|
||||
private function generate_single_pass( PRAutoBlogger_Content_Request $request ): string {
|
||||
$model = get_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
|
||||
$ctx = PRAutoBlogger_Opik_Trace_Context::current();
|
||||
|
||||
$span_id = $ctx->start_span(
|
||||
array(
|
||||
'name' => 'single_pass_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_single_pass( $request ),
|
||||
),
|
||||
),
|
||||
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'
|
||||
);
|
||||
|
||||
$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'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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->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 ),
|
||||
),
|
||||
),
|
||||
$outline = $this->execute_stage(
|
||||
'outline',
|
||||
'outline_generation',
|
||||
PRAutoBlogger_Content_Prompts::build_outline( $request ),
|
||||
$request,
|
||||
$model,
|
||||
array(
|
||||
'temperature' => 0.5,
|
||||
@@ -154,55 +130,11 @@ class PRAutoBlogger_Content_Generator {
|
||||
)
|
||||
);
|
||||
|
||||
$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 ),
|
||||
),
|
||||
),
|
||||
$draft = $this->execute_stage(
|
||||
'draft',
|
||||
'draft_generation',
|
||||
PRAutoBlogger_Content_Prompts::build_draft( $request, $outline ),
|
||||
$request,
|
||||
$model,
|
||||
array(
|
||||
'temperature' => 0.7,
|
||||
@@ -210,38 +142,72 @@ class PRAutoBlogger_Content_Generator {
|
||||
)
|
||||
);
|
||||
|
||||
$ctx->end_span(
|
||||
$span_id,
|
||||
return $this->execute_stage(
|
||||
'polish',
|
||||
'polish_generation',
|
||||
PRAutoBlogger_Content_Prompts::build_polish( $draft ),
|
||||
$request,
|
||||
$model,
|
||||
array(
|
||||
'usage' => array(
|
||||
'prompt_tokens' => $response['prompt_tokens'],
|
||||
'completion_tokens' => $response['completion_tokens'],
|
||||
'total_tokens' => $response['prompt_tokens'] + $response['completion_tokens'],
|
||||
),
|
||||
'temperature' => 0.4,
|
||||
'max_tokens' => 4000,
|
||||
)
|
||||
);
|
||||
|
||||
$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.
|
||||
* 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 stage_polish( PRAutoBlogger_Content_Request $request, string $draft, string $model ): string {
|
||||
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, '', $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' => 'polish_generation',
|
||||
'name' => $span_name,
|
||||
'type' => 'llm',
|
||||
'model' => $model,
|
||||
'provider' => 'openrouter',
|
||||
@@ -256,16 +222,28 @@ class PRAutoBlogger_Content_Generator {
|
||||
),
|
||||
array(
|
||||
'role' => 'user',
|
||||
'content' => PRAutoBlogger_Content_Prompts::build_polish( $draft ),
|
||||
'content' => $user_prompt,
|
||||
),
|
||||
),
|
||||
$model,
|
||||
array(
|
||||
'temperature' => 0.4,
|
||||
'max_tokens' => 4000,
|
||||
)
|
||||
$options
|
||||
);
|
||||
|
||||
// v0.18.1 writer-path guard (belt over the provider seam, which
|
||||
// already throws for OpenRouter): no empty article may ever leave
|
||||
// a writing stage — it would flow to the editor, be rejected as
|
||||
// "draft text is missing", and land as an empty draft post.
|
||||
if ( '' === trim( (string) $response['content'] ) ) {
|
||||
throw new \RuntimeException(
|
||||
sprintf(
|
||||
/* translators: 1: writing stage name, 2: model identifier. */
|
||||
__( 'Writing stage "%1$s" produced empty content (model %2$s) — failing the stage instead of passing an empty article downstream.', 'prautoblogger' ),
|
||||
$stage,
|
||||
(string) $response['model']
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$ctx->end_span(
|
||||
$span_id,
|
||||
array(
|
||||
@@ -279,13 +257,28 @@ class PRAutoBlogger_Content_Generator {
|
||||
|
||||
$this->cost_tracker->log_api_call(
|
||||
null,
|
||||
'polish',
|
||||
$stage,
|
||||
$this->llm->get_provider_name(),
|
||||
$response['model'],
|
||||
$response['prompt_tokens'],
|
||||
$response['completion_tokens']
|
||||
$response['completion_tokens'],
|
||||
'success',
|
||||
'',
|
||||
null,
|
||||
$prompt_key
|
||||
);
|
||||
|
||||
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'];
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ declare(strict_types=1);
|
||||
* independently of the generation orchestration.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Content_Generator (called for every stage).
|
||||
* Dependencies: WordPress query functions (get_posts, get_the_title, get_permalink).
|
||||
* Dependencies: PRAutoBlogger_Prompt_Registry (versioned prompt bodies),
|
||||
* 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.
|
||||
@@ -24,18 +25,22 @@ 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 = '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.';
|
||||
$prompt = PRAutoBlogger_Prompt_Registry::render(
|
||||
'content.system',
|
||||
array(
|
||||
'niche_clause' => '' !== $niche ? " specializing in {$niche}" : '',
|
||||
'tone' => $request->get_tone(),
|
||||
)
|
||||
);
|
||||
|
||||
// Append user-defined writing instructions as a mandatory style guide.
|
||||
$instructions = trim( $request->get_writing_instructions() );
|
||||
@@ -60,24 +65,17 @@ class PRAutoBlogger_Content_Prompts {
|
||||
public static function build_single_pass( PRAutoBlogger_Content_Request $request ): string {
|
||||
$idea = $request->get_idea();
|
||||
|
||||
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()
|
||||
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(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,21 +88,17 @@ class PRAutoBlogger_Content_Prompts {
|
||||
public static function build_outline( PRAutoBlogger_Content_Request $request ): string {
|
||||
$idea = $request->get_idea();
|
||||
|
||||
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()
|
||||
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(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -116,23 +110,15 @@ class PRAutoBlogger_Content_Prompts {
|
||||
* @return string Draft prompt.
|
||||
*/
|
||||
public static function build_draft( PRAutoBlogger_Content_Request $request, string $outline ): string {
|
||||
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() )
|
||||
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() ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -143,18 +129,10 @@ class PRAutoBlogger_Content_Prompts {
|
||||
* @return string Polish prompt.
|
||||
*/
|
||||
public static function build_polish( string $draft ): string {
|
||||
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;
|
||||
return PRAutoBlogger_Prompt_Registry::render(
|
||||
'content.polish',
|
||||
array( 'draft' => $draft )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
23
includes/core/class-cost-ceiling-exception.php
Normal file
23
includes/core/class-cost-ceiling-exception.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?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 {
|
||||
}
|
||||
239
includes/core/class-cost-governor.php
Normal file
239
includes/core/class-cost-governor.php
Normal file
@@ -0,0 +1,239 @@
|
||||
<?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 );
|
||||
}
|
||||
}
|
||||
@@ -140,4 +140,62 @@ 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 ),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,14 @@ 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 );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,14 +71,20 @@ 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: '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'.
|
||||
* @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.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
@@ -82,7 +96,9 @@ class PRAutoBlogger_Cost_Tracker {
|
||||
int $prompt_tokens,
|
||||
int $completion_tokens,
|
||||
string $response_status = 'success',
|
||||
string $error_message = ''
|
||||
string $error_message = '',
|
||||
?string $agent_role = null,
|
||||
?string $prompt_key = null
|
||||
): void {
|
||||
$llm = new PRAutoBlogger_OpenRouter_Provider();
|
||||
$cost = $llm->estimate_cost( $model, $prompt_tokens, $completion_tokens );
|
||||
@@ -101,6 +117,8 @@ 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' ),
|
||||
)
|
||||
);
|
||||
@@ -109,9 +127,19 @@ class PRAutoBlogger_Cost_Tracker {
|
||||
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->insert( $table, $log_entry->to_db_row() );
|
||||
$inserted = $wpdb->insert( $table, $log_entry->to_db_row() );
|
||||
|
||||
if ( false === $wpdb->insert_id ) {
|
||||
// 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 ) {
|
||||
PRAutoBlogger_Logger::instance()->error( 'Failed to log API call: ' . $wpdb->last_error, 'cost-tracker' );
|
||||
}
|
||||
}
|
||||
@@ -161,23 +189,55 @@ class PRAutoBlogger_Cost_Tracker {
|
||||
}
|
||||
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$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' ),
|
||||
)
|
||||
$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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -214,9 +274,9 @@ class PRAutoBlogger_Cost_Tracker {
|
||||
/**
|
||||
* 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).
|
||||
* 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.
|
||||
*
|
||||
* @param array<string> $stages Stage names ('analysis', 'outline', 'draft', 'polish', 'review').
|
||||
* @param int $days Historical window (default 30 days).
|
||||
@@ -225,46 +285,6 @@ 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 {
|
||||
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 ),
|
||||
);
|
||||
return ( new PRAutoBlogger_Cost_Reporter() )->get_avg_tokens_for_stages( $stages, $days );
|
||||
}
|
||||
}
|
||||
|
||||
169
includes/core/class-image-attacher.php
Normal file
169
includes/core/class-image-attacher.php
Normal file
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Persists generated images and composed variants for a post.
|
||||
*
|
||||
* Owns the media-library side of the image pipeline: sideloading the primary
|
||||
* (featured) image with cost logging, sideloading composed OG/square variants
|
||||
* with role/base meta-linking (no cost-tracker entries — composition is $0
|
||||
* local CPU), and prepending the editable HTML caption to the post. Extracted
|
||||
* from PRAutoBlogger_Image_Pipeline in v0.17.0 for 300-line compliance.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Image_Pipeline during content generation run.
|
||||
* Dependencies: PRAutoBlogger_Image_Media_Sideloader, PRAutoBlogger_Cost_Tracker,
|
||||
* PRAutoBlogger_Logger.
|
||||
*
|
||||
* @see core/class-image-pipeline.php — Sole caller.
|
||||
* @see core/class-image-composer.php — Produces the variants this persists.
|
||||
* @see core/class-image-media-sideloader.php — Media library import.
|
||||
*/
|
||||
class PRAutoBlogger_Image_Attacher {
|
||||
|
||||
/** Post meta key per composed variant role. */
|
||||
private const ROLE_POST_META = array(
|
||||
'og' => '_prautoblogger_og_image_id',
|
||||
'square' => '_prautoblogger_square_image_id',
|
||||
);
|
||||
|
||||
/** @var PRAutoBlogger_Image_Media_Sideloader Imports bytes into the media library. */
|
||||
private PRAutoBlogger_Image_Media_Sideloader $sideloader;
|
||||
|
||||
/** @var PRAutoBlogger_Cost_Tracker Logs primary image generation spend. */
|
||||
private PRAutoBlogger_Cost_Tracker $cost_tracker;
|
||||
|
||||
/**
|
||||
* Construct with the pipeline's sideloader + cost tracker.
|
||||
*
|
||||
* @param PRAutoBlogger_Image_Media_Sideloader $sideloader Media importer.
|
||||
* @param PRAutoBlogger_Cost_Tracker $cost_tracker Spend logger.
|
||||
*/
|
||||
public function __construct( PRAutoBlogger_Image_Media_Sideloader $sideloader, PRAutoBlogger_Cost_Tracker $cost_tracker ) {
|
||||
$this->sideloader = $sideloader;
|
||||
$this->cost_tracker = $cost_tracker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sideload a primary image into the media library and log its cost.
|
||||
*
|
||||
* Side effects: media import, cost-tracker row, info log.
|
||||
*
|
||||
* @param array $image_data Provider result with bytes, model, cost_usd.
|
||||
* @param int $post_id Post ID.
|
||||
* @param string $slot Cost-tracking slot ('image_a' or 'image_b').
|
||||
* @param string $label Human-readable label for logs.
|
||||
* @param string $alt_text Alt text — the editorial caption (v0.17.0 bug
|
||||
* fix: previously the model name leaked here).
|
||||
*
|
||||
* @return int|\WP_Error Attachment ID on success.
|
||||
*/
|
||||
public function sideload_and_log( array $image_data, int $post_id, string $slot, string $label, string $alt_text = '' ) {
|
||||
$attachment_id = $this->sideloader->sideload_image( $image_data, $post_id, $alt_text );
|
||||
|
||||
if ( is_wp_error( $attachment_id ) ) {
|
||||
return $attachment_id;
|
||||
}
|
||||
|
||||
$this->cost_tracker->log_image_generation(
|
||||
$image_data['cost_usd'],
|
||||
$image_data['model'] ?? 'unknown',
|
||||
$post_id,
|
||||
$slot
|
||||
);
|
||||
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf( '%s for post %d (att %d, $%.4f)', $label, $post_id, $attachment_id, $image_data['cost_usd'] ),
|
||||
'image_pipeline'
|
||||
);
|
||||
|
||||
return $attachment_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sideload composed variants and meta-link them to post + base image.
|
||||
*
|
||||
* Each variant attachment gets `_prautoblogger_image_role` and
|
||||
* `_prautoblogger_base_attachment_id`; the post gets the role-specific
|
||||
* ID meta (see ROLE_POST_META). No cost-tracker entries — composition is
|
||||
* a $0 local stage. A failed variant sideload logs a WARNING and is
|
||||
* skipped; it never blocks the publish.
|
||||
*
|
||||
* @param array $variants Composed variants (role 'og'/'square' only are persisted).
|
||||
* @param int $post_id Post ID.
|
||||
* @param int $base_attachment_id Featured attachment the variants derive from.
|
||||
* @param string $caption Editorial caption (used as variant alt text).
|
||||
* @param string $model Generation model recorded on the variant attachment.
|
||||
*/
|
||||
public function attach_variants( array $variants, int $post_id, int $base_attachment_id, string $caption, string $model ): void {
|
||||
foreach ( $variants as $variant ) {
|
||||
$role = (string) ( $variant['role'] ?? '' );
|
||||
if ( ! isset( self::ROLE_POST_META[ $role ] ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$attachment_id = $this->sideloader->sideload_image(
|
||||
array(
|
||||
'bytes' => (string) ( $variant['bytes'] ?? '' ),
|
||||
'mime_type' => (string) ( $variant['mime_type'] ?? 'image/png' ),
|
||||
'width' => (int) ( $variant['width'] ?? 0 ),
|
||||
'height' => (int) ( $variant['height'] ?? 0 ),
|
||||
'model' => $model,
|
||||
'cost_usd' => 0.0,
|
||||
'latency_ms' => 0,
|
||||
),
|
||||
$post_id,
|
||||
$caption,
|
||||
$role
|
||||
);
|
||||
|
||||
if ( is_wp_error( $attachment_id ) ) {
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf( 'Failed to sideload %s variant for post %d: %s', $role, $post_id, $attachment_id->get_error_message() ),
|
||||
'image_composer'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
update_post_meta( $attachment_id, '_prautoblogger_image_role', $role );
|
||||
update_post_meta( $attachment_id, '_prautoblogger_base_attachment_id', $base_attachment_id );
|
||||
update_post_meta( $post_id, self::ROLE_POST_META[ $role ], $attachment_id );
|
||||
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf( '%s variant for post %d (att %d, %dx%d)', $role, $post_id, $attachment_id, (int) $variant['width'], (int) $variant['height'] ),
|
||||
'image_composer'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend the editorial caption as styled text at the top of the post.
|
||||
*
|
||||
* Only inserts the caption — NOT the image. The theme already displays
|
||||
* the featured image, so embedding it again would cause a duplicate.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param int $attachment_id Featured image attachment ID (unused; kept for parity with meta storage).
|
||||
* @param string $caption Caption text shown below the image.
|
||||
*/
|
||||
public function prepend_caption_to_post( int $post_id, int $attachment_id, string $caption ): void {
|
||||
$post = get_post( $post_id );
|
||||
if ( ! $post ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Caption-only block — the theme handles the featured image display.
|
||||
$caption_html = sprintf(
|
||||
'<p class="prautoblogger-comic-caption" style="text-align:center;font-style:italic;color:#555;font-size:1.1em;margin:0 0 2em 0;">— "%s"</p>',
|
||||
esc_html( $caption )
|
||||
);
|
||||
|
||||
wp_update_post(
|
||||
array(
|
||||
'ID' => $post_id,
|
||||
'post_content' => $caption_html . "\n\n" . $post->post_content,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
186
includes/core/class-image-composer-canvas.php
Normal file
186
includes/core/class-image-composer-canvas.php
Normal file
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Low-level Imagick canvas primitives for the deterministic image composer.
|
||||
*
|
||||
* Capability smoke test, base-image loading, cover-cropping, logo placement,
|
||||
* and deterministic PNG encoding. No WordPress functions anywhere in this
|
||||
* class — it runs unchanged inside the standalone render harness used to
|
||||
* produce the PR sample images.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Image_Composer_Imagick (renderer) per variant.
|
||||
* Dependencies: ext-imagick (callers must gate on is_imagick_usable()).
|
||||
*
|
||||
* @see core/class-image-composer-imagick.php — Sole consumer.
|
||||
* @see core/class-image-composer.php — Caches the capability probe result.
|
||||
*/
|
||||
class PRAutoBlogger_Image_Composer_Canvas {
|
||||
|
||||
/**
|
||||
* Probe whether Imagick can do everything the renderer needs: PNG
|
||||
* encode plus TTF text annotation (the smoke test draws one glyph on a
|
||||
* tiny in-memory canvas). Cheap to run but callers should cache it.
|
||||
*
|
||||
* Side effects: none beyond a transient in-memory Imagick object.
|
||||
*
|
||||
* @param string $font_path Absolute path to a bundled TTF used for the smoke test.
|
||||
*
|
||||
* @return bool True when full compositing is available.
|
||||
*/
|
||||
public static function is_imagick_usable( string $font_path ): bool {
|
||||
if ( ! extension_loaded( 'imagick' ) || ! class_exists( '\Imagick' ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$formats = \Imagick::queryFormats( 'PNG' );
|
||||
if ( empty( $formats ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( ! is_readable( $font_path ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$probe = new \Imagick();
|
||||
$probe->newImage( 8, 8, new \ImagickPixel( '#ffffff' ), 'png' );
|
||||
$draw = new \ImagickDraw();
|
||||
$draw->setFont( $font_path );
|
||||
$draw->setFontSize( 6 );
|
||||
$probe->annotateImage( $draw, 1, 6, 0.0, 'P' );
|
||||
$probe->clear();
|
||||
|
||||
return true;
|
||||
} catch ( \Throwable $e ) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load base image bytes into an Imagick object (first frame only).
|
||||
*
|
||||
* @param string $bytes Raw image bytes (PNG/JPEG/WebP — any readable format).
|
||||
*
|
||||
* @return \Imagick Loaded image.
|
||||
* @throws \RuntimeException When the bytes cannot be decoded.
|
||||
*/
|
||||
public static function load_base( string $bytes ): \Imagick {
|
||||
$image = new \Imagick();
|
||||
if ( ! $image->readImageBlob( $bytes ) ) {
|
||||
throw new \RuntimeException( 'Composer could not decode base image bytes.' );
|
||||
}
|
||||
$image->setIteratorIndex( 0 );
|
||||
$image = $image->coalesceImages()->current();
|
||||
$image->setImagePage( 0, 0, 0, 0 );
|
||||
|
||||
return $image;
|
||||
}
|
||||
|
||||
/**
|
||||
* Center cover-crop an image to exact target dimensions without upscaling.
|
||||
*
|
||||
* Crops the largest centered region matching the target aspect ratio,
|
||||
* then scales DOWN to the target. Refuses (returns false) when the source
|
||||
* is too small to fill the target without upscaling.
|
||||
*
|
||||
* Side effects: mutates $image in place on success.
|
||||
*
|
||||
* @param \Imagick $image Source image (mutated).
|
||||
* @param int $target_w Target width in px.
|
||||
* @param int $target_h Target height in px.
|
||||
*
|
||||
* @return bool False when the crop would require upscaling.
|
||||
*/
|
||||
public static function cover_crop( \Imagick $image, int $target_w, int $target_h ): bool {
|
||||
$width = $image->getImageWidth();
|
||||
$height = $image->getImageHeight();
|
||||
if ( $width < 1 || $height < 1 ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ratio = max( $target_w / $width, $target_h / $height );
|
||||
if ( $ratio > 1.0 ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$src_w = min( $width, (int) round( $target_w / $ratio ) );
|
||||
$src_h = min( $height, (int) round( $target_h / $ratio ) );
|
||||
$x = (int) floor( ( $width - $src_w ) / 2 );
|
||||
$y = (int) floor( ( $height - $src_h ) / 2 );
|
||||
|
||||
$image->cropImage( $src_w, $src_h, $x, $y );
|
||||
$image->setImagePage( 0, 0, 0, 0 );
|
||||
$image->resizeImage( $target_w, $target_h, \Imagick::FILTER_LANCZOS, 1 );
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composite a brand PNG onto the canvas at a target height and opacity.
|
||||
*
|
||||
* @param \Imagick $canvas Destination canvas (mutated).
|
||||
* @param string $asset_path Absolute path to the brand PNG.
|
||||
* @param int $target_h Logo height in px (width keeps aspect).
|
||||
* @param int $x Left position on the canvas.
|
||||
* @param int $y Top position on the canvas.
|
||||
* @param float $opacity 1.0 = opaque, 0.55 = subtle corner mark.
|
||||
*
|
||||
* @return int Rendered logo width in px (useful for caption x-offsets).
|
||||
* @throws \RuntimeException When the asset is missing or unreadable.
|
||||
*/
|
||||
public static function place_logo( \Imagick $canvas, string $asset_path, int $target_h, int $x, int $y, float $opacity = 1.0 ): int {
|
||||
if ( ! is_readable( $asset_path ) ) {
|
||||
throw new \RuntimeException( 'Composer brand asset missing: ' . basename( $asset_path ) );
|
||||
}
|
||||
|
||||
$logo = new \Imagick( $asset_path );
|
||||
$logo->setImagePage( 0, 0, 0, 0 );
|
||||
$scale = $target_h / max( 1, $logo->getImageHeight() );
|
||||
$target_w = max( 1, (int) round( $logo->getImageWidth() * $scale ) );
|
||||
$logo->resizeImage( $target_w, $target_h, \Imagick::FILTER_LANCZOS, 1 );
|
||||
|
||||
if ( $opacity < 1.0 ) {
|
||||
$logo->evaluateImage( \Imagick::EVALUATE_MULTIPLY, max( 0.0, $opacity ), \Imagick::CHANNEL_ALPHA );
|
||||
}
|
||||
|
||||
$canvas->compositeImage( $logo, \Imagick::COMPOSITE_OVER, $x, $y );
|
||||
$logo->clear();
|
||||
|
||||
return $target_w;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a canvas as a deterministic PNG variant payload.
|
||||
*
|
||||
* Strips all metadata and excludes PNG date/time chunks so identical
|
||||
* inputs produce byte-identical output in the same environment.
|
||||
*
|
||||
* Side effects: destroys the Imagick object after encoding.
|
||||
*
|
||||
* @param \Imagick $image Finished canvas.
|
||||
* @param string $role Variant role: 'featured', 'og', or 'square'.
|
||||
*
|
||||
* @return array{bytes: string, mime_type: string, width: int, height: int, role: string}
|
||||
*/
|
||||
public static function finalize_png( \Imagick $image, string $role ): array {
|
||||
$image->setImageFormat( 'png' );
|
||||
$image->setImageDepth( 8 );
|
||||
$image->stripImage();
|
||||
$image->setOption( 'png:exclude-chunks', 'date,time' );
|
||||
$image->setOption( 'png:compression-level', '9' );
|
||||
|
||||
$variant = array(
|
||||
'bytes' => $image->getImageBlob(),
|
||||
'mime_type' => 'image/png',
|
||||
'width' => $image->getImageWidth(),
|
||||
'height' => $image->getImageHeight(),
|
||||
'role' => $role,
|
||||
);
|
||||
$image->clear();
|
||||
|
||||
return $variant;
|
||||
}
|
||||
}
|
||||
114
includes/core/class-image-composer-editor.php
Normal file
114
includes/core/class-image-composer-editor.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Resize-only composer rung backed by wp_get_image_editor() (GD path).
|
||||
*
|
||||
* Used when Imagick is unusable: emits unbranded, caption-free center-crops
|
||||
* at each variant's exact dimensions. Variants that would require upscaling
|
||||
* (e.g. a 1080×1080 square from a 1200×632 base — WP's editor never upscales
|
||||
* on crop-resize) are skipped; the OG crop is always achievable from the
|
||||
* standard base. Output is GD PNG, which embeds no timestamps, so this rung
|
||||
* is deterministic too.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Image_Composer when the capability probe says 'gd'.
|
||||
* Dependencies: wp_get_image_editor(), get_temp_dir(), PRAutoBlogger_Logger.
|
||||
*
|
||||
* @see core/class-image-composer.php — Orchestrator + degradation ladder.
|
||||
* @see core/class-image-composer-imagick.php — The full-fidelity rung this replaces.
|
||||
*/
|
||||
class PRAutoBlogger_Image_Composer_Editor {
|
||||
|
||||
/**
|
||||
* Render resize-only variants for the requested roles.
|
||||
*
|
||||
* Side effects: temp file write/delete per variant; debug log on skips.
|
||||
*
|
||||
* @param string $bytes Base image bytes.
|
||||
* @param string[] $roles Variant roles to attempt ('og', 'square').
|
||||
* @param array<string, array<string, mixed>> $layout Layout geometry (for target dimensions).
|
||||
*
|
||||
* @return array<int, array{bytes: string, mime_type: string, width: int, height: int, role: string}>
|
||||
* Successfully resized variants only (possibly empty).
|
||||
*/
|
||||
public function compose_variants( string $bytes, array $roles, array $layout ): array {
|
||||
$variants = array();
|
||||
|
||||
foreach ( $roles as $role ) {
|
||||
$target_w = (int) ( $layout[ $role ]['width'] ?? 0 );
|
||||
$target_h = (int) ( $layout[ $role ]['height'] ?? 0 );
|
||||
if ( $target_w < 1 || $target_h < 1 ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$resized = $this->cover_crop( $bytes, $target_w, $target_h );
|
||||
if ( null === $resized ) {
|
||||
PRAutoBlogger_Logger::instance()->debug(
|
||||
sprintf( 'Resize-only %s variant skipped (editor failure or would upscale).', $role ),
|
||||
'image_composer'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
$resized['role'] = $role;
|
||||
$variants[] = $resized;
|
||||
}
|
||||
|
||||
return $variants;
|
||||
}
|
||||
|
||||
/**
|
||||
* Center-crop bytes to exact dimensions with the portable WP editor.
|
||||
*
|
||||
* @param string $bytes Source image bytes.
|
||||
* @param int $target_w Target width in px.
|
||||
* @param int $target_h Target height in px.
|
||||
*
|
||||
* @return array{bytes: string, mime_type: string, width: int, height: int}|null
|
||||
* Null when the editor fails or cannot hit the exact size
|
||||
* without upscaling.
|
||||
*/
|
||||
private function cover_crop( string $bytes, int $target_w, int $target_h ): ?array {
|
||||
$temp = get_temp_dir() . uniqid( 'prab_compose_', true ) . '.png';
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- temp file, same pattern as the sideloader.
|
||||
if ( false === file_put_contents( $temp, $bytes ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$editor = wp_get_image_editor( $temp );
|
||||
if ( is_wp_error( $editor ) ) {
|
||||
return null;
|
||||
}
|
||||
if ( is_wp_error( $editor->resize( $target_w, $target_h, true ) ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$size = $editor->get_size();
|
||||
if ( (int) $size['width'] !== $target_w || (int) $size['height'] !== $target_h ) {
|
||||
return null; // Exact size not reachable without upscaling.
|
||||
}
|
||||
|
||||
if ( is_wp_error( $editor->save( $temp, 'image/png' ) ) ) {
|
||||
return null;
|
||||
}
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- temp file we just wrote.
|
||||
$out = file_get_contents( $temp );
|
||||
|
||||
return false === $out ? null : array(
|
||||
'bytes' => $out,
|
||||
'mime_type' => 'image/png',
|
||||
'width' => $target_w,
|
||||
'height' => $target_h,
|
||||
);
|
||||
} catch ( \Throwable $e ) {
|
||||
PRAutoBlogger_Logger::instance()->debug( 'WP image editor crop threw: ' . $e->getMessage(), 'image_composer' );
|
||||
return null;
|
||||
} finally {
|
||||
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort temp cleanup.
|
||||
@unlink( $temp );
|
||||
}
|
||||
}
|
||||
}
|
||||
250
includes/core/class-image-composer-imagick.php
Normal file
250
includes/core/class-image-composer-imagick.php
Normal file
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Imagick renderer for the deterministic image composer (full-fidelity rung).
|
||||
*
|
||||
* Renders the three branded variants from one base image: corner-marked
|
||||
* featured (1200×632), teal-band OG (1200×630), and card-layout square
|
||||
* (1080×1080). No WordPress functions anywhere — layout geometry and asset
|
||||
* directories arrive via the constructor, so the class runs unchanged inside
|
||||
* the standalone render harness that produced the PR samples. Methods throw
|
||||
* on failure; the orchestrator catches and degrades, so a renderer error can
|
||||
* never fail a publish.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Image_Composer when the capability probe says 'imagick'.
|
||||
* Dependencies: ext-imagick, bundled brand PNGs + OFL fonts,
|
||||
* PRAutoBlogger_Image_Composer_Canvas (primitives),
|
||||
* PRAutoBlogger_Image_Composer_Layout (caption clamp).
|
||||
*
|
||||
* @see core/class-image-composer.php — Orchestrator + degradation ladder.
|
||||
* @see core/class-image-composer-canvas.php — Crop/logo/PNG primitives.
|
||||
* @see core/class-image-composer-layout.php — Geometry defaults + clamp.
|
||||
*/
|
||||
class PRAutoBlogger_Image_Composer_Imagick {
|
||||
|
||||
/** @var array<string, array<string, mixed>> Layout geometry keyed by role. */
|
||||
private array $layout;
|
||||
|
||||
/** @var string Absolute path to the vendored brand PNG directory (trailing slash). */
|
||||
private string $brand_dir;
|
||||
|
||||
/** @var string Absolute path to the bundled font directory (trailing slash). */
|
||||
private string $fonts_dir;
|
||||
|
||||
/**
|
||||
* Construct with resolved layout + asset locations.
|
||||
*
|
||||
* @param array<string, array<string, mixed>> $layout Filtered layout (see Layout::defaults()).
|
||||
* @param string $brand_dir Brand PNG directory, trailing slash.
|
||||
* @param string $fonts_dir Font directory, trailing slash.
|
||||
*/
|
||||
public function __construct( array $layout, string $brand_dir, string $fonts_dir ) {
|
||||
$this->layout = $layout;
|
||||
$this->brand_dir = $brand_dir;
|
||||
$this->fonts_dir = $fonts_dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Featured variant: base image untouched except a subtle corner mark.
|
||||
*
|
||||
* @param string $bytes Base image bytes.
|
||||
*
|
||||
* @return array{bytes: string, mime_type: string, width: int, height: int, role: string}
|
||||
* @throws \RuntimeException|\ImagickException On decode/asset failure.
|
||||
*/
|
||||
public function compose_featured( string $bytes ): array {
|
||||
$conf = $this->layout['featured'];
|
||||
$canvas = PRAutoBlogger_Image_Composer_Canvas::load_base( $bytes );
|
||||
|
||||
$mark_h = (int) $conf['mark_height'];
|
||||
$inset = (int) $conf['mark_inset'];
|
||||
// The mark asset is square, so rendered width equals the height.
|
||||
PRAutoBlogger_Image_Composer_Canvas::place_logo(
|
||||
$canvas,
|
||||
$this->brand_dir . $conf['mark_asset'],
|
||||
$mark_h,
|
||||
$canvas->getImageWidth() - $mark_h - $inset,
|
||||
$canvas->getImageHeight() - $mark_h - $inset,
|
||||
(float) $conf['mark_opacity']
|
||||
);
|
||||
|
||||
return PRAutoBlogger_Image_Composer_Canvas::finalize_png( $canvas, 'featured' );
|
||||
}
|
||||
|
||||
/**
|
||||
* OG variant (1200×630): center-cropped base, bottom teal band with the
|
||||
* brand mark and the clamped caption in white.
|
||||
*
|
||||
* @param string $bytes Base image bytes.
|
||||
* @param string $caption Editorial caption (clamped to 2 lines).
|
||||
*
|
||||
* @return array{bytes: string, mime_type: string, width: int, height: int, role: string}
|
||||
* @throws \RuntimeException|\ImagickException On decode/crop/asset failure.
|
||||
*/
|
||||
public function compose_og( string $bytes, string $caption ): array {
|
||||
$conf = $this->layout['og'];
|
||||
$width = (int) $conf['width'];
|
||||
$height = (int) $conf['height'];
|
||||
$band_h = (int) $conf['band_height'];
|
||||
$band_y = $height - $band_h;
|
||||
|
||||
$canvas = PRAutoBlogger_Image_Composer_Canvas::load_base( $bytes );
|
||||
if ( ! PRAutoBlogger_Image_Composer_Canvas::cover_crop( $canvas, $width, $height ) ) {
|
||||
$canvas->clear();
|
||||
throw new \RuntimeException( 'OG variant would require upscaling the base image.' );
|
||||
}
|
||||
|
||||
$this->draw_rect( $canvas, 0, $band_y, $width, $height, (string) $conf['band_color'] );
|
||||
|
||||
$logo_h = (int) $conf['logo_height'];
|
||||
$logo_w = PRAutoBlogger_Image_Composer_Canvas::place_logo(
|
||||
$canvas,
|
||||
$this->brand_dir . $conf['logo_asset'],
|
||||
$logo_h,
|
||||
(int) $conf['logo_x'],
|
||||
$band_y + (int) floor( ( $band_h - $logo_h ) / 2 )
|
||||
);
|
||||
|
||||
$lines = PRAutoBlogger_Image_Composer_Layout::clamp_caption(
|
||||
$caption,
|
||||
(int) $conf['caption_chars_per_line'],
|
||||
(int) $conf['caption_max_lines']
|
||||
);
|
||||
$this->draw_lines(
|
||||
$canvas,
|
||||
$lines,
|
||||
array(
|
||||
'font' => $this->fonts_dir . $conf['caption_font'],
|
||||
'size' => (float) $conf['caption_size'],
|
||||
'color' => (string) $conf['caption_color'],
|
||||
'line_height' => (float) $conf['caption_line_height'],
|
||||
'x' => (int) $conf['logo_x'] + $logo_w + (int) $conf['caption_gap'],
|
||||
'center_y' => $band_y + (int) floor( $band_h / 2 ),
|
||||
'align' => \Imagick::ALIGN_LEFT,
|
||||
)
|
||||
);
|
||||
|
||||
return PRAutoBlogger_Image_Composer_Canvas::finalize_png( $canvas, 'og' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Square variant (1080×1080, card layout, no upscaling): full base
|
||||
* downscaled into the top slice, cream caption panel in Poppins Bold,
|
||||
* teal footer band with the horizontal reverse lockup.
|
||||
*
|
||||
* @param string $bytes Base image bytes.
|
||||
* @param string $caption Editorial caption (clamped to 3 lines).
|
||||
*
|
||||
* @return array{bytes: string, mime_type: string, width: int, height: int, role: string}
|
||||
* @throws \RuntimeException|\ImagickException On decode/crop/asset failure.
|
||||
*/
|
||||
public function compose_square( string $bytes, string $caption ): array {
|
||||
$conf = $this->layout['square'];
|
||||
$width = (int) $conf['width'];
|
||||
$height = (int) $conf['height'];
|
||||
$slice_h = (int) $conf['slice_height'];
|
||||
$footer_h = (int) $conf['footer_height'];
|
||||
$footer_y = $height - $footer_h;
|
||||
|
||||
$slice = PRAutoBlogger_Image_Composer_Canvas::load_base( $bytes );
|
||||
if ( ! PRAutoBlogger_Image_Composer_Canvas::cover_crop( $slice, $width, $slice_h ) ) {
|
||||
$slice->clear();
|
||||
throw new \RuntimeException( 'Square variant would require upscaling the base image.' );
|
||||
}
|
||||
|
||||
$canvas = new \Imagick();
|
||||
$canvas->newImage( $width, $height, new \ImagickPixel( (string) $conf['panel_color'] ), 'png' );
|
||||
$canvas->compositeImage( $slice, \Imagick::COMPOSITE_OVER, 0, 0 );
|
||||
$slice->clear();
|
||||
|
||||
$this->draw_rect( $canvas, 0, $footer_y, $width, $height, (string) $conf['footer_color'] );
|
||||
|
||||
$logo_h = (int) $conf['footer_logo_height'];
|
||||
$logo = new \Imagick( $this->brand_dir . $conf['footer_logo_asset'] );
|
||||
$logo_w = (int) round( $logo->getImageWidth() * ( $logo_h / max( 1, $logo->getImageHeight() ) ) );
|
||||
$logo->clear();
|
||||
PRAutoBlogger_Image_Composer_Canvas::place_logo(
|
||||
$canvas,
|
||||
$this->brand_dir . $conf['footer_logo_asset'],
|
||||
$logo_h,
|
||||
(int) floor( ( $width - $logo_w ) / 2 ),
|
||||
$footer_y + (int) floor( ( $footer_h - $logo_h ) / 2 )
|
||||
);
|
||||
|
||||
$lines = PRAutoBlogger_Image_Composer_Layout::clamp_caption(
|
||||
$caption,
|
||||
(int) $conf['caption_chars_per_line'],
|
||||
(int) $conf['caption_max_lines']
|
||||
);
|
||||
$this->draw_lines(
|
||||
$canvas,
|
||||
$lines,
|
||||
array(
|
||||
'font' => $this->fonts_dir . $conf['caption_font'],
|
||||
'size' => (float) $conf['caption_size'],
|
||||
'color' => (string) $conf['caption_color'],
|
||||
'line_height' => (float) $conf['caption_line_height'],
|
||||
'x' => (int) floor( $width / 2 ),
|
||||
'center_y' => $slice_h + (int) floor( ( $footer_y - $slice_h ) / 2 ),
|
||||
'align' => \Imagick::ALIGN_CENTER,
|
||||
)
|
||||
);
|
||||
|
||||
return PRAutoBlogger_Image_Composer_Canvas::finalize_png( $canvas, 'square' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill an axis-aligned rectangle on the canvas.
|
||||
*
|
||||
* @param \Imagick $canvas Destination canvas (mutated).
|
||||
* @param int $x1 Left.
|
||||
* @param int $y1 Top.
|
||||
* @param int $x2 Right.
|
||||
* @param int $y2 Bottom.
|
||||
* @param string $color Hex fill color.
|
||||
*/
|
||||
private function draw_rect( \Imagick $canvas, int $x1, int $y1, int $x2, int $y2, string $color ): void {
|
||||
$draw = new \ImagickDraw();
|
||||
$draw->setFillColor( new \ImagickPixel( $color ) );
|
||||
$draw->rectangle( (float) $x1, (float) $y1, (float) $x2, (float) $y2 );
|
||||
$canvas->drawImage( $draw );
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw pre-clamped caption lines vertically centered around center_y.
|
||||
*
|
||||
* @param \Imagick $canvas Destination canvas (mutated).
|
||||
* @param string[] $lines Already-clamped lines (may be empty — no-op).
|
||||
* @param array{font: string, size: float, color: string, line_height: float, x: int, center_y: int, align: int} $opts Text options.
|
||||
*
|
||||
* @throws \RuntimeException When the bundled font file is missing.
|
||||
*/
|
||||
private function draw_lines( \Imagick $canvas, array $lines, array $opts ): void {
|
||||
if ( empty( $lines ) ) {
|
||||
return;
|
||||
}
|
||||
if ( ! is_readable( $opts['font'] ) ) {
|
||||
throw new \RuntimeException( 'Composer font missing: ' . basename( $opts['font'] ) );
|
||||
}
|
||||
|
||||
$draw = new \ImagickDraw();
|
||||
$draw->setFont( $opts['font'] );
|
||||
$draw->setFontSize( $opts['size'] );
|
||||
$draw->setFillColor( new \ImagickPixel( $opts['color'] ) );
|
||||
$draw->setTextAlignment( $opts['align'] );
|
||||
$draw->setTextAntialias( true );
|
||||
|
||||
$metrics = $canvas->queryFontMetrics( $draw, 'Hg' );
|
||||
$line_step = (int) round( $opts['size'] * $opts['line_height'] );
|
||||
$block_h = ( count( $lines ) - 1 ) * $line_step + (int) round( $metrics['ascender'] - $metrics['descender'] );
|
||||
$baseline = $opts['center_y'] - (int) floor( $block_h / 2 ) + (int) round( $metrics['ascender'] );
|
||||
|
||||
foreach ( $lines as $line ) {
|
||||
$canvas->annotateImage( $draw, (float) $opts['x'], (float) $baseline, 0.0, $line );
|
||||
$baseline += $line_step;
|
||||
}
|
||||
}
|
||||
}
|
||||
173
includes/core/class-image-composer-layout.php
Normal file
173
includes/core/class-image-composer-layout.php
Normal file
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Geometry defaults and caption clamping for the deterministic image composer.
|
||||
*
|
||||
* Pure functions and constants only — no WordPress, no Imagick. This keeps
|
||||
* the class unit-testable and usable from a standalone render harness. The
|
||||
* orchestrator exposes defaults() through the `prautoblogger_image_compose_layout`
|
||||
* filter; geometry is intentionally NOT a settings-page surface (promote to
|
||||
* settings only if the CEO asks to tune it — see ARCHITECTURE.md #21).
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Image_Composer (orchestrator) at compose time.
|
||||
* Dependencies: mbstring (bundled with WordPress via compat polyfills).
|
||||
*
|
||||
* @see core/class-image-composer.php — Applies the layout filter.
|
||||
* @see core/class-image-composer-imagick.php — Consumes the geometry values.
|
||||
* @see tests/unit/Core/ImageComposerLayoutTest.php — Clamp boundary cases.
|
||||
*/
|
||||
class PRAutoBlogger_Image_Composer_Layout {
|
||||
|
||||
/** Brand tokens (locked — see peptide-repo-brand README). */
|
||||
public const COLOR_TEAL = '#1B8A92';
|
||||
public const COLOR_LIME = '#7FD600';
|
||||
public const COLOR_ORANGE = '#FF8A3D';
|
||||
public const COLOR_INK = '#0D0D0D';
|
||||
public const COLOR_CREAM = '#FAFAF7';
|
||||
|
||||
/** Ellipsis appended when a caption overflows its line budget. */
|
||||
public const ELLIPSIS = '…';
|
||||
|
||||
/**
|
||||
* Default geometry for every composed variant.
|
||||
*
|
||||
* Asset filenames resolve against assets/brand/ and assets/fonts/ in the
|
||||
* plugin; sizes are px. Values were tuned against rendered samples (see
|
||||
* PR #thread 2026-06-image-composer assets).
|
||||
*
|
||||
* @return array<string, array<string, mixed>> Keyed by role: featured, og, square.
|
||||
*/
|
||||
public static function defaults(): array {
|
||||
return array(
|
||||
'featured' => array(
|
||||
'mark_asset' => 'logo-mark-small-56.png',
|
||||
'mark_height' => 28,
|
||||
'mark_inset' => 16,
|
||||
'mark_opacity' => 0.55,
|
||||
),
|
||||
'og' => array(
|
||||
'width' => 1200,
|
||||
'height' => 630,
|
||||
'band_height' => 120,
|
||||
'band_color' => self::COLOR_TEAL,
|
||||
'logo_asset' => 'logo-mark-small-112.png',
|
||||
'logo_height' => 64,
|
||||
'logo_x' => 40,
|
||||
'caption_font' => 'Poppins-SemiBold.ttf',
|
||||
'caption_size' => 32,
|
||||
'caption_color' => '#FFFFFF',
|
||||
'caption_gap' => 24,
|
||||
'caption_margin_right' => 40,
|
||||
'caption_max_lines' => 2,
|
||||
'caption_chars_per_line' => 52,
|
||||
'caption_line_height' => 1.3,
|
||||
),
|
||||
'square' => array(
|
||||
'width' => 1080,
|
||||
'height' => 1080,
|
||||
'slice_height' => 569,
|
||||
'panel_color' => self::COLOR_CREAM,
|
||||
'footer_height' => 96,
|
||||
'footer_color' => self::COLOR_TEAL,
|
||||
'footer_logo_asset' => 'logo-horizontal-reverse-128.png',
|
||||
'footer_logo_height' => 48,
|
||||
'caption_font' => 'Poppins-Bold.ttf',
|
||||
'caption_size' => 48,
|
||||
'caption_color' => self::COLOR_INK,
|
||||
'caption_max_lines' => 3,
|
||||
'caption_chars_per_line' => 36,
|
||||
'caption_side_padding' => 64,
|
||||
'caption_line_height' => 1.3,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp and word-wrap a caption to a hard per-variant budget.
|
||||
*
|
||||
* Whitespace is collapsed, words wrap at word boundaries, words longer
|
||||
* than a full line are hard-split, and overflow beyond max_lines is cut
|
||||
* with a trailing ellipsis. Multibyte-safe (mb_* functions).
|
||||
*
|
||||
* Side effects: none (pure).
|
||||
*
|
||||
* @param string $caption Raw caption text (any length, any script).
|
||||
* @param int $chars_per_line Hard character budget per line (min 1).
|
||||
* @param int $max_lines Maximum number of lines (min 1).
|
||||
*
|
||||
* @return string[] Wrapped lines; empty array when the caption is blank.
|
||||
*/
|
||||
public static function clamp_caption( string $caption, int $chars_per_line, int $max_lines ): array {
|
||||
$chars_per_line = max( 1, $chars_per_line );
|
||||
$max_lines = max( 1, $max_lines );
|
||||
$normalized = trim( (string) preg_replace( '/\s+/u', ' ', $caption ) );
|
||||
|
||||
if ( '' === $normalized ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$lines = array();
|
||||
$line = '';
|
||||
|
||||
foreach ( explode( ' ', $normalized ) as $word ) {
|
||||
// Hard-split words longer than a full line so they cannot overflow.
|
||||
while ( mb_strlen( $word ) > $chars_per_line ) {
|
||||
if ( '' !== $line ) {
|
||||
$lines[] = $line;
|
||||
$line = '';
|
||||
if ( count( $lines ) >= $max_lines ) {
|
||||
return self::ellipsize( $lines, $chars_per_line );
|
||||
}
|
||||
}
|
||||
$lines[] = mb_substr( $word, 0, $chars_per_line );
|
||||
$word = mb_substr( $word, $chars_per_line );
|
||||
if ( count( $lines ) >= $max_lines ) {
|
||||
return self::ellipsize( $lines, $chars_per_line );
|
||||
}
|
||||
}
|
||||
|
||||
$candidate = ( '' === $line ) ? $word : $line . ' ' . $word;
|
||||
if ( mb_strlen( $candidate ) <= $chars_per_line ) {
|
||||
$line = $candidate;
|
||||
continue;
|
||||
}
|
||||
|
||||
$lines[] = $line;
|
||||
$line = $word;
|
||||
if ( count( $lines ) >= $max_lines ) {
|
||||
return self::ellipsize( $lines, $chars_per_line );
|
||||
}
|
||||
}
|
||||
|
||||
if ( '' !== $line ) {
|
||||
$lines[] = $line;
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append an ellipsis to the final line, trimming first if the line is
|
||||
* already at the character budget so the result never exceeds it.
|
||||
*
|
||||
* @param string[] $lines Lines already at the max-line count.
|
||||
* @param int $chars_per_line Hard character budget per line.
|
||||
*
|
||||
* @return string[] Lines with a trailing ellipsis on the last one.
|
||||
*/
|
||||
private static function ellipsize( array $lines, int $chars_per_line ): array {
|
||||
$last_index = count( $lines ) - 1;
|
||||
$last = $lines[ $last_index ];
|
||||
|
||||
if ( mb_strlen( $last ) + 1 > $chars_per_line ) {
|
||||
$last = rtrim( mb_substr( $last, 0, max( 0, $chars_per_line - 1 ) ) );
|
||||
}
|
||||
|
||||
$lines[ $last_index ] = $last . self::ELLIPSIS;
|
||||
|
||||
return $lines;
|
||||
}
|
||||
}
|
||||
298
includes/core/class-image-composer.php
Normal file
298
includes/core/class-image-composer.php
Normal file
@@ -0,0 +1,298 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Orchestrates deterministic image composition with a capability ladder.
|
||||
*
|
||||
* Sits between the provider's raw bytes and the media sideloader. Probes the
|
||||
* environment once (cached in an option keyed by a PHP/extension fingerprint,
|
||||
* so it auto-invalidates when the host changes — Hostinger extension state
|
||||
* has flipped before), then renders per the ladder: (1) Imagick usable →
|
||||
* full branded compositing; (2) GD via WP editor → resize-only variants, no
|
||||
* branding/text, WARNING once; (3) no image library → pass-through, WARNING
|
||||
* once. compose() never throws and always returns a featured-first non-empty
|
||||
* list, so a composition failure can never block a publish. Local CPU only:
|
||||
* $0, no provider call; duration is logged on the image_composer channel.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Image_Pipeline after batch results + NSFW retry,
|
||||
* before sideloading.
|
||||
* Dependencies: Image_Composer_Imagick (rung 1), Image_Composer_Editor (rung 2),
|
||||
* Image_Composer_Canvas (probe), Image_Composer_Layout, Logger.
|
||||
*
|
||||
* @see core/interface-image-composer.php — The contract.
|
||||
* @see core/class-image-pipeline.php — Caller.
|
||||
* @see core/class-image-attacher.php — Persists returned variants.
|
||||
* @see ARCHITECTURE.md — Decision #21.
|
||||
*/
|
||||
class PRAutoBlogger_Image_Composer implements PRAutoBlogger_Image_Composer_Interface {
|
||||
|
||||
/** Option keys (prautoblogger_ prefix keeps the uninstall sweep effective). */
|
||||
public const OPTION_ENABLED = 'prautoblogger_image_compose_enabled';
|
||||
public const OPTION_VARIANTS = 'prautoblogger_image_compose_variants';
|
||||
public const OPTION_MARK_ENABLED = 'prautoblogger_image_featured_mark_enabled';
|
||||
public const OPTION_CAPABILITY = 'prautoblogger_image_compose_capability';
|
||||
|
||||
/** Capability rungs. */
|
||||
public const CAP_IMAGICK = 'imagick';
|
||||
public const CAP_GD = 'gd';
|
||||
public const CAP_NONE = 'none';
|
||||
|
||||
/** @var PRAutoBlogger_Image_Composer_Imagick|null Lazily built; injectable for tests. */
|
||||
private ?PRAutoBlogger_Image_Composer_Imagick $renderer;
|
||||
|
||||
/** @var bool Degradation WARNING fired this run (once per PHP process). */
|
||||
private static bool $warned_this_run = false;
|
||||
|
||||
/**
|
||||
* Construct, optionally injecting a renderer (tests).
|
||||
* @param PRAutoBlogger_Image_Composer_Imagick|null $renderer Optional renderer override.
|
||||
*/
|
||||
public function __construct( ?PRAutoBlogger_Image_Composer_Imagick $renderer = null ) {
|
||||
$this->renderer = $renderer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose final variants for one generated image. See the interface for
|
||||
* the full contract; this implementation never throws.
|
||||
* Side effects: reads options, may cache the capability probe, logs.
|
||||
*
|
||||
* @param array $image_data Provider result (bytes, mime_type, width, height, ...).
|
||||
* @param array $context {post_id, caption, title, slot}.
|
||||
* @return array<int, array{bytes: string, mime_type: string, width: int, height: int, role: string}>
|
||||
*/
|
||||
public function compose( array $image_data, array $context ): array {
|
||||
$passthrough = array( $this->passthrough_variant( $image_data ) );
|
||||
|
||||
if ( '' === $passthrough[0]['bytes'] || '1' !== (string) get_option( self::OPTION_ENABLED, '1' ) ) {
|
||||
return $passthrough;
|
||||
}
|
||||
|
||||
$started = microtime( true );
|
||||
$bytes = $passthrough[0]['bytes'];
|
||||
$capability = $this->resolve_capability();
|
||||
$caption = (string) ( $context['caption'] ?? '' );
|
||||
$roles = ( 'image_a' === (string) ( $context['slot'] ?? 'image_a' ) )
|
||||
? $this->configured_variants()
|
||||
: array();
|
||||
|
||||
if ( self::CAP_IMAGICK === $capability ) {
|
||||
$variants = $this->compose_via_imagick( $bytes, $caption, $roles, $passthrough[0] );
|
||||
} elseif ( self::CAP_GD === $capability ) {
|
||||
$this->warn_once( 'Imagick unavailable — emitting resize-only variants without branding or caption.' );
|
||||
$editor = new PRAutoBlogger_Image_Composer_Editor();
|
||||
$variants = array_merge( $passthrough, $editor->compose_variants( $bytes, $roles, $this->layout() ) );
|
||||
} else {
|
||||
$this->warn_once( 'No usable image library — base image passed through unbranded.' );
|
||||
$variants = $passthrough;
|
||||
}
|
||||
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf(
|
||||
'Composed %d variant(s) for post %d in %dms ($0, local, capability: %s).',
|
||||
count( $variants ),
|
||||
(int) ( $context['post_id'] ?? 0 ),
|
||||
(int) round( ( microtime( true ) - $started ) * 1000 ),
|
||||
$capability
|
||||
),
|
||||
'image_composer'
|
||||
);
|
||||
|
||||
return $variants;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full compositing rung. Each render is individually guarded: a featured
|
||||
* failure degrades to pass-through, a variant failure skips that variant.
|
||||
*
|
||||
* @param string $bytes Base image bytes.
|
||||
* @param string $caption Editorial caption for OG/square.
|
||||
* @param array $roles Variant roles to render ('og', 'square').
|
||||
* @param array $passthrough Pass-through featured variant (fallback).
|
||||
* @return array<int, array<string, mixed>> Featured-first variant list.
|
||||
*/
|
||||
private function compose_via_imagick( string $bytes, string $caption, array $roles, array $passthrough ): array {
|
||||
$variants = array();
|
||||
|
||||
if ( '1' === (string) get_option( self::OPTION_MARK_ENABLED, '1' ) ) {
|
||||
$featured = $this->try_render( 'featured', $bytes, $caption );
|
||||
$variants[] = null === $featured ? $passthrough : $featured;
|
||||
} else {
|
||||
$variants[] = $passthrough;
|
||||
}
|
||||
|
||||
foreach ( $roles as $role ) {
|
||||
$variant = $this->try_render( $role, $bytes, $caption );
|
||||
if ( null !== $variant ) {
|
||||
$variants[] = $variant;
|
||||
}
|
||||
}
|
||||
|
||||
return $variants;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one role via the Imagick renderer. Any throwable or malformed
|
||||
* result (empty bytes) maps to one per-run WARNING + null (caller degrades).
|
||||
* @param string $role 'featured', 'og', or 'square'.
|
||||
* @param string $bytes Base image bytes.
|
||||
* @param string $caption Editorial caption.
|
||||
* @return array<string, mixed>|null Variant payload or null on failure.
|
||||
*/
|
||||
private function try_render( string $role, string $bytes, string $caption ): ?array {
|
||||
try {
|
||||
$renderer = $this->renderer();
|
||||
if ( 'og' === $role ) {
|
||||
$variant = $renderer->compose_og( $bytes, $caption );
|
||||
} elseif ( 'square' === $role ) {
|
||||
$variant = $renderer->compose_square( $bytes, $caption );
|
||||
} else {
|
||||
$variant = $renderer->compose_featured( $bytes );
|
||||
}
|
||||
|
||||
if ( '' === (string) ( $variant['bytes'] ?? '' ) ) {
|
||||
$this->warn_once( sprintf( 'Imagick render for %s variant returned no bytes — degrading.', $role ) );
|
||||
return null;
|
||||
}
|
||||
$variant['role'] = $role;
|
||||
|
||||
return $variant;
|
||||
} catch ( \Throwable $e ) {
|
||||
$this->warn_once( sprintf( 'Imagick render failed for %s variant: %s', $role, $e->getMessage() ) );
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the capability rung: cached probe + override filter. The
|
||||
* same-named filter lets tests/ops force a rung (e.g. 'none').
|
||||
*
|
||||
* @return string One of the CAP_* constants.
|
||||
*/
|
||||
private function resolve_capability(): string {
|
||||
$fingerprint = md5(
|
||||
PHP_VERSION . '|' . ( extension_loaded( 'imagick' ) ? 'im1' : 'im0' ) . '|' . ( extension_loaded( 'gd' ) ? 'gd1' : 'gd0' )
|
||||
);
|
||||
|
||||
$cached = get_option( self::OPTION_CAPABILITY, array() );
|
||||
if ( is_array( $cached ) && ( $cached['fingerprint'] ?? '' ) === $fingerprint && isset( $cached['capability'] ) ) {
|
||||
$capability = (string) $cached['capability'];
|
||||
} else {
|
||||
$capability = $this->probe_capability();
|
||||
update_option(
|
||||
self::OPTION_CAPABILITY,
|
||||
array(
|
||||
'fingerprint' => $fingerprint,
|
||||
'capability' => $capability,
|
||||
),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
$capability = (string) apply_filters( 'prautoblogger_image_compose_capability', $capability );
|
||||
|
||||
return in_array( $capability, array( self::CAP_IMAGICK, self::CAP_GD, self::CAP_NONE ), true )
|
||||
? $capability
|
||||
: self::CAP_NONE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the host for the best available rung (uncached).
|
||||
* @return string One of the CAP_* constants.
|
||||
*/
|
||||
private function probe_capability(): string {
|
||||
if ( PRAutoBlogger_Image_Composer_Canvas::is_imagick_usable( $this->fonts_dir() . 'Poppins-Bold.ttf' ) ) {
|
||||
return self::CAP_IMAGICK;
|
||||
}
|
||||
|
||||
$png_crop = array(
|
||||
'mime_type' => 'image/png',
|
||||
'methods' => array( 'resize', 'crop' ),
|
||||
);
|
||||
if ( function_exists( 'wp_image_editor_supports' ) && wp_image_editor_supports( $png_crop ) ) {
|
||||
return self::CAP_GD;
|
||||
}
|
||||
|
||||
return self::CAP_NONE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily build the Imagick renderer with filtered layout + asset paths.
|
||||
* @return PRAutoBlogger_Image_Composer_Imagick
|
||||
*/
|
||||
private function renderer(): PRAutoBlogger_Image_Composer_Imagick {
|
||||
if ( null === $this->renderer ) {
|
||||
$this->renderer = new PRAutoBlogger_Image_Composer_Imagick(
|
||||
$this->layout(),
|
||||
dirname( __DIR__, 2 ) . '/assets/brand/',
|
||||
$this->fonts_dir()
|
||||
);
|
||||
}
|
||||
return $this->renderer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout geometry: pure defaults behind the documented override filter.
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
private function layout(): array {
|
||||
return (array) apply_filters( 'prautoblogger_image_compose_layout', PRAutoBlogger_Image_Composer_Layout::defaults() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundled font directory (path-derived, no constants — unit-test safe).
|
||||
* @return string Absolute path with trailing slash.
|
||||
*/
|
||||
private function fonts_dir(): string {
|
||||
return dirname( __DIR__, 2 ) . '/assets/fonts/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Variant roles from settings, whitelisted at point of use.
|
||||
* @return string[] Subset of ['og', 'square'], deduplicated, in config order.
|
||||
*/
|
||||
private function configured_variants(): array {
|
||||
$raw = (string) get_option( self::OPTION_VARIANTS, 'og,square' );
|
||||
$roles = array();
|
||||
foreach ( explode( ',', $raw ) as $piece ) {
|
||||
$piece = strtolower( trim( $piece ) );
|
||||
if ( in_array( $piece, array( 'og', 'square' ), true ) && ! in_array( $piece, $roles, true ) ) {
|
||||
$roles[] = $piece;
|
||||
}
|
||||
}
|
||||
return $roles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the pass-through featured variant from the provider result.
|
||||
* @param array $image_data Provider result.
|
||||
* @return array{bytes: string, mime_type: string, width: int, height: int, role: string}
|
||||
*/
|
||||
private function passthrough_variant( array $image_data ): array {
|
||||
return array(
|
||||
'bytes' => (string) ( $image_data['bytes'] ?? '' ),
|
||||
'mime_type' => (string) ( $image_data['mime_type'] ?? 'image/png' ),
|
||||
'width' => (int) ( $image_data['width'] ?? 0 ),
|
||||
'height' => (int) ( $image_data['height'] ?? 0 ),
|
||||
'role' => 'featured',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a degradation WARNING once per run (one row is enough for ops).
|
||||
* @param string $message Warning text.
|
||||
*/
|
||||
private function warn_once( string $message ): void {
|
||||
if ( self::$warned_this_run ) {
|
||||
return;
|
||||
}
|
||||
self::$warned_this_run = true;
|
||||
PRAutoBlogger_Logger::instance()->warning( $message, 'image_composer' );
|
||||
}
|
||||
|
||||
/** Reset per-run state (unit tests only). */
|
||||
public static function reset_run_state(): void {
|
||||
self::$warned_this_run = false;
|
||||
}
|
||||
}
|
||||
@@ -36,11 +36,13 @@ class PRAutoBlogger_Image_Media_Sideloader {
|
||||
* latency_ms: int,
|
||||
* } $image_data Raw image bytes + metadata from PRAutoBlogger_Image_Provider_Interface.
|
||||
* @param int $post_id Target post ID to attach the image to.
|
||||
* @param string $alt_text Alt text for the image (from the generation prompt).
|
||||
* @param string $alt_text Alt text for the image (the editorial caption).
|
||||
* @param string $filename_suffix Optional role hint appended to the filename
|
||||
* (e.g. 'og', 'square' for composed variants).
|
||||
*
|
||||
* @return int|\WP_Error Attachment ID on success, WP_Error on failure.
|
||||
*/
|
||||
public function sideload_image( array $image_data, int $post_id, string $alt_text = '' ) {
|
||||
public function sideload_image( array $image_data, int $post_id, string $alt_text = '', string $filename_suffix = '' ) {
|
||||
// WordPress media functions live in wp-admin/includes/ and are NOT
|
||||
// loaded automatically in cron or REST contexts. Load them if missing.
|
||||
// Why: WP-Cron runs as a frontend request, so admin-only files like
|
||||
@@ -64,7 +66,7 @@ class PRAutoBlogger_Image_Media_Sideloader {
|
||||
|
||||
// Prepare file array for media_handle_sideload().
|
||||
$file_array = array(
|
||||
'name' => $this->generate_filename( $image_data['model'], $image_data['width'], $image_data['height'] ),
|
||||
'name' => $this->generate_filename( $image_data['model'], $image_data['width'], $image_data['height'], $filename_suffix ),
|
||||
'tmp_name' => $temp_file,
|
||||
);
|
||||
|
||||
@@ -135,16 +137,18 @@ class PRAutoBlogger_Image_Media_Sideloader {
|
||||
* @param string $model Model name (e.g., 'flux-1-schnell').
|
||||
* @param int $width Image width.
|
||||
* @param int $height Image height.
|
||||
* @param string $suffix Optional role hint (e.g. 'og', 'square'); back-compat default ''.
|
||||
*
|
||||
* @return string Sanitized filename.
|
||||
*/
|
||||
private function generate_filename( string $model, int $width, int $height ): string {
|
||||
private function generate_filename( string $model, int $width, int $height, string $suffix = '' ): string {
|
||||
$base = sprintf(
|
||||
'prautoblogger_%s_%dx%d_%s',
|
||||
'prautoblogger_%s_%dx%d_%s%s',
|
||||
sanitize_title( $model ),
|
||||
$width,
|
||||
$height,
|
||||
gmdate( 'Y-m-d_H-i-s' )
|
||||
gmdate( 'Y-m-d_H-i-s' ),
|
||||
'' !== $suffix ? '_' . sanitize_title( $suffix ) : ''
|
||||
);
|
||||
|
||||
return $base . '.png'; // Default to PNG — most image models output PNG.
|
||||
|
||||
@@ -8,13 +8,17 @@ declare(strict_types=1);
|
||||
*
|
||||
* Generates two images per article (A/B): Image A (article-driven) as featured
|
||||
* image, Image B (source-driven) in post meta. Each is independently fallible.
|
||||
* Since v0.17.0 every result passes through the deterministic image composer
|
||||
* (branded variants for A, corner mark for B) before sideloading; composition
|
||||
* degrades gracefully and never blocks a publish.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Pipeline_Runner after publisher creates the post.
|
||||
* Dependencies: Image provider, Prompt builder, Sideloader, Cost tracker, Logger.
|
||||
* Dependencies: Image provider, Prompt builder, Composer, Attacher, Cost tracker, Logger.
|
||||
*
|
||||
* @see core/class-pipeline-runner.php — Calls this after publisher.
|
||||
* @see core/class-image-prompt-builder.php — Prompt generation (scene + caption).
|
||||
* @see core/class-image-media-sideloader.php — Media library integration.
|
||||
* @see core/class-image-composer.php — Deterministic variant composition.
|
||||
* @see core/class-image-attacher.php — Sideloading + variant meta-linking.
|
||||
*/
|
||||
class PRAutoBlogger_Image_Pipeline {
|
||||
|
||||
@@ -34,26 +38,35 @@ class PRAutoBlogger_Image_Pipeline {
|
||||
private PRAutoBlogger_Image_Media_Sideloader $sideloader;
|
||||
/** @var PRAutoBlogger_Cost_Tracker Logs image generation spend. */
|
||||
private PRAutoBlogger_Cost_Tracker $cost_tracker;
|
||||
/** @var PRAutoBlogger_Image_Composer_Interface Composes branded variants pre-sideload. */
|
||||
private PRAutoBlogger_Image_Composer_Interface $composer;
|
||||
/** @var PRAutoBlogger_Image_Attacher Sideloads + meta-links results and variants. */
|
||||
private PRAutoBlogger_Image_Attacher $attacher;
|
||||
/** @var PRAutoBlogger_Opik_Trace_Context|null Optional tracing context. */
|
||||
private ?PRAutoBlogger_Opik_Trace_Context $trace_context;
|
||||
|
||||
/**
|
||||
* Construct with dependencies.
|
||||
*
|
||||
* When no provider is injected, the constructor reads the
|
||||
* `prautoblogger_image_provider` setting and instantiates the
|
||||
* matching concrete class ('runware' or 'openrouter').
|
||||
* Construct with dependencies. When no provider is injected, the
|
||||
* `prautoblogger_image_provider` setting picks the concrete class
|
||||
* ('runware' or 'openrouter').
|
||||
*
|
||||
* @param PRAutoBlogger_Image_Provider_Interface|null $provider Optional provider override.
|
||||
* @param PRAutoBlogger_Cost_Tracker|null $cost_tracker Optional cost tracker.
|
||||
* @param PRAutoBlogger_Opik_Trace_Context|null $trace_context Optional tracing context.
|
||||
* @param PRAutoBlogger_Image_Composer_Interface|null $composer Optional composer override.
|
||||
*/
|
||||
public function __construct(
|
||||
?PRAutoBlogger_Image_Provider_Interface $provider = null,
|
||||
?PRAutoBlogger_Cost_Tracker $cost_tracker = null,
|
||||
?PRAutoBlogger_Opik_Trace_Context $trace_context = null
|
||||
?PRAutoBlogger_Opik_Trace_Context $trace_context = null,
|
||||
?PRAutoBlogger_Image_Composer_Interface $composer = null
|
||||
) {
|
||||
$this->provider = $provider ?? self::create_default_provider();
|
||||
$this->prompt_builder = new PRAutoBlogger_Image_Prompt_Builder( $trace_context );
|
||||
$this->sideloader = new PRAutoBlogger_Image_Media_Sideloader();
|
||||
$this->cost_tracker = $cost_tracker ?? new PRAutoBlogger_Cost_Tracker();
|
||||
$this->composer = $composer ?? new PRAutoBlogger_Image_Composer();
|
||||
$this->attacher = new PRAutoBlogger_Image_Attacher( $this->sideloader, $this->cost_tracker );
|
||||
$this->trace_context = $trace_context;
|
||||
}
|
||||
|
||||
@@ -112,6 +125,16 @@ 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(
|
||||
@@ -138,23 +161,33 @@ 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 ) )
|
||||
->retry_blocked_slots( $post_id, $batch_requests, $batch_results, $captions, $article_data, $source_data );
|
||||
}
|
||||
|
||||
$title = (string) ( $article_data['post_title'] ?? $article_data['suggested_title'] ?? '' );
|
||||
|
||||
// Process Image A result.
|
||||
$this->process_image_a( $post_id, $batch_results, $captions, $result );
|
||||
$this->process_slot( 'image_a', $post_id, $batch_results, $captions, $title, $result );
|
||||
|
||||
// Process Image B result (if requested).
|
||||
if ( isset( $batch_results['image_b'] ) ) {
|
||||
$this->process_image_b( $post_id, $batch_results, $captions, $result );
|
||||
$this->process_slot( 'image_b', $post_id, $batch_results, $captions, $title, $result );
|
||||
} elseif ( null === $source_data || empty( $source_data ) ) {
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf( 'Image B skipped for post %d: no source data.', $post_id ),
|
||||
@@ -166,142 +199,98 @@ class PRAutoBlogger_Image_Pipeline {
|
||||
}
|
||||
|
||||
/**
|
||||
* Process Image A batch result: sideload, set featured image, insert caption.
|
||||
* Process one slot's batch result: compose, sideload, link to the post.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param array $batch_results Keyed results from generate_image_batch().
|
||||
* @param array $captions Keyed captions from prompt builder.
|
||||
* @param array $result Pipeline result array (modified by reference).
|
||||
* Image A becomes the featured image (role meta, caption prepend, and
|
||||
* composed OG/square variants meta-linked); Image B is stored in post
|
||||
* meta with a corner mark only. Alt text is the editorial caption
|
||||
* (v0.17.0 fix — the model name used to leak here).
|
||||
*
|
||||
* @param string $slot 'image_a' or 'image_b'.
|
||||
* @param int $post_id Post ID.
|
||||
* @param array $batch_results Keyed results from generate_image_batch().
|
||||
* @param array $captions Keyed captions from prompt builder.
|
||||
* @param string $title Post title (composer context).
|
||||
* @param array $result Pipeline result array (modified by reference).
|
||||
*/
|
||||
private function process_image_a( int $post_id, array $batch_results, array $captions, array &$result ): void {
|
||||
$image_data = $batch_results['image_a'] ?? null;
|
||||
private function process_slot( string $slot, int $post_id, array $batch_results, array $captions, string $title, array &$result ): void {
|
||||
$label = 'image_a' === $slot ? 'Image A' : 'Image B';
|
||||
$image_data = $batch_results[ $slot ] ?? null;
|
||||
|
||||
if ( ! $image_data || isset( $image_data['error'] ) ) {
|
||||
$err_msg = $image_data['error'] ?? 'Image A missing from batch results.';
|
||||
$err_msg = $image_data['error'] ?? sprintf( '%s missing from batch results.', $label );
|
||||
$result['errors'][] = $err_msg;
|
||||
// Log at WARNING so the path is visible even if the outer catch in the
|
||||
// caller doesn't fire. See thread image-mime-bug Issue 2.
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf( 'Image A generation failed for post %d: %s', $post_id, $err_msg ),
|
||||
sprintf( '%s generation failed for post %d: %s', $label, $post_id, $err_msg ),
|
||||
'image_pipeline'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
$attachment_id = $this->sideload_and_log( $image_data, $post_id, 'image_a', 'Image A' );
|
||||
$caption = (string) ( $captions[ $slot ] ?? '' );
|
||||
$variants = $this->compose_variants( $image_data, $post_id, $caption, $title, $slot );
|
||||
$featured = array_merge( $image_data, array_shift( $variants ) );
|
||||
|
||||
$attachment_id = $this->attacher->sideload_and_log( $featured, $post_id, $slot, $label, $caption );
|
||||
if ( is_wp_error( $attachment_id ) ) {
|
||||
$result['errors'][] = $attachment_id->get_error_message();
|
||||
return;
|
||||
}
|
||||
|
||||
$result['image_a_id'] = $attachment_id;
|
||||
$result['cost_usd'] += $image_data['cost_usd'];
|
||||
set_post_thumbnail( $post_id, $attachment_id );
|
||||
$result[ $slot . '_id' ] = $attachment_id;
|
||||
$result['cost_usd'] += $image_data['cost_usd'];
|
||||
|
||||
if ( '' !== ( $captions['image_a'] ?? '' ) ) {
|
||||
update_post_meta( $attachment_id, '_prautoblogger_image_caption', $captions['image_a'] );
|
||||
$this->prepend_caption_to_post( $post_id, $attachment_id, $captions['image_a'] );
|
||||
if ( '' !== $caption ) {
|
||||
update_post_meta( $attachment_id, '_prautoblogger_image_caption', $caption );
|
||||
}
|
||||
|
||||
if ( 'image_a' === $slot ) {
|
||||
set_post_thumbnail( $post_id, $attachment_id );
|
||||
update_post_meta( $attachment_id, '_prautoblogger_image_role', 'featured' );
|
||||
if ( '' !== $caption ) {
|
||||
$this->attacher->prepend_caption_to_post( $post_id, $attachment_id, $caption );
|
||||
}
|
||||
$this->attacher->attach_variants( $variants, $post_id, $attachment_id, $caption, (string) ( $image_data['model'] ?? 'unknown' ) );
|
||||
} else {
|
||||
update_post_meta( $post_id, '_prautoblogger_image_b_id', $attachment_id );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process Image B batch result: sideload, store meta.
|
||||
* Run the composer with a hard safety net: any failure (composer bugs
|
||||
* included — its own internals already degrade) falls back to passing
|
||||
* the base image through, so composition can never fail a publish.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param array $batch_results Keyed results from generate_image_batch().
|
||||
* @param array $captions Keyed captions from prompt builder.
|
||||
* @param array $result Pipeline result array (modified by reference).
|
||||
* @param array $image_data Provider result for one slot.
|
||||
* @param int $post_id Post ID (composer context).
|
||||
* @param string $caption Editorial caption (composer context).
|
||||
* @param string $title Post title (composer context).
|
||||
* @param string $slot 'image_a' or 'image_b'.
|
||||
* @return array<int, array<string, mixed>> Featured-first variant list (never empty).
|
||||
*/
|
||||
private function process_image_b( int $post_id, array $batch_results, array $captions, array &$result ): void {
|
||||
$image_data = $batch_results['image_b'] ?? null;
|
||||
if ( ! $image_data || isset( $image_data['error'] ) ) {
|
||||
$err_msg = $image_data['error'] ?? 'Image B missing from batch results.';
|
||||
$result['errors'][] = $err_msg;
|
||||
// Log at WARNING so the path is visible even if the outer catch in the
|
||||
// caller doesn't fire. See thread image-mime-bug Issue 2.
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf( 'Image B generation failed for post %d: %s', $post_id, $err_msg ),
|
||||
'image_pipeline'
|
||||
private function compose_variants( array $image_data, int $post_id, string $caption, string $title, string $slot ): array {
|
||||
try {
|
||||
$variants = $this->composer->compose(
|
||||
$image_data,
|
||||
array(
|
||||
'post_id' => $post_id,
|
||||
'caption' => $caption,
|
||||
'title' => $title,
|
||||
'slot' => $slot,
|
||||
)
|
||||
);
|
||||
if ( ! empty( $variants ) ) {
|
||||
return $variants;
|
||||
}
|
||||
} catch ( \Throwable $e ) {
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
'Image composition failed, publishing original image: ' . $e->getMessage(),
|
||||
'image_composer'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
$attachment_id = $this->sideload_and_log( $image_data, $post_id, 'image_b', 'Image B' );
|
||||
if ( is_wp_error( $attachment_id ) ) {
|
||||
$result['errors'][] = $attachment_id->get_error_message();
|
||||
return;
|
||||
}
|
||||
|
||||
$result['image_b_id'] = $attachment_id;
|
||||
$result['cost_usd'] += $image_data['cost_usd'];
|
||||
update_post_meta( $post_id, '_prautoblogger_image_b_id', $attachment_id );
|
||||
|
||||
if ( '' !== ( $captions['image_b'] ?? '' ) ) {
|
||||
update_post_meta( $attachment_id, '_prautoblogger_image_caption', $captions['image_b'] );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sideload image bytes into the media library and log the cost.
|
||||
*
|
||||
* @param array $image_data Provider result with bytes, model, cost_usd.
|
||||
* @param int $post_id Post ID.
|
||||
* @param string $slot Cost-tracking slot ('image_a' or 'image_b').
|
||||
* @param string $label Human-readable label for logs.
|
||||
* @return int|\WP_Error Attachment ID on success.
|
||||
*/
|
||||
private function sideload_and_log( array $image_data, int $post_id, string $slot, string $label ) {
|
||||
$attachment_id = $this->sideloader->sideload_image(
|
||||
$image_data,
|
||||
$post_id,
|
||||
substr( $image_data['model'] ?? 'image', 0, 100 )
|
||||
);
|
||||
|
||||
if ( is_wp_error( $attachment_id ) ) {
|
||||
return $attachment_id;
|
||||
}
|
||||
|
||||
$this->cost_tracker->log_image_generation(
|
||||
$image_data['cost_usd'],
|
||||
$image_data['model'] ?? 'unknown',
|
||||
$post_id,
|
||||
$slot
|
||||
);
|
||||
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf( '%s for post %d (att %d, $%.4f)', $label, $post_id, $attachment_id, $image_data['cost_usd'] ),
|
||||
'image_pipeline'
|
||||
);
|
||||
|
||||
return $attachment_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend the comic caption as styled text at the top of the post content.
|
||||
*
|
||||
* Only inserts the caption — NOT the image. The theme already displays the
|
||||
* featured image, so embedding it again would cause a duplicate.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param int $attachment_id Featured image attachment ID (for meta storage only).
|
||||
* @param string $caption Caption text (the comic punchline).
|
||||
*/
|
||||
private function prepend_caption_to_post( int $post_id, int $attachment_id, string $caption ): void {
|
||||
$post = get_post( $post_id );
|
||||
if ( ! $post ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Caption-only block — the theme handles the featured image display.
|
||||
$caption_html = sprintf(
|
||||
'<p class="prautoblogger-comic-caption" style="text-align:center;font-style:italic;color:#555;font-size:1.1em;margin:0 0 2em 0;">— "%s"</p>',
|
||||
esc_html( $caption )
|
||||
);
|
||||
|
||||
wp_update_post(
|
||||
array(
|
||||
'ID' => $post_id,
|
||||
'post_content' => $caption_html . "\n\n" . $post->post_content,
|
||||
)
|
||||
);
|
||||
return array( array_merge( $image_data, array( 'role' => 'featured' ) ) );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,6 +200,7 @@ PROMPT;
|
||||
array(
|
||||
'temperature' => 0.7,
|
||||
'max_tokens' => self::REWRITER_MAX_TOKENS,
|
||||
'stage' => 'image_prompt_rewrite',
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
98
includes/core/class-pipeline-runner.php
Executable file → Normal file
98
includes/core/class-pipeline-runner.php
Executable file → Normal file
@@ -65,6 +65,7 @@ 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,
|
||||
@@ -84,7 +85,8 @@ 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() );
|
||||
$this->log_summary( $result );
|
||||
PRAutoBlogger_Pipeline_Status::log_summary( $result );
|
||||
PRAutoBlogger_Run_State::mark_status( (string) $cost_tracker->get_run_id(), 'done' );
|
||||
}
|
||||
|
||||
return $result;
|
||||
@@ -115,6 +117,19 @@ 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;
|
||||
}
|
||||
@@ -140,15 +155,16 @@ class PRAutoBlogger_Pipeline_Runner {
|
||||
|
||||
if ( ! empty( $queue['ideas'] ) ) {
|
||||
update_option( self::QUEUE_KEY, $queue, false );
|
||||
$this->update_queue_status( $queue );
|
||||
PRAutoBlogger_Pipeline_Status::update_queue_progress( $queue );
|
||||
$this->schedule_next();
|
||||
} else {
|
||||
// All articles done — amortize shared research costs across them.
|
||||
PRAutoBlogger_Post_Assembler::amortize_research_costs( $queue['run_id'] );
|
||||
$this->write_final_status( $queue['results'] );
|
||||
PRAutoBlogger_Pipeline_Status::write_final( $queue['results'] );
|
||||
delete_option( self::QUEUE_KEY );
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
$this->log_summary( $queue['results'] );
|
||||
PRAutoBlogger_Pipeline_Status::log_summary( $queue['results'] );
|
||||
PRAutoBlogger_Run_State::mark_status( (string) $queue['run_id'], 'done' );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,20 +179,26 @@ 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". */
|
||||
$this->broadcast_stage( sprintf( __( 'Collecting sources from %s…', 'prautoblogger' ), $source_labels ) );
|
||||
PRAutoBlogger_Pipeline_Status::broadcast( sprintf( __( 'Collecting sources from %s…', 'prautoblogger' ), $source_labels ) );
|
||||
PRAutoBlogger_Run_Stage_State::start( $run_id, 'research' );
|
||||
( new PRAutoBlogger_Source_Collector() )
|
||||
->set_cost_tracker( $cost_tracker )
|
||||
->collect_from_all_sources();
|
||||
PRAutoBlogger_Run_Stage_State::done( $run_id, 'research' );
|
||||
|
||||
$this->broadcast_stage( __( 'Analyzing topics and scoring…', 'prautoblogger' ) );
|
||||
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Analyzing topics and scoring…', 'prautoblogger' ) );
|
||||
PRAutoBlogger_Run_Stage_State::start( $run_id, 'analysis' );
|
||||
$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' );
|
||||
|
||||
$this->broadcast_stage( __( 'Selecting best topic…', 'prautoblogger' ) );
|
||||
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Selecting best topic…', 'prautoblogger' ) );
|
||||
$scorer = new PRAutoBlogger_Idea_Scorer();
|
||||
$scorer->set_skip_dedup( $this->skip_dedup );
|
||||
$ideas = $scorer->score_and_rank( $analysis, $target );
|
||||
@@ -211,7 +233,7 @@ class PRAutoBlogger_Pipeline_Runner {
|
||||
);
|
||||
|
||||
update_option( self::QUEUE_KEY, $queue, false );
|
||||
$this->update_queue_status( $queue );
|
||||
PRAutoBlogger_Pipeline_Status::update_queue_progress( $queue );
|
||||
$this->schedule_next();
|
||||
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
@@ -236,71 +258,13 @@ 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'] ) ) {
|
||||
$this->write_final_status( $queue['results'] );
|
||||
PRAutoBlogger_Pipeline_Status::write_final( $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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
106
includes/core/class-pipeline-status.php
Normal file
106
includes/core/class-pipeline-status.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?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'
|
||||
);
|
||||
}
|
||||
}
|
||||
147
includes/core/class-prompt-defaults-editorial.php
Normal file
147
includes/core/class-prompt-defaults-editorial.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
210
includes/core/class-prompt-defaults.php
Normal file
210
includes/core/class-prompt-defaults.php
Normal file
@@ -0,0 +1,210 @@
|
||||
<?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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
143
includes/core/class-prompt-registry-writer.php
Normal file
143
includes/core/class-prompt-registry-writer.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
214
includes/core/class-prompt-registry.php
Normal file
214
includes/core/class-prompt-registry.php
Normal file
@@ -0,0 +1,214 @@
|
||||
<?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
Executable file → Normal file
74
includes/core/class-publisher.php
Executable file → Normal file
@@ -82,6 +82,39 @@ 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.
|
||||
@@ -94,7 +127,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 ),
|
||||
'meta_input' => $this->build_meta( $idea, $review, $run_id, $idea_hash ),
|
||||
);
|
||||
|
||||
/** @see class-prautoblogger.php — listeners registered in main loader. */
|
||||
@@ -140,7 +173,8 @@ class PRAutoBlogger_Publisher {
|
||||
private function build_meta(
|
||||
PRAutoBlogger_Article_Idea $idea,
|
||||
PRAutoBlogger_Editorial_Review $review,
|
||||
?string $run_id = null
|
||||
?string $run_id = null,
|
||||
string $idea_hash = ''
|
||||
): array {
|
||||
$meta = array(
|
||||
'_prautoblogger_generated' => '1',
|
||||
@@ -160,6 +194,42 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
59
includes/core/class-run-context.php
Normal file
59
includes/core/class-run-context.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
270
includes/core/class-run-reaper.php
Normal file
270
includes/core/class-run-reaper.php
Normal file
@@ -0,0 +1,270 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
293
includes/core/class-run-stage-state.php
Normal file
293
includes/core/class-run-stage-state.php
Normal file
@@ -0,0 +1,293 @@
|
||||
<?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`.
|
||||
*
|
||||
* What: One row per (run_id, stage, agent_role, item_key) holding
|
||||
* pending|running|done|failed|halted plus the stage's output
|
||||
* snapshot (meta_json). This is what makes resume idempotent on the
|
||||
* chained-cron architecture: a `done` stage is NEVER re-run or
|
||||
* re-charged — re-entry reuses its stored output; `done` is sticky
|
||||
* (an upsert cannot demote it). agent_role is the Phase-2 fan-out
|
||||
* dimension (quorum members; the quorum logic itself is Phase 2);
|
||||
* item_key scopes article-level stages because one run_id spans all
|
||||
* N articles of a batch run ('' for run-level stages). Self-healing:
|
||||
* a missing table degrades every method to a no-op/false.
|
||||
* Who triggers it: Pipeline_Runner (run-level research/analysis),
|
||||
* Article_Worker (per-article generate/review/publish),
|
||||
* Content_Generator (per-LLM-stage outline/draft/polish),
|
||||
* Run_Reaper (stuck-stage sweep + payload pruning).
|
||||
* Dependencies: WordPress $wpdb, wp_json_encode.
|
||||
*
|
||||
* @see core/class-run-state.php — Run-level lifecycle + ledger row.
|
||||
* @see core/class-run-reaper.php — Sweeps stages stuck in 'running'.
|
||||
* @see ARCHITECTURE.md #21 — Idempotency-key design.
|
||||
*/
|
||||
class PRAutoBlogger_Run_Stage_State {
|
||||
|
||||
/** @var bool|null Per-request "run_stages table exists" probe result. */
|
||||
private static ?bool $table_ok = null;
|
||||
|
||||
/** Fully-qualified run_stages table name. */
|
||||
public static function table_name(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . 'prautoblogger_run_stages';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the run_stages table exists and is queryable. Cached per
|
||||
* request; never throws.
|
||||
*
|
||||
* @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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable hash identifying one article idea within a run (post-creation
|
||||
* idempotency + stage item scoping both key off this).
|
||||
*
|
||||
* @param PRAutoBlogger_Article_Idea $idea The idea.
|
||||
* @return string 16-hex-char hash.
|
||||
*/
|
||||
public static function idea_hash( PRAutoBlogger_Article_Idea $idea ): string {
|
||||
return substr( md5( $idea->get_suggested_title() . '|' . $idea->get_topic() ), 0, 16 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage item_key for an article idea ('' is reserved for run-level stages).
|
||||
*
|
||||
* @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 entered (running). Upsert: first entry inserts the
|
||||
* row; re-entry bumps `attempt` — but a `done` stage is sticky and is
|
||||
* neither demoted nor re-counted.
|
||||
*
|
||||
* Side effects: one INSERT … ON DUPLICATE KEY UPDATE.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $stage Stage name (see Stage_Display_Map).
|
||||
* @param string $agent_role Fan-out dimension ('' default in Phase 1).
|
||||
* @param string $item_key Article scope ('' for run-level stages).
|
||||
* @return void
|
||||
*/
|
||||
public static function start( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): void {
|
||||
if ( '' === $run_id || ! self::is_available() ) {
|
||||
return;
|
||||
}
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
$now = current_time( 'mysql' );
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"INSERT INTO {$table}
|
||||
(run_id, stage, agent_role, item_key, status, attempt, started_at, updated_at)
|
||||
VALUES (%s, %s, %s, %s, 'running', 1, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
attempt = IF(status = 'done', attempt, attempt + 1),
|
||||
status = IF(status = 'done', 'done', 'running'),
|
||||
updated_at = VALUES(updated_at)",
|
||||
$run_id,
|
||||
$stage,
|
||||
$agent_role,
|
||||
$item_key,
|
||||
$now,
|
||||
$now
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a stage done, persisting its output for resume-without-recharge.
|
||||
*
|
||||
* Side effects: one INSERT … ON DUPLICATE KEY UPDATE.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $stage Stage name.
|
||||
* @param string $agent_role Fan-out dimension.
|
||||
* @param string $item_key Article scope.
|
||||
* @param string|null $output Stage output to snapshot (pruned after R days).
|
||||
* @param float $cost_usd Cost attributed to the stage.
|
||||
* @return void
|
||||
*/
|
||||
public static function done( string $run_id, string $stage, string $agent_role = '', string $item_key = '', ?string $output = null, float $cost_usd = 0.0 ): void {
|
||||
if ( '' === $run_id || ! self::is_available() ) {
|
||||
return;
|
||||
}
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
$now = current_time( 'mysql' );
|
||||
$meta = null !== $output ? wp_json_encode( array( 'output' => $output ) ) : null;
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"INSERT INTO {$table}
|
||||
(run_id, stage, agent_role, item_key, status, attempt, cost_usd, meta_json, started_at, updated_at, finished_at)
|
||||
VALUES (%s, %s, %s, %s, 'done', 1, %f, %s, %s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
status = 'done',
|
||||
cost_usd = VALUES(cost_usd),
|
||||
meta_json = VALUES(meta_json),
|
||||
updated_at = VALUES(updated_at),
|
||||
finished_at = VALUES(finished_at)",
|
||||
$run_id,
|
||||
$stage,
|
||||
$agent_role,
|
||||
$item_key,
|
||||
$cost_usd,
|
||||
$meta,
|
||||
$now,
|
||||
$now,
|
||||
$now
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param string $item_key Article scope.
|
||||
* @return void
|
||||
*/
|
||||
public static function fail( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): void {
|
||||
if ( '' === $run_id || ! self::is_available() ) {
|
||||
return;
|
||||
}
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
$now = current_time( 'mysql' );
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"UPDATE {$table} SET status = 'failed', updated_at = %s, finished_at = %s
|
||||
WHERE run_id = %s AND stage = %s AND agent_role = %s AND item_key = %s AND status != 'done'",
|
||||
$now,
|
||||
$now,
|
||||
$run_id,
|
||||
$stage,
|
||||
$agent_role,
|
||||
$item_key
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch one stage row.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $stage Stage name.
|
||||
* @param string $agent_role Fan-out dimension.
|
||||
* @param string $item_key Article scope.
|
||||
* @return array<string, mixed>|null Row or 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;
|
||||
}
|
||||
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
|
||||
);
|
||||
return is_array( $row ) ? $row : 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.
|
||||
* @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.
|
||||
* @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 (pending/running) stage of one item — used by
|
||||
* the worker's catch-all so a crashed article leaves no stage stuck
|
||||
* in 'running' until the reaper. Done stages are untouched.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $item_key Stage item key ('' = run-level stages).
|
||||
* @return void
|
||||
*/
|
||||
public static function fail_open_for_item( string $run_id, string $item_key ): void {
|
||||
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;
|
||||
}
|
||||
}
|
||||
259
includes/core/class-run-state.php
Normal file
259
includes/core/class-run-state.php
Normal file
@@ -0,0 +1,259 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
172
includes/core/class-stage-display-map.php
Normal file
172
includes/core/class-stage-display-map.php
Normal file
@@ -0,0 +1,172 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
50
includes/core/interface-image-composer.php
Normal file
50
includes/core/interface-image-composer.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Contract for image composers that turn raw provider bytes into final variants.
|
||||
*
|
||||
* A composer sits between the image provider (raw generated bytes) and the
|
||||
* media sideloader. It receives one base image plus per-post context and
|
||||
* returns one or more finished variants — the first is always the `featured`
|
||||
* role (the bytes that become the post thumbnail), optionally followed by
|
||||
* social variants (`og`, `square`). Implementations must NEVER throw out of
|
||||
* compose(): on any internal failure they degrade to passing the base image
|
||||
* through unmodified so publishing is never blocked.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Image_Pipeline between batch results and sideload.
|
||||
* Dependencies: none — this is the contract only.
|
||||
*
|
||||
* @see core/class-image-composer.php — Default implementation (capability ladder).
|
||||
* @see core/class-image-pipeline.php — Caller.
|
||||
* @see core/class-image-attacher.php — Persists the returned variants.
|
||||
*/
|
||||
interface PRAutoBlogger_Image_Composer_Interface {
|
||||
|
||||
/**
|
||||
* Compose final image variant(s) from raw provider bytes.
|
||||
*
|
||||
* Side effects: implementations may read plugin options and write to the
|
||||
* activity log; they must not make network calls or fail the publish.
|
||||
*
|
||||
* @param array{
|
||||
* bytes: string,
|
||||
* mime_type: string,
|
||||
* width: int,
|
||||
* height: int,
|
||||
* } $image_data Base image bytes + metadata from the provider result.
|
||||
* @param array{
|
||||
* post_id: int,
|
||||
* caption: string,
|
||||
* title: string,
|
||||
* slot: string,
|
||||
* } $context Post ID, editorial caption, post title, and slot
|
||||
* ('image_a' or 'image_b'); only image_a yields variants.
|
||||
*
|
||||
* @return array<int, array{bytes: string, mime_type: string, width: int, height: int, role: string}>
|
||||
* Non-empty list; index 0 always carries role 'featured'.
|
||||
*/
|
||||
public function compose( array $image_data, array $context ): array;
|
||||
}
|
||||
10
includes/models/class-generation-log.php
Executable file → Normal file
10
includes/models/class-generation-log.php
Executable file → Normal file
@@ -26,6 +26,8 @@ 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;
|
||||
|
||||
/**
|
||||
@@ -44,6 +46,8 @@ 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' );
|
||||
}
|
||||
|
||||
@@ -71,6 +75,10 @@ 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; }
|
||||
|
||||
@@ -90,6 +98,8 @@ 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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ class PRAutoBlogger_LLM_Research_Provider implements PRAutoBlogger_Source_Provid
|
||||
'temperature' => 0.7,
|
||||
'max_tokens' => 8000,
|
||||
'response_format' => array( 'type' => 'json_object' ),
|
||||
'stage' => 'llm_research',
|
||||
)
|
||||
);
|
||||
|
||||
@@ -123,25 +124,9 @@ class PRAutoBlogger_LLM_Research_Provider implements PRAutoBlogger_Source_Provid
|
||||
* @return string
|
||||
*/
|
||||
private function build_system_prompt(): string {
|
||||
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.';
|
||||
// v0.18.0: renders from the versioned registry ('research.system')
|
||||
// with a byte-identical in-code fallback.
|
||||
return PRAutoBlogger_Prompt_Registry::render( 'research.system' );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
168
includes/providers/class-open-router-completion-guard.php
Normal file
168
includes/providers/class-open-router-completion-guard.php
Normal file
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Empty-completion guard for OpenRouter chat responses (v0.18.1).
|
||||
*
|
||||
* What: Treats a chat completion whose visible content is empty/whitespace
|
||||
* as a FAILURE, never a success — per the LLM-call discipline:
|
||||
* schema-validate every output, retry/fall back, never silently
|
||||
* pass. The incident this prevents (2026-06-11, prod run acf24029,
|
||||
* draft post 921): a true reasoning model at a high reasoning effort
|
||||
* spent the entire max_tokens completion budget on thinking,
|
||||
* finished with finish_reason='length', and emitted zero visible
|
||||
* content — which flowed writer → editor (reject: "draft text is
|
||||
* missing") → an empty draft post in the Review Queue, with the
|
||||
* cost booked as 'success'.
|
||||
*
|
||||
* Behavior on every parsed response:
|
||||
* - finish_reason='length' is warning-logged (model, stage, prompt/
|
||||
* completion/reasoning tokens) whether or not content survived.
|
||||
* - Empty content + (finish_reason='length' OR reasoning active on
|
||||
* the request) → ONE retry with reasoning disabled for that call
|
||||
* (per-call `reasoning` override), then fail the stage.
|
||||
* - Every failed/retried attempt lands in generation_log with its
|
||||
* REAL response_status ('error') and token burn, and — inside a
|
||||
* v0.18.0 run — a run_decisions row records the verdict.
|
||||
* Who triggers it: PRAutoBlogger_OpenRouter_Provider::send_chat_completion()
|
||||
* on every successfully parsed response.
|
||||
* Dependencies: Logger, Cost_Tracker (failure rows), Run_Context (run
|
||||
* attribution), Audit_Writer (run_decisions verdicts).
|
||||
*
|
||||
* @see class-open-router-provider.php — Integration point; supplies the retry callable.
|
||||
* @see class-open-router-response-parser.php — Produces the parsed array assessed here.
|
||||
* @see class-open-router-request-builder.php — Reasoning token cap (first line of defense).
|
||||
* @see core/class-audit-writer.php — run_decisions verdict rows.
|
||||
*/
|
||||
class PRAutoBlogger_OpenRouter_Completion_Guard {
|
||||
|
||||
/** Verdict recorded when an empty attempt is retried with reasoning disabled. */
|
||||
public const VERDICT_RETRY = 'retry_reasoning_disabled';
|
||||
|
||||
/** Verdict recorded when a call finally fails for empty content. */
|
||||
public const VERDICT_FAIL = 'failed_empty_completion';
|
||||
|
||||
/**
|
||||
* Assess a parsed completion: pass it through, retry once, or fail loudly.
|
||||
*
|
||||
* @param array<string, mixed> $parsed Parsed response from Response_Parser::parse_success().
|
||||
* @param array<string, mixed> $options Original call options. Reads the caller-metadata keys
|
||||
* 'stage' / 'prompt_key' (audit attribution) and
|
||||
* 'empty_retry' (marks the retry attempt — never retried twice).
|
||||
* @param bool $reasoning_active Whether the dispatched request had reasoning enabled.
|
||||
* @param callable $retry Re-dispatches the call with the options it is given.
|
||||
* Invoked at most once, with reasoning disabled.
|
||||
*
|
||||
* @return array<string, mixed> A completion whose visible content is non-empty.
|
||||
*
|
||||
* @throws \RuntimeException When content is empty and the retry budget is spent or a retry
|
||||
* cannot plausibly help.
|
||||
*/
|
||||
public function finalize( array $parsed, array $options, bool $reasoning_active, callable $retry ): array {
|
||||
$stage = (string) ( $options['stage'] ?? 'unknown' );
|
||||
$finish_reason = (string) ( $parsed['finish_reason'] ?? 'unknown' );
|
||||
|
||||
if ( 'length' === $finish_reason ) {
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf(
|
||||
'Completion hit its max_tokens ceiling (finish_reason=length): model=%s, stage=%s, prompt_tokens=%d, completion_tokens=%d, reasoning_tokens=%d.',
|
||||
(string) $parsed['model'],
|
||||
$stage,
|
||||
(int) $parsed['prompt_tokens'],
|
||||
(int) $parsed['completion_tokens'],
|
||||
(int) $parsed['reasoning_tokens']
|
||||
),
|
||||
'openrouter'
|
||||
);
|
||||
}
|
||||
|
||||
if ( '' !== trim( (string) ( $parsed['content'] ?? '' ) ) ) {
|
||||
return $parsed; // Healthy completion — visible content present.
|
||||
}
|
||||
|
||||
$already_retried = ! empty( $options['empty_retry'] );
|
||||
// A reasoning-disabled retry can plausibly help when the budget was
|
||||
// exhausted (length) or when reasoning was eating the budget.
|
||||
$retry_can_help = 'length' === $finish_reason || $reasoning_active;
|
||||
|
||||
if ( $retry_can_help && ! $already_retried ) {
|
||||
$note = sprintf(
|
||||
'Empty completion (finish_reason=%s, completion_tokens=%d, reasoning_tokens=%d) — retrying once with reasoning disabled.',
|
||||
$finish_reason,
|
||||
(int) $parsed['completion_tokens'],
|
||||
(int) $parsed['reasoning_tokens']
|
||||
);
|
||||
$this->record_attempt( $parsed, $options, $stage, self::VERDICT_RETRY, $note );
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf( 'Stage %s: %s (model %s)', $stage, $note, (string) $parsed['model'] ),
|
||||
'openrouter'
|
||||
);
|
||||
|
||||
$retry_options = $options;
|
||||
$retry_options['reasoning'] = array( 'enabled' => false );
|
||||
$retry_options['empty_retry'] = true;
|
||||
return $retry( $retry_options );
|
||||
}
|
||||
|
||||
$note = sprintf(
|
||||
'Empty completion (finish_reason=%s, completion_tokens=%d, reasoning_tokens=%d)%s — failing the call.',
|
||||
$finish_reason,
|
||||
(int) $parsed['completion_tokens'],
|
||||
(int) $parsed['reasoning_tokens'],
|
||||
$already_retried ? ' after a reasoning-disabled retry' : ''
|
||||
);
|
||||
$this->record_attempt( $parsed, $options, $stage, self::VERDICT_FAIL, $note );
|
||||
|
||||
throw new \RuntimeException(
|
||||
sprintf(
|
||||
/* translators: 1: pipeline stage, 2: model identifier, 3: finish_reason value, 4: retry note. */
|
||||
__( 'LLM call for stage "%1$s" returned empty content (model %2$s, finish_reason=%3$s)%4$s.', 'prautoblogger' ),
|
||||
$stage,
|
||||
(string) $parsed['model'],
|
||||
$finish_reason,
|
||||
$already_retried ? __( ' after a reasoning-disabled retry', 'prautoblogger' ) : ''
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Book a failed/retried attempt: a generation_log row with the REAL
|
||||
* response_status and token burn (the empty call still cost money),
|
||||
* plus a run_decisions verdict for new-style runs. Both writes are
|
||||
* self-healing no-ops on a half-migrated schema; neither can throw.
|
||||
*
|
||||
* @param array<string, mixed> $parsed Parsed response of the failed attempt.
|
||||
* @param array<string, mixed> $options Call options ('prompt_key' attribution).
|
||||
* @param string $stage Stage label for the log row.
|
||||
* @param string $verdict VERDICT_RETRY or VERDICT_FAIL.
|
||||
* @param string $note Human-readable failure note.
|
||||
* @return void
|
||||
*/
|
||||
private function record_attempt( array $parsed, array $options, string $stage, string $verdict, string $note ): void {
|
||||
$run_id = PRAutoBlogger_Run_Context::current_run_id();
|
||||
|
||||
$tracker = new PRAutoBlogger_Cost_Tracker();
|
||||
if ( null !== $run_id && '' !== $run_id ) {
|
||||
// Attribute the row to the surrounding run. Idempotent: the
|
||||
// run context, ledger row, and prompt pins already exist.
|
||||
$tracker->set_run_id( $run_id );
|
||||
}
|
||||
$tracker->log_api_call(
|
||||
null,
|
||||
$stage,
|
||||
'OpenRouter',
|
||||
(string) $parsed['model'],
|
||||
(int) $parsed['prompt_tokens'],
|
||||
(int) $parsed['completion_tokens'],
|
||||
'error',
|
||||
$note,
|
||||
null,
|
||||
isset( $options['prompt_key'] ) ? (string) $options['prompt_key'] : null
|
||||
);
|
||||
|
||||
// run_decisions verdict — no-op outside a run or pre-1.2.0 schema.
|
||||
PRAutoBlogger_Audit_Writer::record_decision( (string) ( $run_id ?? '' ), $stage, $verdict, $note );
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,9 @@ class PRAutoBlogger_OpenRouter_Pricing {
|
||||
/**
|
||||
* Estimate the cost of an API call in USD.
|
||||
*
|
||||
* Uses hardcoded pricing first, falls back to cached model data.
|
||||
* 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()).
|
||||
*
|
||||
* @param string $model Model identifier.
|
||||
* @param int $prompt_tokens Input tokens.
|
||||
@@ -146,18 +148,7 @@ class PRAutoBlogger_OpenRouter_Pricing {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
}
|
||||
$pricing = $this->resolve_pricing( $model );
|
||||
|
||||
if ( null === $pricing ) {
|
||||
// Unknown model — use a conservative estimate.
|
||||
@@ -172,6 +163,82 @@ 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.
|
||||
*
|
||||
|
||||
@@ -11,14 +11,17 @@ 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.
|
||||
* 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).
|
||||
*
|
||||
* @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 header building + cURL filter.
|
||||
* @see class-open-router-request-builder.php — Request body/header building + cURL filter.
|
||||
* @see class-open-router-response-parser.php — Response parsing + error classification.
|
||||
* @see class-open-router-completion-guard.php — Empty-completion guard + reasoning-off retry (v0.18.1).
|
||||
* @see class-cost-tracker.php — Called after every request to log token usage.
|
||||
* @see ARCHITECTURE.md — Data flow diagram showing where this fits.
|
||||
*/
|
||||
@@ -38,8 +41,15 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
|
||||
* temperature?: float,
|
||||
* max_tokens?: int,
|
||||
* response_format?: array{type: string},
|
||||
* reasoning?: array{enabled: bool, effort?: string},
|
||||
* } $options Optional parameters. Pass 'reasoning' to override the global setting.
|
||||
* reasoning?: array{enabled: bool, effort?: string, max_tokens?: int},
|
||||
* stage?: string,
|
||||
* prompt_key?: string,
|
||||
* empty_retry?: bool,
|
||||
* } $options Optional parameters. Pass 'reasoning' to override the global
|
||||
* setting. 'stage'/'prompt_key' are caller metadata for the
|
||||
* empty-completion guard's logging + audit rows (never sent
|
||||
* upstream); 'empty_retry' is set internally by the guard's
|
||||
* single reasoning-disabled retry.
|
||||
*
|
||||
* @return array{
|
||||
* content: string,
|
||||
@@ -50,56 +60,39 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
|
||||
* finish_reason: string,
|
||||
* }
|
||||
*
|
||||
* @throws \RuntimeException On API error after retries exhausted.
|
||||
* @throws \RuntimeException On API error after retries exhausted, or when the
|
||||
* completion's visible content is empty/whitespace
|
||||
* (v0.18.1 guard — after at most one reasoning-disabled retry).
|
||||
* @throws PRAutoBlogger_Cost_Ceiling_Exception When the per-run cost governor blocks
|
||||
* the call (run already halted).
|
||||
*/
|
||||
public function send_chat_completion( array $messages, string $model, array $options = array() ): array {
|
||||
$api_key = $this->get_api_key();
|
||||
if ( '' === $api_key ) {
|
||||
throw new \RuntimeException(
|
||||
__( 'OpenRouter API key is not configured. Go to PRAutoBlogger → Settings.', 'prautoblogger' )
|
||||
);
|
||||
}
|
||||
$builder = new PRAutoBlogger_OpenRouter_Request_Builder();
|
||||
|
||||
// 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' )
|
||||
);
|
||||
}
|
||||
// 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 = array(
|
||||
'model' => $model,
|
||||
'messages' => $messages,
|
||||
);
|
||||
// Body assembly — incl. global/per-call reasoning and the v0.18.1
|
||||
// reasoning token cap + completion headroom — lives in the builder.
|
||||
$body = $builder->build_body( $messages, $model, $options );
|
||||
|
||||
if ( isset( $options['temperature'] ) ) {
|
||||
$body['temperature'] = $options['temperature'];
|
||||
}
|
||||
if ( isset( $options['max_tokens'] ) ) {
|
||||
$body['max_tokens'] = $options['max_tokens'];
|
||||
}
|
||||
if ( isset( $options['response_format'] ) ) {
|
||||
$body['response_format'] = $options['response_format'];
|
||||
}
|
||||
// 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 );
|
||||
|
||||
// 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' ),
|
||||
);
|
||||
// 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'];
|
||||
}
|
||||
$reservation = PRAutoBlogger_Cost_Governor::open_chat_reservation( $model, $messages, $govern_options );
|
||||
|
||||
$last_error = '';
|
||||
|
||||
@@ -109,7 +102,6 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
|
||||
$cache_ttl = $config->get_cache_ttl_seconds();
|
||||
$via_gateway = $config->is_via_gateway();
|
||||
|
||||
$builder = new PRAutoBlogger_OpenRouter_Request_Builder();
|
||||
$request_headers = $builder->build_headers( $api_key, $via_gateway, $cache_ttl );
|
||||
$curl_auth_filter = $builder->register_curl_auth_filter( $request_headers, $base_host );
|
||||
|
||||
@@ -191,8 +183,28 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
|
||||
);
|
||||
}
|
||||
|
||||
// Success — parse response.
|
||||
return $parser->parse_success( $data, $model );
|
||||
// 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 );
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// All retries exhausted.
|
||||
@@ -207,6 +219,9 @@ 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 );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,17 +290,4 @@ 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 );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,100 @@ declare(strict_types=1);
|
||||
*/
|
||||
class PRAutoBlogger_OpenRouter_Request_Builder {
|
||||
|
||||
/**
|
||||
* Build the JSON request body for a chat-completion call.
|
||||
*
|
||||
* Centralizes option handling (v0.18.1, moved from the provider for
|
||||
* the 300-line cap): temperature, max_tokens, response_format, and
|
||||
* the reasoning block — an explicit caller override takes priority
|
||||
* over the global setting (that per-call override is how the
|
||||
* empty-completion retry disables reasoning for its second attempt).
|
||||
*
|
||||
* Caller-metadata keys ('stage', 'prompt_key', 'empty_retry') are
|
||||
* deliberately NOT copied into the HTTP body.
|
||||
*
|
||||
* @param array<int, array{role: string, content: string}> $messages Chat messages.
|
||||
* @param string $model Model identifier.
|
||||
* @param array<string, mixed> $options Call options.
|
||||
*
|
||||
* @return array<string, mixed> Request body ready for wp_json_encode().
|
||||
*/
|
||||
public function build_body( array $messages, string $model, array $options ): array {
|
||||
$body = array(
|
||||
'model' => $model,
|
||||
'messages' => $messages,
|
||||
);
|
||||
|
||||
if ( isset( $options['temperature'] ) ) {
|
||||
$body['temperature'] = $options['temperature'];
|
||||
}
|
||||
if ( isset( $options['max_tokens'] ) ) {
|
||||
$body['max_tokens'] = $options['max_tokens'];
|
||||
}
|
||||
if ( isset( $options['response_format'] ) ) {
|
||||
$body['response_format'] = $options['response_format'];
|
||||
}
|
||||
|
||||
// Reasoning mode: explicit caller override takes priority, then global setting.
|
||||
if ( isset( $options['reasoning'] ) ) {
|
||||
$body['reasoning'] = $options['reasoning'];
|
||||
} elseif ( '1' === get_option( 'prautoblogger_reasoning_enabled', '0' ) ) {
|
||||
$body['reasoning'] = array(
|
||||
'enabled' => true,
|
||||
'effort' => get_option( 'prautoblogger_reasoning_effort', 'medium' ),
|
||||
);
|
||||
}
|
||||
|
||||
return $this->apply_reasoning_budget( $body );
|
||||
}
|
||||
|
||||
/**
|
||||
* Reasoning budget sanity (v0.18.1): cap thinking, protect content.
|
||||
*
|
||||
* When the outgoing request enables reasoning and carries a completion
|
||||
* ceiling, the thinking budget is capped via OpenRouter's
|
||||
* `reasoning.max_tokens` and the completion ceiling is raised by the
|
||||
* same amount — so no reasoning effort (incl. xhigh) can consume the
|
||||
* entire completion budget and emit an empty message (the 2026-06-11
|
||||
* empty-draft incident, prod run acf24029). The cap is SETTINGS-backed
|
||||
* (`prautoblogger_reasoning_max_tokens`, default
|
||||
* PRAUTOBLOGGER_DEFAULT_REASONING_MAX_TOKENS, 0 = pure effort mode:
|
||||
* no cap, no headroom — the historical behavior). When the cap is
|
||||
* active it replaces `effort` — OpenRouter treats the two as
|
||||
* alternative budget controls and normalizes max_tokens per model.
|
||||
*
|
||||
* No-op when reasoning is absent/disabled on the request, when the
|
||||
* request has no completion ceiling, or when the cap setting is 0.
|
||||
*
|
||||
* @param array<string, mixed> $body Assembled request body.
|
||||
*
|
||||
* @return array<string, mixed> Body with the reasoning budget applied.
|
||||
*/
|
||||
private function apply_reasoning_budget( array $body ): array {
|
||||
if ( ! isset( $body['reasoning'] ) || false === ( $body['reasoning']['enabled'] ?? true ) ) {
|
||||
return $body;
|
||||
}
|
||||
if ( ! isset( $body['max_tokens'] ) ) {
|
||||
return $body; // No completion ceiling to protect.
|
||||
}
|
||||
|
||||
if ( ! isset( $body['reasoning']['max_tokens'] ) ) {
|
||||
$cap = absint( get_option( 'prautoblogger_reasoning_max_tokens', PRAUTOBLOGGER_DEFAULT_REASONING_MAX_TOKENS ) );
|
||||
if ( $cap < 1 ) {
|
||||
return $body; // Cap disabled — pure effort mode.
|
||||
}
|
||||
$body['reasoning']['max_tokens'] = $cap;
|
||||
unset( $body['reasoning']['effort'] );
|
||||
}
|
||||
|
||||
// Headroom: models that spend reasoning from the completion budget
|
||||
// (e.g. deepseek/deepseek-v4-flash) must still have the caller's
|
||||
// full max_tokens available for visible content.
|
||||
$body['max_tokens'] = (int) $body['max_tokens'] + (int) $body['reasoning']['max_tokens'];
|
||||
|
||||
return $body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build request headers for OpenRouter API call.
|
||||
*
|
||||
@@ -78,4 +172,43 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ class PRAutoBlogger_Opik_Eval_Scorer {
|
||||
array(
|
||||
'temperature' => 0.1,
|
||||
'max_tokens' => 200,
|
||||
'stage' => 'opik_eval_judge',
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -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.16.0
|
||||
* Version: 0.18.1
|
||||
* Requires at least: 6.0
|
||||
* Requires PHP: 7.4
|
||||
* Author: PeptideRepo
|
||||
@@ -35,14 +35,26 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
| Defined here so every file in the plugin can reference paths, versions,
|
||||
| and limits without magic strings.
|
||||
*/
|
||||
define( 'PRAUTOBLOGGER_VERSION', '0.16.0' );
|
||||
define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' );
|
||||
define( 'PRAUTOBLOGGER_VERSION', '0.18.1' );
|
||||
define( 'PRAUTOBLOGGER_DB_VERSION', '1.2.0' );
|
||||
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
|
||||
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||
define( 'PRAUTOBLOGGER_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
|
||||
define( 'PRAUTOBLOGGER_PLUGIN_BASENAME', plugin_basename( __FILE__ ) );
|
||||
// API and retry limits — named constants instead of magic numbers.
|
||||
define( 'PRAUTOBLOGGER_MAX_RETRIES', 3 );
|
||||
// Pipeline v2 Phase 1 (v0.18.0) defaults. Both are SETTINGS-backed at every
|
||||
// read site (prautoblogger_per_run_cost_ceiling_usd /
|
||||
// prautoblogger_request_json_retention_days); these constants exist only as
|
||||
// the named get_option() fallbacks — never inline the literals.
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD', 0.50 );
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS', 14 );
|
||||
// v0.18.1: ceiling on "thinking" tokens per reasoning-enabled LLM call. The
|
||||
// request's completion budget (max_tokens) is RAISED by this amount so
|
||||
// reasoning can never consume the visible-content budget and return an empty
|
||||
// message (the 2026-06-11 empty-draft incident). SETTINGS-backed
|
||||
// (prautoblogger_reasoning_max_tokens); 0 disables the cap (pure effort mode).
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_REASONING_MAX_TOKENS', 2048 );
|
||||
define( 'PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS', 2 );
|
||||
define( 'PRAUTOBLOGGER_API_TIMEOUT_SECONDS', 180 );
|
||||
define( 'PRAUTOBLOGGER_CACHE_TTL_SECONDS', 3600 );
|
||||
|
||||
56
templates/admin/review-queue.php
Executable file → Normal file
56
templates/admin/review-queue.php
Executable file → Normal file
@@ -18,6 +18,37 @@ $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>
|
||||
@@ -86,7 +117,10 @@ $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 );
|
||||
@@ -139,14 +173,18 @@ $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( [
|
||||
'base' => add_query_arg( 'paged', '%#%' ),
|
||||
'format' => '',
|
||||
'current' => $current_page,
|
||||
'total' => $total_pages,
|
||||
'prev_text' => '«',
|
||||
'next_text' => '»',
|
||||
] ) );
|
||||
echo wp_kses_post(
|
||||
paginate_links(
|
||||
array(
|
||||
'base' => add_query_arg( 'paged', '%#%' ),
|
||||
'format' => '',
|
||||
'current' => $current_page,
|
||||
'total' => $total_pages,
|
||||
'prev_text' => '«',
|
||||
'next_text' => '»',
|
||||
)
|
||||
)
|
||||
);
|
||||
echo '</div>';
|
||||
endif;
|
||||
?>
|
||||
|
||||
11
tests/bootstrap.php
Executable file → Normal file
11
tests/bootstrap.php
Executable file → Normal file
@@ -21,6 +21,17 @@ 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 );
|
||||
|
||||
164
tests/unit/Core/ContentGeneratorTest.php
Normal file
164
tests/unit/Core/ContentGeneratorTest.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?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'] );
|
||||
}
|
||||
}
|
||||
221
tests/unit/Core/CostGovernorTest.php
Normal file
221
tests/unit/Core/CostGovernorTest.php
Normal file
@@ -0,0 +1,221 @@
|
||||
<?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 );
|
||||
}
|
||||
}
|
||||
176
tests/unit/Core/ImageAttacherTest.php
Normal file
176
tests/unit/Core/ImageAttacherTest.php
Normal file
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Image_Attacher.
|
||||
*
|
||||
* Validates primary sideload + cost logging (including the v0.17.0 alt-text
|
||||
* fix: the caption, not the model name, becomes alt text) and composed
|
||||
* variant persistence with role/base meta-linking at $0 cost.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class ImageAttacherTest extends BaseTestCase {
|
||||
|
||||
/** @var array Captured update_post_meta calls: [object_id, key, value]. */
|
||||
private array $meta_calls = [];
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
Functions\when( 'is_wp_error' )->alias(
|
||||
static function ( $thing ) {
|
||||
return $thing instanceof \WP_Error;
|
||||
}
|
||||
);
|
||||
$this->meta_calls = [];
|
||||
Functions\when( 'update_post_meta' )->alias(
|
||||
function ( $object_id, $key, $value ) {
|
||||
$this->meta_calls[] = [ $object_id, $key, $value ];
|
||||
return true;
|
||||
}
|
||||
);
|
||||
$this->stub_get_option( [] );
|
||||
}
|
||||
|
||||
/** Helper: find a captured meta value by object id + key. */
|
||||
private function meta_value( int $object_id, string $key ) {
|
||||
foreach ( $this->meta_calls as $call ) {
|
||||
if ( $call[0] === $object_id && $call[1] === $key ) {
|
||||
return $call[2];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Helper: primary image fixture. */
|
||||
private function image_data(): array {
|
||||
return [
|
||||
'bytes' => 'featured_bytes',
|
||||
'mime_type' => 'image/png',
|
||||
'width' => 1200,
|
||||
'height' => 632,
|
||||
'model' => 'flux-1-schnell',
|
||||
'cost_usd' => 0.0006,
|
||||
'latency_ms' => 1500,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* sideload_and_log() forwards the caption as alt text (bug fix: the model
|
||||
* name used to leak into alt text) and logs the generation cost once.
|
||||
*/
|
||||
public function test_sideload_and_log_forwards_caption_as_alt_and_logs_cost(): void {
|
||||
$sideloader = $this->createMock( \PRAutoBlogger_Image_Media_Sideloader::class );
|
||||
$sideloader->expects( $this->once() )
|
||||
->method( 'sideload_image' )
|
||||
->with( $this->image_data(), 7, 'Collagen fragments may aid joint repair' )
|
||||
->willReturn( 42 );
|
||||
|
||||
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
|
||||
$cost_tracker->expects( $this->once() )
|
||||
->method( 'log_image_generation' )
|
||||
->with( 0.0006, 'flux-1-schnell', 7, 'image_a' );
|
||||
|
||||
$attacher = new \PRAutoBlogger_Image_Attacher( $sideloader, $cost_tracker );
|
||||
$result = $attacher->sideload_and_log( $this->image_data(), 7, 'image_a', 'Image A', 'Collagen fragments may aid joint repair' );
|
||||
|
||||
$this->assertSame( 42, $result );
|
||||
}
|
||||
|
||||
/**
|
||||
* A failed primary sideload returns the WP_Error untouched and logs no cost.
|
||||
*/
|
||||
public function test_sideload_and_log_propagates_error_without_cost_row(): void {
|
||||
$error = new \WP_Error( 'sideload_failed', 'disk full' );
|
||||
$sideloader = $this->createMock( \PRAutoBlogger_Image_Media_Sideloader::class );
|
||||
$sideloader->method( 'sideload_image' )->willReturn( $error );
|
||||
|
||||
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
|
||||
$cost_tracker->expects( $this->never() )->method( 'log_image_generation' );
|
||||
|
||||
$attacher = new \PRAutoBlogger_Image_Attacher( $sideloader, $cost_tracker );
|
||||
|
||||
$this->assertSame( $error, $attacher->sideload_and_log( $this->image_data(), 7, 'image_a', 'Image A', 'cap' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* attach_variants() sideloads og + square with the role as filename
|
||||
* suffix and the caption as alt text, writes role/base attachment meta
|
||||
* and the role-specific post meta, and never logs composition cost.
|
||||
*/
|
||||
public function test_attach_variants_meta_links_og_and_square(): void {
|
||||
$variants = [
|
||||
[
|
||||
'bytes' => 'og_bytes',
|
||||
'mime_type' => 'image/png',
|
||||
'width' => 1200,
|
||||
'height' => 630,
|
||||
'role' => 'og',
|
||||
],
|
||||
[
|
||||
'bytes' => 'square_bytes',
|
||||
'mime_type' => 'image/png',
|
||||
'width' => 1080,
|
||||
'height' => 1080,
|
||||
'role' => 'square',
|
||||
],
|
||||
];
|
||||
|
||||
$returned_ids = [ 'og' => 43, 'square' => 44 ];
|
||||
$sideloader = $this->createMock( \PRAutoBlogger_Image_Media_Sideloader::class );
|
||||
$sideloader->expects( $this->exactly( 2 ) )
|
||||
->method( 'sideload_image' )
|
||||
->willReturnCallback(
|
||||
function ( array $image_data, int $post_id, string $alt, string $suffix ) use ( $returned_ids ) {
|
||||
$this->assertSame( 7, $post_id );
|
||||
$this->assertSame( 'A short caption', $alt );
|
||||
$this->assertSame( 0.0, $image_data['cost_usd'] );
|
||||
$this->assertSame( 'flux-1-schnell', $image_data['model'] );
|
||||
$this->assertArrayHasKey( $suffix, $returned_ids, 'filename suffix must be the role' );
|
||||
return $returned_ids[ $suffix ];
|
||||
}
|
||||
);
|
||||
|
||||
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
|
||||
$cost_tracker->expects( $this->never() )->method( 'log_image_generation' );
|
||||
|
||||
$attacher = new \PRAutoBlogger_Image_Attacher( $sideloader, $cost_tracker );
|
||||
$attacher->attach_variants( $variants, 7, 42, 'A short caption', 'flux-1-schnell' );
|
||||
|
||||
$this->assertSame( 'og', $this->meta_value( 43, '_prautoblogger_image_role' ) );
|
||||
$this->assertSame( 42, $this->meta_value( 43, '_prautoblogger_base_attachment_id' ) );
|
||||
$this->assertSame( 43, $this->meta_value( 7, '_prautoblogger_og_image_id' ) );
|
||||
|
||||
$this->assertSame( 'square', $this->meta_value( 44, '_prautoblogger_image_role' ) );
|
||||
$this->assertSame( 42, $this->meta_value( 44, '_prautoblogger_base_attachment_id' ) );
|
||||
$this->assertSame( 44, $this->meta_value( 7, '_prautoblogger_square_image_id' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Unknown roles (e.g. a stray featured entry) are ignored, and a failed
|
||||
* variant sideload is skipped without meta writes or exceptions.
|
||||
*/
|
||||
public function test_attach_variants_skips_unknown_roles_and_failed_sideloads(): void {
|
||||
$variants = [
|
||||
[ 'bytes' => 'f', 'mime_type' => 'image/png', 'width' => 1, 'height' => 1, 'role' => 'featured' ],
|
||||
[ 'bytes' => 'o', 'mime_type' => 'image/png', 'width' => 1200, 'height' => 630, 'role' => 'og' ],
|
||||
];
|
||||
|
||||
$sideloader = $this->createMock( \PRAutoBlogger_Image_Media_Sideloader::class );
|
||||
$sideloader->expects( $this->once() ) // Only og — featured is not a variant role here.
|
||||
->method( 'sideload_image' )
|
||||
->willReturn( new \WP_Error( 'sideload_failed', 'nope' ) );
|
||||
|
||||
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
|
||||
|
||||
$attacher = new \PRAutoBlogger_Image_Attacher( $sideloader, $cost_tracker );
|
||||
$attacher->attach_variants( $variants, 7, 42, 'cap', 'model' );
|
||||
|
||||
$this->assertSame( [], $this->meta_calls );
|
||||
}
|
||||
}
|
||||
104
tests/unit/Core/ImageComposerImagickTest.php
Normal file
104
tests/unit/Core/ImageComposerImagickTest.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Image_Composer_Imagick (real renders).
|
||||
*
|
||||
* These run only where ext-imagick is loaded (skipped on the standard CI
|
||||
* matrix); the same renders are exercised by the standalone harness that
|
||||
* produced the PR samples. Asserts the determinism contract — identical
|
||||
* inputs yield byte-identical output in the same environment — plus the
|
||||
* exact variant geometry.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
|
||||
class ImageComposerImagickTest extends BaseTestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
if ( ! extension_loaded( 'imagick' ) ) {
|
||||
$this->markTestSkipped( 'ext-imagick not available — renderer covered by the server-side harness.' );
|
||||
}
|
||||
}
|
||||
|
||||
/** Helper: renderer wired to the real bundled assets. */
|
||||
private function renderer(): \PRAutoBlogger_Image_Composer_Imagick {
|
||||
$root = dirname( __DIR__, 3 );
|
||||
|
||||
return new \PRAutoBlogger_Image_Composer_Imagick(
|
||||
\PRAutoBlogger_Image_Composer_Layout::defaults(),
|
||||
$root . '/assets/brand/',
|
||||
$root . '/assets/fonts/'
|
||||
);
|
||||
}
|
||||
|
||||
/** Helper: deterministic 1200×632 fixture base image (gradient + disc). */
|
||||
private function fixture_bytes(): string {
|
||||
$image = new \Imagick();
|
||||
$image->newPseudoImage( 1200, 632, 'gradient:#1B8A92-#FAFAF7' );
|
||||
$draw = new \ImagickDraw();
|
||||
$draw->setFillColor( new \ImagickPixel( '#FF8A3D' ) );
|
||||
$draw->circle( 600, 316, 600, 470 );
|
||||
$image->drawImage( $draw );
|
||||
$image->setImageFormat( 'png' );
|
||||
|
||||
return $image->getImageBlob();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determinism: rendering the same input twice yields byte-identical
|
||||
* variants (metadata stripped, PNG date/time chunks excluded).
|
||||
*/
|
||||
public function test_same_input_renders_byte_identical_output(): void {
|
||||
$bytes = $this->fixture_bytes();
|
||||
$caption = 'Collagen fragments may aid joint repair';
|
||||
|
||||
$first = $this->renderer()->compose_og( $bytes, $caption );
|
||||
$second = $this->renderer()->compose_og( $bytes, $caption );
|
||||
$this->assertSame( hash( 'sha256', $first['bytes'] ), hash( 'sha256', $second['bytes'] ), 'OG render must be byte-stable' );
|
||||
|
||||
$first = $this->renderer()->compose_square( $bytes, $caption );
|
||||
$second = $this->renderer()->compose_square( $bytes, $caption );
|
||||
$this->assertSame( hash( 'sha256', $first['bytes'] ), hash( 'sha256', $second['bytes'] ), 'Square render must be byte-stable' );
|
||||
|
||||
$first = $this->renderer()->compose_featured( $bytes );
|
||||
$second = $this->renderer()->compose_featured( $bytes );
|
||||
$this->assertSame( hash( 'sha256', $first['bytes'] ), hash( 'sha256', $second['bytes'] ), 'Featured render must be byte-stable' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Variant geometry matches the spec: featured keeps base dimensions,
|
||||
* OG is exactly 1200×630, square exactly 1080×1080, all PNG.
|
||||
*/
|
||||
public function test_variant_dimensions_and_mime(): void {
|
||||
$bytes = $this->fixture_bytes();
|
||||
$caption = 'Collagen fragments may aid joint repair';
|
||||
|
||||
$featured = $this->renderer()->compose_featured( $bytes );
|
||||
$this->assertSame( [ 1200, 632, 'image/png', 'featured' ], [ $featured['width'], $featured['height'], $featured['mime_type'], $featured['role'] ] );
|
||||
|
||||
$og = $this->renderer()->compose_og( $bytes, $caption );
|
||||
$this->assertSame( [ 1200, 630, 'image/png', 'og' ], [ $og['width'], $og['height'], $og['mime_type'], $og['role'] ] );
|
||||
|
||||
$square = $this->renderer()->compose_square( $bytes, $caption );
|
||||
$this->assertSame( [ 1080, 1080, 'image/png', 'square' ], [ $square['width'], $square['height'], $square['mime_type'], $square['role'] ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Output PNG bytes embed no date text chunks or tIME chunk, so a render
|
||||
* is stable across wall-clock time, not just within one process. (We
|
||||
* scan the raw bytes: Imagick synthesizes `date:*` image properties at
|
||||
* read time even for chunk-free PNGs, so getImageProperties() can't
|
||||
* distinguish embedded dates from synthesized ones.)
|
||||
*/
|
||||
public function test_output_contains_no_date_chunks(): void {
|
||||
$og = $this->renderer()->compose_og( $this->fixture_bytes(), 'Short caption' );
|
||||
|
||||
$this->assertFalse( strpos( $og['bytes'], 'date:create' ), 'PNG must not embed a date:create text chunk' );
|
||||
$this->assertFalse( strpos( $og['bytes'], 'date:modify' ), 'PNG must not embed a date:modify text chunk' );
|
||||
$this->assertFalse( strpos( $og['bytes'], 'tIME' ), 'PNG must not embed a tIME chunk' );
|
||||
}
|
||||
}
|
||||
144
tests/unit/Core/ImageComposerLayoutTest.php
Normal file
144
tests/unit/Core/ImageComposerLayoutTest.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Image_Composer_Layout.
|
||||
*
|
||||
* Validates the caption clamp (word wrap, hard budgets, ellipsis, multibyte)
|
||||
* and the shape of the layout defaults consumed by the renderers.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
|
||||
class ImageComposerLayoutTest extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* Empty and whitespace-only captions clamp to no lines at all.
|
||||
*/
|
||||
public function test_empty_caption_returns_no_lines(): void {
|
||||
$this->assertSame( [], \PRAutoBlogger_Image_Composer_Layout::clamp_caption( '', 52, 2 ) );
|
||||
$this->assertSame( [], \PRAutoBlogger_Image_Composer_Layout::clamp_caption( " \n\t ", 52, 2 ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* A single short word stays on one line, untouched.
|
||||
*/
|
||||
public function test_single_word_caption(): void {
|
||||
$this->assertSame(
|
||||
[ 'Collagen' ],
|
||||
\PRAutoBlogger_Image_Composer_Layout::clamp_caption( 'Collagen', 52, 2 )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A caption exactly at the per-line budget fills the line with no
|
||||
* ellipsis and no spill.
|
||||
*/
|
||||
public function test_caption_exactly_at_budget_is_not_truncated(): void {
|
||||
$caption = 'abcde fghij'; // 11 chars, budget 11.
|
||||
$lines = \PRAutoBlogger_Image_Composer_Layout::clamp_caption( $caption, 11, 1 );
|
||||
|
||||
$this->assertSame( [ 'abcde fghij' ], $lines );
|
||||
}
|
||||
|
||||
/**
|
||||
* A caption exactly filling max_lines is kept in full (boundary: the
|
||||
* ellipsis only appears when content is actually dropped).
|
||||
*/
|
||||
public function test_caption_exactly_filling_max_lines_keeps_all_words(): void {
|
||||
$lines = \PRAutoBlogger_Image_Composer_Layout::clamp_caption( 'aa bb cc dd', 5, 2 );
|
||||
|
||||
$this->assertSame( [ 'aa bb', 'cc dd' ], $lines );
|
||||
}
|
||||
|
||||
/**
|
||||
* 3× the budget gets cut at max_lines with a trailing ellipsis, and no
|
||||
* line ever exceeds the per-line budget.
|
||||
*/
|
||||
public function test_overflowing_caption_is_ellipsized_within_budget(): void {
|
||||
$caption = 'Collagen fragments may aid joint repair according to early trials in athletes';
|
||||
$lines = \PRAutoBlogger_Image_Composer_Layout::clamp_caption( $caption, 20, 2 );
|
||||
|
||||
$this->assertCount( 2, $lines );
|
||||
$this->assertStringEndsWith( \PRAutoBlogger_Image_Composer_Layout::ELLIPSIS, $lines[1] );
|
||||
foreach ( $lines as $line ) {
|
||||
$this->assertLessThanOrEqual( 20, mb_strlen( $line ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Words wrap at word boundaries — a word never straddles two lines when
|
||||
* it fits on the next one.
|
||||
*/
|
||||
public function test_wraps_at_word_boundaries(): void {
|
||||
$lines = \PRAutoBlogger_Image_Composer_Layout::clamp_caption( 'peptide bonds form chains', 13, 3 );
|
||||
|
||||
$this->assertSame( [ 'peptide bonds', 'form chains' ], $lines );
|
||||
}
|
||||
|
||||
/**
|
||||
* A single word longer than a full line is hard-split instead of
|
||||
* overflowing the canvas.
|
||||
*/
|
||||
public function test_overlong_single_word_is_hard_split(): void {
|
||||
$lines = \PRAutoBlogger_Image_Composer_Layout::clamp_caption( 'abcdefghij', 4, 3 );
|
||||
|
||||
$this->assertSame( [ 'abcd', 'efgh', 'ij' ], $lines );
|
||||
}
|
||||
|
||||
/**
|
||||
* Multibyte captions are measured in characters, not bytes, and the
|
||||
* ellipsis path keeps each line within the budget.
|
||||
*/
|
||||
public function test_multibyte_caption_counts_characters_not_bytes(): void {
|
||||
$lines = \PRAutoBlogger_Image_Composer_Layout::clamp_caption( 'ペプチドは結合組織を強化する可能性がある', 6, 2 );
|
||||
|
||||
$this->assertCount( 2, $lines );
|
||||
foreach ( $lines as $line ) {
|
||||
$this->assertLessThanOrEqual( 6, mb_strlen( $line ) );
|
||||
}
|
||||
$this->assertStringEndsWith( \PRAutoBlogger_Image_Composer_Layout::ELLIPSIS, $lines[1] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Interior whitespace runs (newlines, tabs, doubles) collapse to single
|
||||
* spaces before wrapping.
|
||||
*/
|
||||
public function test_whitespace_is_collapsed_before_wrapping(): void {
|
||||
$lines = \PRAutoBlogger_Image_Composer_Layout::clamp_caption( "joint \n\t repair", 20, 2 );
|
||||
|
||||
$this->assertSame( [ 'joint repair' ], $lines );
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout defaults carry every key the renderers consume, with the
|
||||
* spec'd headline geometry (band 120, square slice 569, footer 96).
|
||||
*/
|
||||
public function test_defaults_shape_matches_renderer_contract(): void {
|
||||
$defaults = \PRAutoBlogger_Image_Composer_Layout::defaults();
|
||||
|
||||
$this->assertSame( [ 'featured', 'og', 'square' ], array_keys( $defaults ) );
|
||||
|
||||
$this->assertSame( 28, $defaults['featured']['mark_height'] );
|
||||
$this->assertSame( 0.55, $defaults['featured']['mark_opacity'] );
|
||||
|
||||
$this->assertSame( 1200, $defaults['og']['width'] );
|
||||
$this->assertSame( 630, $defaults['og']['height'] );
|
||||
$this->assertSame( 120, $defaults['og']['band_height'] );
|
||||
$this->assertSame( 2, $defaults['og']['caption_max_lines'] );
|
||||
|
||||
$this->assertSame( 1080, $defaults['square']['width'] );
|
||||
$this->assertSame( 1080, $defaults['square']['height'] );
|
||||
$this->assertSame( 569, $defaults['square']['slice_height'] );
|
||||
$this->assertSame( 96, $defaults['square']['footer_height'] );
|
||||
$this->assertSame( 3, $defaults['square']['caption_max_lines'] );
|
||||
|
||||
foreach ( [ 'og', 'square' ] as $role ) {
|
||||
foreach ( [ 'caption_font', 'caption_size', 'caption_color', 'caption_chars_per_line', 'caption_line_height' ] as $key ) {
|
||||
$this->assertArrayHasKey( $key, $defaults[ $role ], "$role missing $key" );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
325
tests/unit/Core/ImageComposerTest.php
Normal file
325
tests/unit/Core/ImageComposerTest.php
Normal file
@@ -0,0 +1,325 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Image_Composer (orchestrator).
|
||||
*
|
||||
* Validates the degradation ladder (Imagick mocked present/absent via the
|
||||
* capability filter), pass-through guarantees, variant config whitelisting,
|
||||
* capability caching, and the uninstall prefix contract.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
use Brain\Monkey\Filters;
|
||||
|
||||
class ImageComposerTest extends BaseTestCase {
|
||||
|
||||
/** @var array Default provider-result fixture fed into compose(). */
|
||||
private array $image_data = [
|
||||
'bytes' => 'raw_base_bytes',
|
||||
'mime_type' => 'image/png',
|
||||
'width' => 1200,
|
||||
'height' => 632,
|
||||
'model' => 'flux-1-schnell',
|
||||
'cost_usd' => 0.0006,
|
||||
'latency_ms' => 1500,
|
||||
];
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
\PRAutoBlogger_Image_Composer::reset_run_state();
|
||||
Functions\when( 'update_option' )->justReturn( true );
|
||||
$this->stub_get_option( [] ); // All composer options fall back to defaults.
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
\PRAutoBlogger_Image_Composer::reset_run_state();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/** Helper: composer context for Image A. */
|
||||
private function context( string $slot = 'image_a' ): array {
|
||||
return [
|
||||
'post_id' => 7,
|
||||
'caption' => 'Collagen fragments may aid joint repair',
|
||||
'title' => 'Collagen Peptides Explained',
|
||||
'slot' => $slot,
|
||||
];
|
||||
}
|
||||
|
||||
/** Helper: a fake rendered variant payload. */
|
||||
private function variant( string $role, int $w, int $h ): array {
|
||||
return [
|
||||
'bytes' => "rendered_{$role}",
|
||||
'mime_type' => 'image/png',
|
||||
'width' => $w,
|
||||
'height' => $h,
|
||||
'role' => $role,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose disabled in settings → single pass-through featured variant,
|
||||
* renderer untouched.
|
||||
*/
|
||||
public function test_disabled_setting_returns_passthrough_only(): void {
|
||||
$this->stub_get_option( [ \PRAutoBlogger_Image_Composer::OPTION_ENABLED => '0' ] );
|
||||
|
||||
$renderer = $this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class );
|
||||
$renderer->expects( $this->never() )->method( 'compose_featured' );
|
||||
$renderer->expects( $this->never() )->method( 'compose_og' );
|
||||
|
||||
$composer = new \PRAutoBlogger_Image_Composer( $renderer );
|
||||
$variants = $composer->compose( $this->image_data, $this->context() );
|
||||
|
||||
$this->assertCount( 1, $variants );
|
||||
$this->assertSame( 'featured', $variants[0]['role'] );
|
||||
$this->assertSame( 'raw_base_bytes', $variants[0]['bytes'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty provider bytes short-circuit to pass-through (no probe, no render).
|
||||
*/
|
||||
public function test_empty_bytes_return_passthrough(): void {
|
||||
$composer = new \PRAutoBlogger_Image_Composer(
|
||||
$this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class )
|
||||
);
|
||||
$variants = $composer->compose( [ 'bytes' => '' ], $this->context() );
|
||||
|
||||
$this->assertCount( 1, $variants );
|
||||
$this->assertSame( 'featured', $variants[0]['role'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Ladder rung 1 (Imagick "present" via filter + mocked renderer):
|
||||
* Image A yields marked featured + og + square, featured first, with the
|
||||
* caption forwarded to both text-bearing variants.
|
||||
*/
|
||||
public function test_imagick_rung_renders_featured_plus_variants(): void {
|
||||
Filters\expectApplied( 'prautoblogger_image_compose_capability' )->andReturn( 'imagick' );
|
||||
|
||||
$caption = $this->context()['caption'];
|
||||
$renderer = $this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class );
|
||||
$renderer->expects( $this->once() )->method( 'compose_featured' )
|
||||
->with( 'raw_base_bytes' )->willReturn( $this->variant( 'featured', 1200, 632 ) );
|
||||
$renderer->expects( $this->once() )->method( 'compose_og' )
|
||||
->with( 'raw_base_bytes', $caption )->willReturn( $this->variant( 'og', 1200, 630 ) );
|
||||
$renderer->expects( $this->once() )->method( 'compose_square' )
|
||||
->with( 'raw_base_bytes', $caption )->willReturn( $this->variant( 'square', 1080, 1080 ) );
|
||||
|
||||
$composer = new \PRAutoBlogger_Image_Composer( $renderer );
|
||||
$variants = $composer->compose( $this->image_data, $this->context() );
|
||||
|
||||
$this->assertSame( [ 'featured', 'og', 'square' ], array_column( $variants, 'role' ) );
|
||||
$this->assertSame( 'rendered_featured', $variants[0]['bytes'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Featured corner mark toggled off → featured stays the untouched base,
|
||||
* but social variants still render.
|
||||
*/
|
||||
public function test_mark_disabled_passes_featured_through_but_keeps_variants(): void {
|
||||
Filters\expectApplied( 'prautoblogger_image_compose_capability' )->andReturn( 'imagick' );
|
||||
$this->stub_get_option( [ \PRAutoBlogger_Image_Composer::OPTION_MARK_ENABLED => '0' ] );
|
||||
|
||||
$renderer = $this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class );
|
||||
$renderer->expects( $this->never() )->method( 'compose_featured' );
|
||||
$renderer->method( 'compose_og' )->willReturn( $this->variant( 'og', 1200, 630 ) );
|
||||
$renderer->method( 'compose_square' )->willReturn( $this->variant( 'square', 1080, 1080 ) );
|
||||
|
||||
$composer = new \PRAutoBlogger_Image_Composer( $renderer );
|
||||
$variants = $composer->compose( $this->image_data, $this->context() );
|
||||
|
||||
$this->assertSame( 'raw_base_bytes', $variants[0]['bytes'] );
|
||||
$this->assertSame( [ 'featured', 'og', 'square' ], array_column( $variants, 'role' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Image B slot gets the corner mark only — no og/square renders ever.
|
||||
*/
|
||||
public function test_image_b_slot_yields_mark_only(): void {
|
||||
Filters\expectApplied( 'prautoblogger_image_compose_capability' )->andReturn( 'imagick' );
|
||||
|
||||
$renderer = $this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class );
|
||||
$renderer->expects( $this->once() )->method( 'compose_featured' )
|
||||
->willReturn( $this->variant( 'featured', 1200, 632 ) );
|
||||
$renderer->expects( $this->never() )->method( 'compose_og' );
|
||||
$renderer->expects( $this->never() )->method( 'compose_square' );
|
||||
|
||||
$composer = new \PRAutoBlogger_Image_Composer( $renderer );
|
||||
$variants = $composer->compose( $this->image_data, $this->context( 'image_b' ) );
|
||||
|
||||
$this->assertCount( 1, $variants );
|
||||
$this->assertSame( 'featured', $variants[0]['role'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* The variant option is whitelisted at point of use: unknown tokens are
|
||||
* dropped, duplicates deduped, config order preserved.
|
||||
*/
|
||||
public function test_variant_config_is_whitelisted_and_ordered(): void {
|
||||
Filters\expectApplied( 'prautoblogger_image_compose_capability' )->andReturn( 'imagick' );
|
||||
$this->stub_get_option(
|
||||
[ \PRAutoBlogger_Image_Composer::OPTION_VARIANTS => ' square, banana ,og, og' ]
|
||||
);
|
||||
|
||||
$renderer = $this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class );
|
||||
$renderer->method( 'compose_featured' )->willReturn( $this->variant( 'featured', 1200, 632 ) );
|
||||
$renderer->expects( $this->once() )->method( 'compose_square' )
|
||||
->willReturn( $this->variant( 'square', 1080, 1080 ) );
|
||||
$renderer->expects( $this->once() )->method( 'compose_og' )
|
||||
->willReturn( $this->variant( 'og', 1200, 630 ) );
|
||||
|
||||
$composer = new \PRAutoBlogger_Image_Composer( $renderer );
|
||||
$variants = $composer->compose( $this->image_data, $this->context() );
|
||||
|
||||
$this->assertSame( [ 'featured', 'square', 'og' ], array_column( $variants, 'role' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Acceptance: with every render throwing (e.g. probe lied / Imagick
|
||||
* flipped mid-run), compose() still returns the publishable pass-through
|
||||
* featured variant and never lets the exception escape.
|
||||
*/
|
||||
public function test_renderer_failure_degrades_to_passthrough(): void {
|
||||
Filters\expectApplied( 'prautoblogger_image_compose_capability' )->andReturn( 'imagick' );
|
||||
|
||||
$renderer = $this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class );
|
||||
$renderer->method( 'compose_featured' )->willThrowException( new \RuntimeException( 'no imagick after all' ) );
|
||||
$renderer->method( 'compose_og' )->willThrowException( new \RuntimeException( 'no imagick after all' ) );
|
||||
$renderer->method( 'compose_square' )->willThrowException( new \RuntimeException( 'no imagick after all' ) );
|
||||
|
||||
$composer = new \PRAutoBlogger_Image_Composer( $renderer );
|
||||
$variants = $composer->compose( $this->image_data, $this->context() );
|
||||
|
||||
$this->assertCount( 1, $variants );
|
||||
$this->assertSame( 'featured', $variants[0]['role'] );
|
||||
$this->assertSame( 'raw_base_bytes', $variants[0]['bytes'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Ladder rung 3 forced via the override filter: pass-through, no renderer
|
||||
* involvement, publish data intact.
|
||||
*/
|
||||
public function test_capability_none_passes_through(): void {
|
||||
Filters\expectApplied( 'prautoblogger_image_compose_capability' )->andReturn( 'none' );
|
||||
|
||||
$renderer = $this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class );
|
||||
$renderer->expects( $this->never() )->method( 'compose_featured' );
|
||||
|
||||
$composer = new \PRAutoBlogger_Image_Composer( $renderer );
|
||||
$variants = $composer->compose( $this->image_data, $this->context() );
|
||||
|
||||
$this->assertCount( 1, $variants );
|
||||
$this->assertSame( 'raw_base_bytes', $variants[0]['bytes'] );
|
||||
$this->assertSame( 1200, $variants[0]['width'] );
|
||||
$this->assertSame( 632, $variants[0]['height'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* A garbage filter return is coerced to 'none' (pass-through), never an
|
||||
* undefined rung.
|
||||
*/
|
||||
public function test_unknown_capability_value_is_treated_as_none(): void {
|
||||
Filters\expectApplied( 'prautoblogger_image_compose_capability' )->andReturn( 'webgpu' );
|
||||
|
||||
$composer = new \PRAutoBlogger_Image_Composer(
|
||||
$this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class )
|
||||
);
|
||||
$variants = $composer->compose( $this->image_data, $this->context() );
|
||||
|
||||
$this->assertCount( 1, $variants );
|
||||
$this->assertSame( 'featured', $variants[0]['role'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* A cached probe with a matching environment fingerprint is reused —
|
||||
* no re-probe, no option write. (update_option calls are captured via a
|
||||
* when() alias: an expect() here would be shadowed by the setUp stub.)
|
||||
*/
|
||||
public function test_matching_capability_cache_skips_probe_write(): void {
|
||||
$fingerprint = md5(
|
||||
PHP_VERSION . '|' . ( extension_loaded( 'imagick' ) ? 'im1' : 'im0' ) . '|' . ( extension_loaded( 'gd' ) ? 'gd1' : 'gd0' )
|
||||
);
|
||||
$this->stub_get_option(
|
||||
[
|
||||
\PRAutoBlogger_Image_Composer::OPTION_CAPABILITY => [
|
||||
'fingerprint' => $fingerprint,
|
||||
'capability' => 'none',
|
||||
],
|
||||
]
|
||||
);
|
||||
$writes = [];
|
||||
Functions\when( 'update_option' )->alias(
|
||||
function ( $key, $value = null, $autoload = null ) use ( &$writes ) {
|
||||
$writes[] = $key;
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
$composer = new \PRAutoBlogger_Image_Composer(
|
||||
$this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class )
|
||||
);
|
||||
$variants = $composer->compose( $this->image_data, $this->context() );
|
||||
|
||||
$this->assertSame( 'featured', $variants[0]['role'] );
|
||||
$this->assertSame( [], $writes, 'A matching cache fingerprint must not trigger a probe rewrite.' );
|
||||
}
|
||||
|
||||
/**
|
||||
* A stale fingerprint (host changed) triggers a fresh probe and a cache
|
||||
* rewrite — the auto-invalidation the spec requires. (Captured via a
|
||||
* when() alias: an expect() here would be shadowed by the setUp stub.)
|
||||
*/
|
||||
public function test_stale_capability_cache_reprobes_and_rewrites(): void {
|
||||
$this->stub_get_option(
|
||||
[
|
||||
\PRAutoBlogger_Image_Composer::OPTION_CAPABILITY => [
|
||||
'fingerprint' => 'old-host-fingerprint',
|
||||
'capability' => 'imagick',
|
||||
],
|
||||
]
|
||||
);
|
||||
$writes = [];
|
||||
Functions\when( 'update_option' )->alias(
|
||||
function ( $key, $value = null, $autoload = null ) use ( &$writes ) {
|
||||
$writes[] = [ $key, $value, $autoload ];
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
$composer = new \PRAutoBlogger_Image_Composer(
|
||||
$this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class )
|
||||
);
|
||||
$variants = $composer->compose( $this->image_data, $this->context() );
|
||||
|
||||
$this->assertSame( 'featured', $variants[0]['role'] );
|
||||
$this->assertCount( 1, $writes, 'A stale fingerprint must rewrite the capability cache.' );
|
||||
$this->assertSame( \PRAutoBlogger_Image_Composer::OPTION_CAPABILITY, $writes[0][0] );
|
||||
$this->assertIsArray( $writes[0][1] );
|
||||
$this->assertArrayHasKey( 'capability', $writes[0][1] );
|
||||
$this->assertNotSame( 'old-host-fingerprint', $writes[0][1]['fingerprint'] ?? '' );
|
||||
$this->assertFalse( $writes[0][2], 'Capability cache must not autoload.' );
|
||||
}
|
||||
|
||||
/**
|
||||
* QA note for the uninstall contract: every new option key keeps the
|
||||
* `prautoblogger_` prefix so uninstall.php's prefix-sweep purges it.
|
||||
* (Per spec, uninstall.php itself is intentionally untouched.)
|
||||
*/
|
||||
public function test_new_option_keys_keep_uninstall_sweep_prefix(): void {
|
||||
$keys = [
|
||||
\PRAutoBlogger_Image_Composer::OPTION_ENABLED,
|
||||
\PRAutoBlogger_Image_Composer::OPTION_VARIANTS,
|
||||
\PRAutoBlogger_Image_Composer::OPTION_MARK_ENABLED,
|
||||
\PRAutoBlogger_Image_Composer::OPTION_CAPABILITY,
|
||||
];
|
||||
|
||||
foreach ( $keys as $key ) {
|
||||
$this->assertStringStartsWith( 'prautoblogger_', $key );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,11 @@ class ImagePipelineTest extends BaseTestCase {
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
// v0.17.0: the composer caches its capability probe via update_option
|
||||
// and keeps a once-per-run warning flag that must not leak between tests.
|
||||
\PRAutoBlogger_Image_Composer::reset_run_state();
|
||||
Functions\when( 'update_option' )->justReturn( true );
|
||||
|
||||
// Mock WordPress functions used by the pipeline and its dependencies.
|
||||
Functions\when( 'is_wp_error' )->alias( function ( $thing ) {
|
||||
return $thing instanceof \WP_Error;
|
||||
@@ -346,6 +351,67 @@ class ImagePipelineTest extends BaseTestCase {
|
||||
$this->assertNotEmpty( $result['errors'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.17.0: composed OG/square variants are sideloaded and meta-linked —
|
||||
* role + base-attachment meta on the variant attachments, role-specific
|
||||
* image ID meta on the post, and the featured attachment tagged with its
|
||||
* role. Composition adds no cost-tracker rows beyond the primary image.
|
||||
*/
|
||||
public function test_composed_variants_are_sideloaded_and_meta_linked(): void {
|
||||
$image_result = [
|
||||
'bytes' => 'fake_image_data',
|
||||
'mime_type' => 'image/png',
|
||||
'width' => 1200,
|
||||
'height' => 632,
|
||||
'model' => 'test-model',
|
||||
'cost_usd' => 0.05,
|
||||
'latency_ms' => 2000,
|
||||
];
|
||||
|
||||
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
|
||||
$provider->method( 'estimate_cost' )->willReturn( 0.05 );
|
||||
$provider->method( 'generate_image_batch' )->willReturn( [ 'image_a' => $image_result ] );
|
||||
|
||||
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
|
||||
$cost_tracker->method( 'would_exceed_budget' )->willReturn( false );
|
||||
// Exactly one cost row: the primary image. Variants are $0 local renders.
|
||||
$cost_tracker->expects( $this->exactly( 1 ) )->method( 'log_image_generation' );
|
||||
|
||||
$composer = new class() implements \PRAutoBlogger_Image_Composer_Interface {
|
||||
public function compose( array $image_data, array $context ): array {
|
||||
return [
|
||||
[ 'bytes' => 'marked', 'mime_type' => 'image/png', 'width' => 1200, 'height' => 632, 'role' => 'featured' ],
|
||||
[ 'bytes' => 'og_png', 'mime_type' => 'image/png', 'width' => 1200, 'height' => 630, 'role' => 'og' ],
|
||||
[ 'bytes' => 'sq_png', 'mime_type' => 'image/png', 'width' => 1080, 'height' => 1080, 'role' => 'square' ],
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
Functions\when( 'get_temp_dir' )->justReturn( '/tmp/' );
|
||||
$next_id = 41;
|
||||
Functions\when( 'media_handle_sideload' )->alias( function () use ( &$next_id ) {
|
||||
return ++$next_id; // 42 featured, 43 og, 44 square.
|
||||
} );
|
||||
|
||||
$meta_calls = [];
|
||||
Functions\when( 'update_post_meta' )->alias( function ( $object_id, $key, $value ) use ( &$meta_calls ) {
|
||||
$meta_calls[] = [ $object_id, $key, $value ];
|
||||
return true;
|
||||
} );
|
||||
|
||||
$pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker, null, $composer );
|
||||
$result = $pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test' ], null );
|
||||
|
||||
$this->assertSame( 42, $result['image_a_id'] );
|
||||
$this->assertContains( [ 42, '_prautoblogger_image_role', 'featured' ], $meta_calls );
|
||||
$this->assertContains( [ 43, '_prautoblogger_image_role', 'og' ], $meta_calls );
|
||||
$this->assertContains( [ 43, '_prautoblogger_base_attachment_id', 42 ], $meta_calls );
|
||||
$this->assertContains( [ 1, '_prautoblogger_og_image_id', 43 ], $meta_calls );
|
||||
$this->assertContains( [ 44, '_prautoblogger_image_role', 'square' ], $meta_calls );
|
||||
$this->assertContains( [ 44, '_prautoblogger_base_attachment_id', 42 ], $meta_calls );
|
||||
$this->assertContains( [ 1, '_prautoblogger_square_image_id', 44 ], $meta_calls );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test graceful handling when one image in the batch returns an error.
|
||||
*/
|
||||
|
||||
238
tests/unit/Core/PromptRegistryTest.php
Normal file
238
tests/unit/Core/PromptRegistryTest.php
Normal file
@@ -0,0 +1,238 @@
|
||||
<?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' ) );
|
||||
}
|
||||
}
|
||||
183
tests/unit/Core/PublisherSideEffectsTest.php
Normal file
183
tests/unit/Core/PublisherSideEffectsTest.php
Normal file
@@ -0,0 +1,183 @@
|
||||
<?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 );
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,6 @@ namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
use Brain\Monkey\Actions;
|
||||
use Brain\Monkey\Filters;
|
||||
|
||||
class PublisherTest extends BaseTestCase {
|
||||
|
||||
@@ -36,6 +34,10 @@ 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 ); } );
|
||||
@@ -168,131 +170,33 @@ class PublisherTest extends BaseTestCase {
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that taxonomy terms are assigned based on article_type from the idea.
|
||||
* 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).
|
||||
*/
|
||||
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;
|
||||
public function test_save_as_draft_refuses_empty_content(): void {
|
||||
Functions\expect( 'wp_insert_post' )->never();
|
||||
|
||||
$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 );
|
||||
$this->expectException( \RuntimeException::class );
|
||||
$this->expectExceptionMessage( 'generated content is empty' );
|
||||
|
||||
$publisher->save_as_draft( '', $this->idea, $this->review, 'run_empty_draft' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that target keywords are set as post tags.
|
||||
* v0.18.1 belt-and-braces: publish() refuses content that is empty
|
||||
* once tags and whitespace are stripped (an empty HTML shell).
|
||||
*/
|
||||
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;
|
||||
public function test_publish_refuses_whitespace_only_html_content(): void {
|
||||
Functions\expect( 'wp_insert_post' )->never();
|
||||
|
||||
$publisher = new \PRAutoBlogger_Publisher();
|
||||
$publisher->publish( '<p>Content</p>', $this->idea, $this->review );
|
||||
|
||||
$this->assertSame( [ 'test', 'keyword', 'example' ], $captured_tags );
|
||||
}
|
||||
$this->expectException( \RuntimeException::class );
|
||||
$this->expectExceptionMessage( 'generated content is empty' );
|
||||
|
||||
/**
|
||||
* 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 );
|
||||
$publisher->publish( "<p> \n\t </p>", $this->idea, $this->review );
|
||||
}
|
||||
}
|
||||
|
||||
115
tests/unit/Core/RunReaperTest.php
Normal file
115
tests/unit/Core/RunReaperTest.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?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.
|
||||
}
|
||||
}
|
||||
140
tests/unit/Core/RunStageStateTest.php
Normal file
140
tests/unit/Core/RunStageStateTest.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?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' ) );
|
||||
}
|
||||
}
|
||||
98
tests/unit/Core/StageDisplayMapTest.php
Normal file
98
tests/unit/Core/StageDisplayMapTest.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?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' ) );
|
||||
}
|
||||
}
|
||||
240
tests/unit/Providers/OpenRouterCompletionGuardTest.php
Normal file
240
tests/unit/Providers/OpenRouterCompletionGuardTest.php
Normal file
@@ -0,0 +1,240 @@
|
||||
<?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'] );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,4 +177,89 @@ 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 );
|
||||
}
|
||||
}
|
||||
|
||||
223
tests/unit/Providers/OpenRouterProviderRetryTest.php
Normal file
223
tests/unit/Providers/OpenRouterProviderRetryTest.php
Normal file
@@ -0,0 +1,223 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
}
|
||||
189
tests/unit/Providers/OpenRouterRequestBuilderTest.php
Normal file
189
tests/unit/Providers/OpenRouterRequestBuilderTest.php
Normal file
@@ -0,0 +1,189 @@
|
||||
<?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 );
|
||||
}
|
||||
}
|
||||
17
uninstall.php
Executable file → Normal file
17
uninstall.php
Executable file → Normal file
@@ -31,6 +31,12 @@ $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 ) {
|
||||
@@ -81,11 +87,14 @@ $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 ) {
|
||||
$timestamp = wp_next_scheduled( $hook );
|
||||
if ( false !== $timestamp ) {
|
||||
wp_unschedule_event( $timestamp, $hook );
|
||||
}
|
||||
wp_clear_scheduled_hook( $hook );
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user