Compare commits
1 Commits
main
...
claude/rem
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d36a41864d |
@@ -1,135 +0,0 @@
|
||||
# GENERATED by peptide-e2e/tools/gitea-pipeline/render.py from repos.json.
|
||||
# Do not edit by hand: change repos.json or the template and re-render.
|
||||
name: CI / Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request: {}
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: pipeline-prautoblogger-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
lint-php:
|
||||
name: PHP Lint (${{ matrix.php }})
|
||||
runs-on: ubuntu-latest
|
||||
container: git.protopeer.com/peptide-repo/ci-php:2026-10
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
php: ["8.1", "8.2", "8.3"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: https://git.protopeer.com/peptide-repo/peptide-e2e/.gitea/actions/ci-lint-php@main
|
||||
with:
|
||||
php: ${{ matrix.php }}
|
||||
|
||||
phpcs:
|
||||
name: PHPCS (WordPress standards)
|
||||
runs-on: ubuntu-latest
|
||||
container: git.protopeer.com/peptide-repo/ci-php:2026-10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.head_ref || github.ref_name }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: https://git.protopeer.com/peptide-repo/peptide-e2e/.gitea/actions/ci-phpcs@main
|
||||
with:
|
||||
autofix: 'true'
|
||||
|
||||
file-size:
|
||||
name: 300-line rule (includes)
|
||||
runs-on: ubuntu-latest
|
||||
container: git.protopeer.com/peptide-repo/ci-php:2026-10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: https://git.protopeer.com/peptide-repo/peptide-e2e/.gitea/actions/ci-file-size@main
|
||||
with:
|
||||
paths: includes
|
||||
|
||||
phpunit:
|
||||
name: PHPUnit - brain-monkey (PHP ${{ matrix.php }})
|
||||
runs-on: ubuntu-latest
|
||||
container: git.protopeer.com/peptide-repo/ci-php:2026-10
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
php: ["8.1", "8.2", "8.3"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: https://git.protopeer.com/peptide-repo/peptide-e2e/.gitea/actions/ci-phpunit@main
|
||||
with:
|
||||
php: ${{ matrix.php }}
|
||||
tests: brain-monkey
|
||||
|
||||
check-constants:
|
||||
name: Check PRAUTOBLOGGER_* constants
|
||||
runs-on: ubuntu-latest
|
||||
container: git.protopeer.com/peptide-repo/ci-php:2026-10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use PHP 8.1 (preinstalled in the CI image)
|
||||
run: update-alternatives --set php /usr/bin/php8.1 && php -v | head -1
|
||||
# Fails if any PRAUTOBLOGGER_* constant referenced in shipped PHP is neither
|
||||
# defined in prautoblogger.php nor guarded by defined() (v0.19.0 incident class).
|
||||
- run: php scripts/check-constants.php
|
||||
- name: Self-test the scanner
|
||||
run: php scripts/check-constants.php --self-test
|
||||
|
||||
deploy-staging:
|
||||
name: Deploy to staging
|
||||
needs: ["lint-php", "phpcs", "file-size", "phpunit", "check-constants"]
|
||||
if: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' }}
|
||||
runs-on: peptide-vps
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: https://git.protopeer.com/peptide-repo/peptide-e2e/.gitea/actions/deploy@main
|
||||
with:
|
||||
stage: staging
|
||||
target_path: wp-content/plugins/prautoblogger
|
||||
ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
ssh_host: ${{ secrets.SSH_HOST }}
|
||||
ssh_port: ${{ secrets.SSH_PORT }}
|
||||
ssh_username: ${{ secrets.SSH_USERNAME }}
|
||||
|
||||
smoke:
|
||||
name: Smoke tests (staging)
|
||||
needs: deploy-staging
|
||||
runs-on: peptide-vps
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: https://git.protopeer.com/peptide-repo/peptide-e2e/.gitea/actions/smoke@main
|
||||
with:
|
||||
wp_admin_user: ${{ secrets.STAGING_WP_ADMIN_USER }}
|
||||
wp_admin_password: ${{ secrets.STAGING_WP_ADMIN_PASSWORD }}
|
||||
|
||||
deploy-production:
|
||||
name: Deploy to production
|
||||
# Smoke is a gate: production runs only if staging deploy AND smoke succeeded.
|
||||
# PROD_DEPLOY_ENABLED (org variable) is the cutover switch / kill switch.
|
||||
needs: [deploy-staging, smoke]
|
||||
if: ${{ vars.PROD_DEPLOY_ENABLED == 'true' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' }}
|
||||
runs-on: peptide-vps
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: https://git.protopeer.com/peptide-repo/peptide-e2e/.gitea/actions/deploy@main
|
||||
with:
|
||||
stage: production
|
||||
target_path: wp-content/plugins/prautoblogger
|
||||
ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
ssh_host: ${{ secrets.SSH_HOST }}
|
||||
ssh_port: ${{ secrets.SSH_PORT }}
|
||||
ssh_username: ${{ secrets.SSH_USERNAME }}
|
||||
|
||||
audit-direct-push:
|
||||
name: Audit - commit came from a PR
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
runs-on: ubuntu-latest
|
||||
container: git.protopeer.com/peptide-repo/ci-php:2026-10
|
||||
steps:
|
||||
- uses: https://git.protopeer.com/peptide-repo/peptide-e2e/.gitea/actions/audit-direct-push@main
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -168,13 +168,7 @@ prautoblogger/
|
||||
│ │ ├── class-review-queue.php # Approve/edit/reject queue for generated drafts
|
||||
│ │ ├── class-log-viewer.php # Activity log viewer with level filtering
|
||||
│ │ ├── class-gen-history-page.php # M4: Generation History hidden page (options.php parent, priority 14)
|
||||
│ │ ├── class-gen-history-query.php # M4: Paginated run list + per-run I/O extraction queries
|
||||
│ │ ├── class-pipeline-settings-step-map.php # P2b.5: step rail (research/curate/analysis/writer/editorial/seo/image/authority) + key allowlists
|
||||
│ │ ├── class-pipeline-settings-option-fields.php # P2b.5: contexts() (now includes curate/seo/authority) + sanitizer
|
||||
│ │ ├── class-pipeline-settings-option-fields-data.php # P2b.5: core context field arrays (delegates curate/seo/authority to Authority companion)
|
||||
│ │ ├── class-pipeline-settings-option-fields-data-authority.php # P2b.5 NEW: curate/seo/authority field arrays (split for 300-line rule)
|
||||
│ │ ├── class-pipeline-settings-save-handler.php # P2b.5: adds authority context + parse_and_save_category_tiers()
|
||||
│ │ └── class-pipeline-settings-renderer.php # P2b.5: special-cases prautoblogger_category_tiers_input in build_option_field_values()
|
||||
│ │ └── class-gen-history-query.php # M4: Paginated run list + per-run I/O extraction queries
|
||||
│ │
|
||||
│ ├── ajax/
|
||||
│ │ ├── class-model-registry-refresh.php # AJAX: refresh OpenRouter model registry (manual trigger)
|
||||
@@ -195,7 +189,7 @@ prautoblogger/
|
||||
│ │ ├── class-peptide-linker.php # Deterministic post-processor: hyperlinks peptide mentions to /peptides/ pages
|
||||
│ │ ├── class-chief-editor.php # Editor agent — LLM-powered editorial review
|
||||
│ │ ├── class-publisher.php # Creates WordPress posts from approved content
|
||||
│ │ ├── class-post-assembler.php # Post-creation helpers: taxonomy, log linking, images, sanitization. P2-2: imagery-suppressed guard in attach_generated_images()
|
||||
│ │ ├── class-post-assembler.php # Post-creation helpers: taxonomy, log linking, images, sanitization
|
||||
│ │ ├── 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)
|
||||
@@ -227,17 +221,6 @@ prautoblogger/
|
||||
│ │ ├── class-rerun-job-support.php # M3 queue/lock/budget/status-transient plumbing (v0.20.0)
|
||||
│ │ ├── class-request-recorder.php # B1: process-scoped outbound request-body stash -> generation_log.request_json (v0.20.0)
|
||||
│ │ ├── class-run-reaper.php # Stuck-run sweep + audit-payload retention (rides the #19 cron)
|
||||
│ │ ├── class-research-fanout.php # P2b.1: parallel specialist research agents (N×curl_multi, quorum, cost-reserve) — Authority only (v0.28.0)
|
||||
│ │ ├── class-research-batch.php # P2b.1: curl_multi execution layer for research agents (extracted from fanout, v0.28.0)
|
||||
│ │ ├── class-research-judge.php # P2b.1: curate stage — dedup+score fan-out results, write run_sources keep/discard (v0.28.0; dedup inlined back into judge in P2b.2)
|
||||
│ │ ├── class-research-source-scorer.php # P2b.1: source authority weighting for the judge (v0.28.0)
|
||||
│ │ ├── class-research-source-writer.php # P2b.1: run_sources DB writer for the curate stage; extracted from judge to satisfy 300-line rule (v0.28.0)
|
||||
│ │ ├── class-editorial-loop.php # P2b.2: bounded editorial loop — editor↔writer ≤editorial_max_rounds — Authority only (v0.29.0)
|
||||
│ │ ├── class-editorial-revision-caller.php # P2b.2: writer revision LLM step (extracted from editorial-loop for 300-line rule) (v0.29.0)
|
||||
│ │ ├── class-seo-stage.php # P2b.3: SEO stage — writes _prab_* post-meta (JSON-LD contract) + computes citation_score — Authority only (v0.30.0)
|
||||
# P2b.4: class-authority-pipeline.php Authority pipeline orchestrator 6-stage (v0.31.0)
|
||||
# P2b.4: class-authority-pipeline-stages.php stage helpers (v0.31.0)
|
||||
# P2b.4: class-tier-router.php per-category tier router (v0.31.0)
|
||||
│ │ ├── 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)
|
||||
@@ -255,12 +238,6 @@ prautoblogger/
|
||||
│ │ ├── interface-source-provider.php # Contract for any social media source
|
||||
│ │ ├── class-reddit-json-client.php # Reddit HTTP client — RSS (primary) + .json (fallback)
|
||||
│ │ ├── class-llm-research-provider.php # LLM deep research source (reasoning models)
|
||||
│ │ ├── interface-research-fanout.php # P2b.1: contract for parallel research fan-out (v0.28.0)
|
||||
│ │ ├── interface-research-judge.php # P2b.1: contract for the curate stage judge (v0.28.0)
|
||||
│ │ ├── interface-editorial-loop.php # P2b.2: contract for bounded editorial loop (v0.29.0)
|
||||
│ │ ├── interface-seo-stage.php # P2b.3: contract for the SEO stage (v0.30.0)
|
||||
# P2b.4: interface-tier-router.php contract for tier router (v0.31.0)
|
||||
# P2b.4: interface-authority-pipeline.php contract for Authority pipeline (v0.31.0)
|
||||
│ │ ├── class-reddit-provider.php # Reddit data collection orchestrator (RSS primary)
|
||||
│ │ ├── interface-image-provider.php # Contract for any image generation provider (incl. batch)
|
||||
│ │ ├── class-open-router-image-provider.php # OpenRouter image gen (single + batch dispatch)
|
||||
@@ -293,7 +270,6 @@ prautoblogger/
|
||||
│ ├── class-article-idea.php # Value object: scored article idea
|
||||
│ ├── class-content-request.php # Value object: generation request with params
|
||||
│ ├── class-editorial-review.php # Value object: editor verdict, scores, revised content
|
||||
│ ├── class-editorial-round.php # P2b.2: value object: one editorial loop round (round_number, notes, verdict, revised_content, scores) (v0.29.0)
|
||||
│ ├── class-generation-log.php # Value object: log entry for a generation run
|
||||
│ └── class-content-score.php # Value object: composite performance score
|
||||
│
|
||||
@@ -705,14 +681,6 @@ All prefixed with `prautoblogger_`:
|
||||
| `prautoblogger_image_compose_capability` | Cached capability probe `{fingerprint, capability}`; fingerprint = PHP version + imagick/gd presence, so it auto-invalidates on host changes |
|
||||
| `prautoblogger_board_poll_interval` | Mission Brief board poll interval in seconds (default 5, min 3). Localized into board.js. |
|
||||
| `prautoblogger_board_published_window_days` | Days back to show in the Published column (default 7). |
|
||||
| `prautoblogger_editorial_max_rounds` | Authority-tier editorial loop: max editor↔writer rounds before Review Queue escalation (int, default 3, range 1–10). P2b.2 (v0.29.0). |
|
||||
| `prautoblogger_citation_score_threshold` | Authority-tier SEO stage: minimum citation_score to pass the publish gate (float, default 0.0 — intentionally uncalibrated; calibrate after first ~10 Authority runs). P2b.3 (v0.30.0). |
|
||||
| `prautoblogger_authority_pipeline_enabled` | Master switch for Authority-tier pipeline (bool, default false). OFF = all generation uses Economy path (zero behaviour change). P2b.4 v0.31.0. |
|
||||
| `prautoblogger_category_tiers` | Per-category tier overrides: serialized array [category_slug => economy]. Economy is an explicit demote; default is Authority. P2b.4 v0.31.0. |
|
||||
| `prautoblogger_research_agent_count` | Authority fan-out agent count (int, default 3, range 1–5). Exposed in Research step panel. P2b.5 (v0.32.0). |
|
||||
| `prautoblogger_curate_model` | Model for the Curate (research judge) step. Exposed in Curate step panel. P2b.5 (v0.32.0). |
|
||||
| `prautoblogger_seo_model` | Model for the SEO stage LLM call. Exposed in SEO step panel. P2b.5 (v0.32.0). |
|
||||
| `prautoblogger_seo_instructions` | Custom instructions for the SEO stage LLM call (textarea). Exposed in SEO step panel. P2b.5 (v0.32.0). |
|
||||
|
||||
### Post Meta
|
||||
|
||||
@@ -724,7 +692,7 @@ Stored on every PRAutoBlogger-generated post:
|
||||
| `_prautoblogger_analysis_id` | FK to ab_analysis_results.id |
|
||||
| `_prautoblogger_source_ids` | JSON array of source data IDs used |
|
||||
| `_prautoblogger_model_used` | Model that generated the content |
|
||||
| `_prautoblogger_pipeline_mode` | 'single_pass', 'multi_step', or 'authority' |
|
||||
| `_prautoblogger_pipeline_mode` | 'single_pass' or 'multi_step' |
|
||||
| `_prautoblogger_total_cost` | Total USD cost for this post |
|
||||
| `_prautoblogger_total_tokens` | Total tokens consumed for this post |
|
||||
| `_prautoblogger_editor_verdict` | 'approved', 'revised', 'rejected' |
|
||||
@@ -734,13 +702,6 @@ Stored on every PRAutoBlogger-generated post:
|
||||
| `_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) |
|
||||
| `_prab_schema_version` | Int `1` — opt-in trigger for prcore JSON-LD emission (P2b.3 v0.30.0). Presence causes prcore to emit Drug/MedicalWebPage schema. |
|
||||
| `_prab_citations` | JSON array of kept research sources: `[{url, title, doi?, quality_score?}]` (P2b.3 v0.30.0). |
|
||||
| `_prab_about_peptides` | JSON array of related peptide post IDs (P2b.3 v0.30.0). |
|
||||
| `_prab_review_mode` | `editorial-system` (automated SEO stage) or `human` (set in P2b.4 on Review Queue approval). |
|
||||
| `_prab_reviewed_at` | ISO 8601 datetime of when the SEO stage ran (P2b.3 v0.30.0). |
|
||||
| `_prab_citation_score` | Float (stored as string): average quality_score of kept sources. Publish gate in P2b.4. |
|
||||
| `_prautoblogger_imagery_suppressed` | Set to 1 on articles held by citation gate or editorial escalation (P2b.4 v0.31.0). Suppresses image generation until human-approved. |
|
||||
|
||||
Composed-variant **attachment** meta (v0.17.0): every pipeline attachment gets
|
||||
`_prautoblogger_image_role` (`featured`/`og`/`square`); OG/square variants also
|
||||
@@ -1273,7 +1234,6 @@ which acquires the lock and runs orchestration within its own process. The
|
||||
| DB option | `prautoblogger_checkpoint_run_id` | Current run UUID (for finalize/cleanup paths) |
|
||||
| Transient | `prautoblogger_generation_status` | Status broadcast (polling by board/dossier JS) |
|
||||
| DB | `prautoblogger_runs` | Per-run ledger with status, cost ceiling |
|
||||
| DB option | `prautoblogger_editorial_max_rounds` | Max editorial loop rounds (int, default 3, range 1–10). P2b.2 (v0.29.0). |
|
||||
|
||||
### Budget / Halt Contract
|
||||
|
||||
|
||||
223
CHANGELOG.md
223
CHANGELOG.md
@@ -5,148 +5,6 @@ All notable changes to PRAutoBlogger will be documented in this file.
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project uses [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [0.32.2] - 2026-06-24
|
||||
|
||||
### Fixed
|
||||
- **P1 (canary) — quorum-miss HOLD no longer crashes as `failed`** (`class-authority-pipeline-stages.php`).
|
||||
`hold_as_draft()` now branches on whether draft content is present:
|
||||
- **No content** (quorum miss, pre-draft failure): records the hold decision to `run_decisions`
|
||||
only — Publisher is never called. Eliminates the `RuntimeException("Refusing to create draft
|
||||
post")` thrown by the v0.18.1 empty-content guard, which was causing the run to land as
|
||||
`failed` instead of `held`. No post is created; HOLD decision row is written; operator sees
|
||||
the run in the Review Queue (status `held-quorum`).
|
||||
- **With content** (editorial escalation, citation gate, cost ceiling after draft): saves draft
|
||||
post + suppresses imagery — behaviour unchanged from pre-fix.
|
||||
- **P2 — Authority posts now carry `_prautoblogger_pipeline_mode = 'authority'`** (`class-publisher.php`,
|
||||
`class-authority-pipeline.php`, `class-authority-pipeline-stages.php`). `Publisher::save_as_draft()`
|
||||
and `publish()` accept an optional `string $pipeline_mode = ''` parameter. When '' the value falls
|
||||
back to `get_option('prautoblogger_writing_pipeline', ...)` (Economy/single-pass unchanged). The
|
||||
Authority pipeline passes `'authority'` explicitly on every post creation call. Economy path is
|
||||
unaffected — callers that omit the param still read the option.
|
||||
|
||||
### Tests (PHPUnit — 556 tests, 0 failures)
|
||||
- `AuthorityPipelineTest::test_quorum_miss_hold_records_decision_no_post_created` — no post, HOLD
|
||||
decision row written, status=held-quorum.
|
||||
- `AuthorityPipelineTest::test_quorum_miss_does_not_throw` — run() must not throw on quorum miss.
|
||||
- `AuthorityPipelineTest::test_editorial_escalation_hold_saves_draft_with_content` — content-present
|
||||
path unchanged: draft post created, imagery suppressed.
|
||||
- `AuthorityPipelineTest::test_authority_published_post_gets_authority_pipeline_mode` — meta value is
|
||||
`authority`, not `single_pass`.
|
||||
- `PublisherTest::test_publish_pipeline_mode_defaults_to_option_value` — Economy omits param; option
|
||||
value (`single_pass`) written to meta.
|
||||
- `PublisherTest::test_publish_explicit_pipeline_mode_overrides_option` — Authority passes `'authority'`;
|
||||
meta = `authority` regardless of option.
|
||||
|
||||
## [0.32.1] - 2026-06-24
|
||||
|
||||
### Fixed
|
||||
- P2-A (IA): entry now reflects the 3 P2b.5 steps (curate, seo, authority). Key Options table updated with 7 new Authority option keys: , , , , , , .
|
||||
- P2-B (virtual field): in now skips any field whose option key ends in before calling . This prevents (the virtual textarea) from being persisted as a raw string; only the parsed array (written by ) is stored.
|
||||
- P2-C (stub test): upgraded from a tautological string-is-string assertion to real assertions against : all 6 v2 Authority stages (, , , , , ) must be , return non-empty labels, and have distinct labels.
|
||||
|
||||
## [0.32.0] - 2026-06-24
|
||||
### Added
|
||||
- P2b.5: Authority pipeline admin controls in Pipeline Settings — master toggle (`prautoblogger_authority_pipeline_enabled`), citation score gate (`prautoblogger_citation_score_threshold`), per-category tier map editor (`prautoblogger_category_tiers_input`/`prautoblogger_category_tiers`)
|
||||
- P2b.5: New pipeline steps in step rail: Curate (research judge model/prompts, Authority only) and SEO (post-meta model/prompts, Authority only) and Authority Settings step (master switch + tier map)
|
||||
- P2b.5: Research step gains Agent Count field (1–5, default 3, `prautoblogger_research_agent_count`); Editorial step gains Max Rounds field (1–5, default 3, `prautoblogger_editorial_max_rounds`)
|
||||
- P2b.5: New companion file `class-pipeline-settings-option-fields-data-authority.php` (curate/seo/authority field arrays, split for 300-line compliance)
|
||||
- P2-2: `Post_Assembler::attach_generated_images()` checks `_prautoblogger_imagery_suppressed` post meta and short-circuits when set; prevents imagery leaking on held Authority articles (citation gate miss, quorum miss, editorial escalation, or cost ceiling halt)
|
||||
- P2-1: Authority stage vocabulary + step rail integration tests (full Authority pipeline tests ship with P2b.1–P2b.4 classes; these stubs extend to admin integration)
|
||||
- P2-3: ARCHITECTURE.md file-tree entries for all P2b.5 changes in proper `├──` format
|
||||
|
||||
## [0.31.0] - 2026-06-24
|
||||
|
||||
### Added
|
||||
- **P2b.4 — tier routing + Authority pipeline orchestrator + citation/imagery gates**
|
||||
- `PRAutoBlogger_Tier_Router` (`core/class-tier-router.php`, 100 lines): resolves Authority vs Economy per-category. Master switch `prautoblogger_authority_pipeline_enabled` defaults FALSE — flag OFF means ALL generation uses the Economy single-pass path, byte-identical to pre-P2b.4.
|
||||
- `PRAutoBlogger_Tier_Router_Interface` (`providers/interface-tier-router.php`).
|
||||
- `PRAutoBlogger_Authority_Pipeline` (`core/class-authority-pipeline.php`, 247 lines): 6-stage orchestrator: research -> curate -> draft -> editorial -> seo -> publish gate. Cost-governor reserve/settle on every stage; `PRAutoBlogger_Cost_Ceiling_Exception` -> HOLD as draft (never force-complete). Citation gate: `citation_score >= threshold` to publish (default threshold 0.0, gate always passes until calibrated).
|
||||
- `PRAutoBlogger_Authority_Pipeline_Stages` (`core/class-authority-pipeline-stages.php`, 195 lines): stage helpers extracted for 300-line rule.
|
||||
- `PRAutoBlogger_Authority_Pipeline_Interface` (`providers/interface-authority-pipeline.php`).
|
||||
- **Article_Worker wired**: 3-line tier check before Economy path. Economy path unchanged.
|
||||
- **Imagery gate**: held articles get `_prautoblogger_imagery_suppressed = 1`; image pipeline skipped. Published articles proceed normally.
|
||||
- New options: `prautoblogger_authority_pipeline_enabled` (bool, default false), `prautoblogger_category_tiers` (serialized array, default empty = all Authority).
|
||||
- PHPUnit: `TierRouterTest` (6 tests) + `AuthorityPipelineTest` (6 tests). All 544 tests GREEN on VPS PHP 8.3.
|
||||
|
||||
### Updated
|
||||
- `ARCHITECTURE.md` — new files, options, post-meta, generation flow note (additions only).
|
||||
- `CONVENTIONS.md` — tier routing + master flag pattern.
|
||||
- `CONTEXT.md` — P2b.4 glossary section.
|
||||
- `prautoblogger.php` — version bumped to 0.31.0.
|
||||
- `uninstall.php` — new options covered by existing wildcard; `_prab_*` purge already present.
|
||||
|
||||
## [0.30.0] - 2026-06-24
|
||||
|
||||
### Fixed (QA P1/P2 sweep — post-a39fb75)
|
||||
- **P1 (uninstall purge):** Added second `_prab_%` DELETE to `uninstall.php` §3. The existing `_prautoblogger_%` wildcard does not match the six `_prab_*` keys written by the SEO stage. All six keys (`_prab_schema_version`, `_prab_citations`, `_prab_about_peptides`, `_prab_review_mode`, `_prab_reviewed_at`, `_prab_citation_score`) are now purged on plugin deletion.
|
||||
- **P2-2 (docblock clarity):** Updated `run()` side-effects note in `class-seo-stage.php` from "7 keys max" to "7 keys max — 6 written here; `_prab_reviewed_by` is P2b.4/human-approval only".
|
||||
- **P2-3 (test coverage):** Added `test_peptide_ids_encoded_as_json_int_array()` to `SeoStageTest` — asserts that a non-empty `$peptide_ids` array is JSON-encoded as a sequential integer array in `_prab_about_peptides`. Total: 9 tests GREEN on VPS PHP 8.3.
|
||||
|
||||
### Added
|
||||
- **P2b.3 — SEO stage: _prab_* meta writer + citation_score** — additive Authority-tier only; not wired into the live Economy (single-pass) path until P2b.4 (tier routing).
|
||||
- `PRAutoBlogger_Seo_Stage` (`core/class-seo-stage.php`, 173 lines) — deterministic (no LLM calls) meta-writer. Writes all keys from the ratified JSON-LD contract v1 (`convo/prcore/decisions/2026-06-11-jsonld-contract-v1.md`) to the published post so prcore can emit Drug/MedicalWebPage schema. Computes `citation_score` = average `quality_score` of kept sources (0.0–1.0). Records `run_stages` start→done (`agent_role=seo`) and a `run_decisions` row (`stage=seo, verdict=scored`). Reads `prautoblogger_citation_score_threshold` option (default `0.0`) and logs it — the publish gate acting on this score is P2b.4. Economy single-pass path untouched.
|
||||
- `PRAutoBlogger_Seo_Stage_Interface` (`providers/interface-seo-stage.php`) — contract for `run()`.
|
||||
- New option `prautoblogger_citation_score_threshold` (float, default 0.0, intentionally uncalibrated). Uninstall purges it with the existing `LIKE 'prautoblogger\_%'` wildcard.
|
||||
- _prab_* meta keys written: `_prab_schema_version` (int 1), `_prab_citations` (JSON kept sources), `_prab_about_peptides` (JSON peptide IDs), `_prab_review_mode` ('editorial-system'), `_prab_reviewed_at` (ISO 8601 datetime), `_prab_citation_score` (float as string). `_prab_reviewed_by` is NOT written by this stage (human approval only, P2b.4).
|
||||
- PHPUnit: `SeoStageTest` — 8 tests covering schema_version=1, citations from kept sources, review_mode=editorial-system, citation_score computation (avg quality_score), score stored as post-meta, empty-sources score=0.0, threshold option read, run_stages start+done called. All 531 tests GREEN on VPS PHP 8.3.
|
||||
|
||||
### Updated
|
||||
- `ARCHITECTURE.md` — SEO stage added to Phase 2b file tree, options table (`prautoblogger_citation_score_threshold`), and post-meta table (`_prab_*` keys).
|
||||
- `CONVENTIONS.md` — Authority Pipeline SEO Stage section: design rules, key patterns, `_prab_*` key reference table.
|
||||
- `CONTEXT.md` — New P2b.3 glossary section: SEO stage, JSON-LD contract v1, citation_score, citation_score_threshold, _prab_* key definitions.
|
||||
|
||||
## [0.29.0] - 2026-06-24
|
||||
|
||||
### Added
|
||||
- **P2b.2 — bounded `Editorial_Loop`** — additive Authority-tier only; not wired into the live Economy (single-pass) path until P2b.4 (tier routing).
|
||||
- `PRAutoBlogger_Editorial_Loop` (`core/class-editorial-loop.php`, 300 lines) — iterative editor↔writer loop bounded by `prautoblogger_editorial_max_rounds` (new `get_option` key, default 3, configurable 1–10). Each round: Chief_Editor critiques → if 'approved' return content; if 'revised'/'rejected' get revised content → record round → repeat. After max rounds without approval, escalates to Review Queue (saves as draft, returns ''). Records every round to `run_decisions` (one row per round: verdict + "Round N: <notes>" rationale) and `run_stages` (start→done for role='editor' per round; start→done for role='writer' when writer revision is dispatched). Implements `PRAutoBlogger_Editorial_Loop_Interface`. `was_escalated()` returns true on exhaustion. `get_rounds()` returns the full `PRAutoBlogger_Editorial_Round[]` history.
|
||||
- `PRAutoBlogger_Editorial_Loop_Interface` (`providers/interface-editorial-loop.php`) — contract for `run()` + `was_escalated()`.
|
||||
- `PRAutoBlogger_Editorial_Revision_Caller` (`core/class-editorial-revision-caller.php`, 138 lines) — extracted writer-revision step (split from Editorial_Loop for 300-line compliance). Calls the writer LLM with `Content_Prompts::build_revision_system/user()`, logs cost via `Cost_Tracker`, manages run_stage start→done for role='writer'. Returns prior draft unchanged on empty LLM response (never silent pass).
|
||||
- `PRAutoBlogger_Editorial_Round` (`models/class-editorial-round.php`) — immutable value object capturing one loop iteration (round_number, editor_notes, editor_verdict, revised_content, quality_score, seo_score). `to_array()` for audit JSON snapshot.
|
||||
- `Content_Prompts::build_revision_system()` + `build_revision_user()` — two new static methods for revision LLM prompts; single-pass path untouched.
|
||||
- New option `prautoblogger_editorial_max_rounds` (int, default 3, range 1–10). Uninstall purges it with the existing `LIKE 'prautoblogger\_%'` wildcard.
|
||||
- PHPUnit: `EditorialLoopTest` — 8 tests covering approval on round 1, escalation after max rounds, floor (min=1) clamp, cap (max=10) clamp, round recording to `run_decisions`, escalation recorded as single 'escalated' row, inline revision path (editor-provided `revised_content` skips writer call), writer revision path (Editorial_Revision_Caller invoked on 'rejected' verdict). All 523 tests GREEN on VPS PHP 8.3.
|
||||
|
||||
### Updated
|
||||
- `ARCHITECTURE.md` — Editorial_Loop added to Phase 2b file tree, options table (`prautoblogger_editorial_max_rounds`), and pipeline data flow note.
|
||||
- `CONVENTIONS.md` — Loop-bounded editorial pattern documented.
|
||||
- `CONTEXT.md` — New P2b.2 glossary section: editorial loop, editorial_max_rounds, round record, Review Queue escalation.
|
||||
|
||||
## [0.28.0] - 2026-06-23
|
||||
|
||||
### Added
|
||||
- **P2b.1 — Research_Fanout + Research_Judge (the `curate` stage)** — additive only; not wired into the live Economy (single-pass) path until P2b.4 (tier routing).
|
||||
- `PRAutoBlogger_Research_Fanout` — dispatches N specialist LLM research agents in parallel via `curl_multi` (default 3 agents, configurable 2–5 via `prautoblogger_research_agent_count`; MIN_AGENTS=2 because quorum=⌈N/2⌉+1=2 makes N=1 unsatisfiable). Reserves the SUMMED worst-case cost of the entire batch from the per-run cost governor before dispatch (atomic conditional write — concurrent writers cannot slip past the per-run ceiling). Quorum check: ⌈N/2⌉+1 agents must return usable results, else returns empty array (caller holds the run). Invalid/schema-mismatched agent results are excluded and never silently passed. Per-agent `run_stages` rows written with `researcher:<role>` agent_role. `settle()` wrapped in `try/finally` — always settles the reservation even if `batch->execute()` throws, preventing phantom reserved_usd in the run ledger.
|
||||
- `PRAutoBlogger_Research_Batch` — extracted curl_multi execution layer (mirrors `class-open-router-image-batch.php` pattern); keeps Research_Fanout under 300 lines and independently testable.
|
||||
- `PRAutoBlogger_Research_Judge` — the `curate` stage; deduplicates sources by URL-canonical match then semantic similarity (OpenRouter embeddings / cosine similarity, matching Semantic_Dedup), with keyword-overlap fallback. Assigns `quality_score` = relevance × source-type authority weight. Writes `run_sources` rows (kept=1 / kept=0 with reason + quality_score) via `Audit_Writer`. Returns top-12 scored sources for the draft stage.
|
||||
- `PRAutoBlogger_Research_Source_Scorer` — authority weighting helper (peer-reviewed→1.0, institutional→0.85, preprint→0.70, HTTPS→0.60, HTTP→0.40); extracted from judge to keep line count under 300.
|
||||
- Both subsystems behind interfaces (`PRAutoBlogger_Research_Fanout_Interface`, `PRAutoBlogger_Research_Judge_Interface`) for provider-swappability. Stage_Display_Map already contains `research` + `curate` from v0.18.0; no new stage vocabulary added.
|
||||
- PHPUnit: `ResearchFanoutTest` (quorum logic, cost-reserve wiring, ceiling-breach abort, partial-failure) + `ResearchJudgeTest` (dedup, quality_score, run_sources writes, graceful absent-table degradation).
|
||||
- `CONTEXT.md` Phase 2b P2b.1 section — glossary entries for fan-out, Research_Fanout, curate stage, Research_Judge, quorum, specialist role, quality_score, authority weight, MIN_AGENTS floor.
|
||||
|
||||
### Fixed (CI green — post-17cef8a testfix)
|
||||
- **ResearchFanoutTest void log_api_call mock:** Removed willReturn(null) from make_cost_tracker(); log_api_call is declared void so PHPUnit raises IncompatibleReturnValueException. Fix: drop willReturn, keep bare method() call only.
|
||||
- **ResearchFanoutTest Run_Context::set() undefined:** Changed PRAutoBlogger_Run_Context::set() to ::set_run_id() in test_ceiling_breach_exception_aborts_before_dispatch; the class API is set_run_id/current_run_id/clear only.
|
||||
- **ResearchJudgeTest keyword-dedup collapses distinct sources to 1 (code regression):** deduplicate() keyword-overlap fallback lacked a minimum keyword count guard. Generic source titles (Source 0 / Excerpt 0) produce only 2 shared keywords, collapsing all sources to 1 unique. Fix: require count(kw) >= 3 before the overlap loop (mirrors Semantic_Dedup pattern); URL-exact dedup already handles same-URL duplicates; sparse keyword sets pass through unconditionally.
|
||||
|
||||
### Fixed (QA P1/P2 sweep — post-721bf8a)
|
||||
- **P1-1:** Added Phase 2b P2b.1 glossary section to `CONTEXT.md` (DoD v1.2.0 §7 — new domain terms must be defined same PR).
|
||||
- **P2-2:** Wrapped `batch->execute()` + results loop + `settle()` in `try/finally` so the cost-governor reservation is ALWAYS settled/released even if an unexpected exception is thrown, preventing phantom `reserved_usd` in the run ledger.
|
||||
- **P2-1:** Added `test_ceiling_breach_exception_aborts_before_dispatch()` to `ResearchFanoutTest` — asserts that when `open_amount_reservation()` throws `PRAutoBlogger_Cost_Ceiling_Exception`, `batch->execute()` is never called (enforces cost-governance contract).
|
||||
- **P2-3:** Raised `MIN_AGENTS` from 1 to 2 in `Research_Fanout` — with quorum=⌈N/2⌉+1, N=1 makes quorum=2 which can never be satisfied; floor of 2 ensures the minimum fan-out can actually succeed.
|
||||
|
||||
## [0.27.1] - 2026-06-23
|
||||
|
||||
### Fixed (M4 P2 cleanup)
|
||||
- **P2-1 (safety cap):** Added `LIMIT 100` to the `generation_log` query inside
|
||||
`Gen_History_Query::get_run_io()` (`includes/admin/class-gen-history-query.php`).
|
||||
A run has ~6–10 stage rows in practice; 100 is a generous safety cap that prevents
|
||||
a pathological run with many log rows from blowing up the AJAX payload.
|
||||
- **P2-2 (test):** Implemented the declared-but-missing
|
||||
`test_get_page_returns_empty_when_wpdb_null()` test in
|
||||
`tests/unit/Admin/GenHistoryQueryTest.php`. Confirms the `get_page()` null-wpdb
|
||||
guard returns `array( 'rows' => array(), 'total' => 0 )`.
|
||||
|
||||
## [0.27.0] - 2026-06-23
|
||||
|
||||
### Fixed (QA P1/P2 sweep — post-3cd2843)
|
||||
@@ -1730,4 +1588,83 @@ swap with no user-visible change in article quality.
|
||||
### Changed
|
||||
- **CI/CD pipeline now runs PHPCS and PHPUnit** before deploying.
|
||||
Added `shivammathur/setup-php@v2` for PHP 8.1, Composer install,
|
||||
WordPress Coding Standards check, and f
|
||||
WordPress Coding Standards check, and full PHPUnit test suite.
|
||||
PHPCS runs in report-only mode (`|| true`) while codebase is
|
||||
brought into full compliance; PHPUnit failures block deploy.
|
||||
- Removed stub source providers (TikTok, Instagram, YouTube).
|
||||
These were dead code — not registered in Source_Collector, threw
|
||||
RuntimeException if enabled, and confused AI agents reading the
|
||||
codebase. The `Source_Provider_Interface` remains the contract for
|
||||
adding future platforms (see CONVENTIONS.md).
|
||||
- Removed "coming soon" checkboxes from Enabled Sources setting.
|
||||
- Updated ARCHITECTURE.md: removed stub file references, added
|
||||
cross-system LLM budget coordination section documenting how
|
||||
PRAutoBlogger and Peptide News share an OpenRouter account.
|
||||
|
||||
### Improved
|
||||
- **PublisherTest rewritten with real behavioral assertions.**
|
||||
Now tests: post_status ('publish' vs 'draft'), generation metadata
|
||||
storage, title from idea, taxonomy assignment, tag assignment,
|
||||
run_id-based log linking, RuntimeException on WP_Error, action/filter
|
||||
hook firing. Replaces previous method-existence-only checks.
|
||||
|
||||
|
||||
## [0.2.1] — 2026-04-12
|
||||
|
||||
### Changed
|
||||
- **Reddit data source switched to RSS primary, .json fallback.**
|
||||
PullPush.io was frequently stale/unavailable. Reddit RSS/Atom feeds work
|
||||
reliably from datacenter IPs (Hostinger) where .json gets 403. Comments
|
||||
are still fetched via .json (unavailable in RSS).
|
||||
- Updated "Enabled Sources" checkbox label to "Reddit (RSS + .json)".
|
||||
- Updated "Test Connections" to show RSS + .json status.
|
||||
- Source status indicator now shows Reddit RSS (Primary) and .json (Fallback).
|
||||
|
||||
### Removed
|
||||
- `class-pull-push-client.php` — replaced by RSS feeds in `class-reddit-json-client.php`.
|
||||
- All PullPush.io references from admin UI, docs, and provider code.
|
||||
|
||||
### Fixed
|
||||
- Generate Now button now always sends `force: '1'` to clear stale generation locks.
|
||||
- Fixed encryption double-encryption bug with `enc:` prefix detection.
|
||||
|
||||
## [0.2.0] — 2026-04-12
|
||||
|
||||
### Changed
|
||||
- **Reddit data source migrated from OAuth to RSS + .json (no auth required).**
|
||||
Reddit rejected our API application; RSS feeds and .json endpoints are free
|
||||
and require no authentication.
|
||||
- Removed Reddit Client ID and Client Secret fields from admin API Keys tab.
|
||||
- Updated "Test Connections" results to show Reddit source status.
|
||||
|
||||
### Added
|
||||
- Source status indicator in API Keys tab showing RSS and .json
|
||||
availability with live status dots and last-collection timestamp.
|
||||
- Configurable research cache TTL (1–72 hours) in Sources tab.
|
||||
- Reddit time window selector (24h / week / month) in Sources tab.
|
||||
- Posts-per-subreddit limit (5–100) in Sources tab.
|
||||
- LiteSpeed cache purge step in CI/CD deploy pipeline.
|
||||
|
||||
## [0.1.0] — 2026-04-10
|
||||
|
||||
### Added
|
||||
- Initial plugin scaffold with WordPress-native architecture.
|
||||
- OpenRouter LLM provider with configurable model selection and retry logic.
|
||||
- Reddit API source provider (OAuth2 script app) for collecting posts and comments.
|
||||
- Content analysis engine: detects recurring questions, complaints, and comparisons.
|
||||
- Idea scorer with deduplication against existing posts.
|
||||
- Configurable writer agent pipeline (single-pass or multi-step: outline → draft → polish).
|
||||
- Chief editor agent for LLM-powered editorial review before publishing.
|
||||
- Publisher with full generation metadata stored as post_meta.
|
||||
- Cost tracker with per-call logging, monthly budget enforcement, and hard-stop.
|
||||
- Metrics collector: WordPress native + GA4 integration + composite content scoring.
|
||||
- Admin settings page with sections for API keys, models, content, sources, schedule, and analytics.
|
||||
- Metrics dashboard showing monthly spend, budget utilization, cost by stage, and daily spend.
|
||||
- Post metabox showing generation metadata on PRAutoBlogger-generated posts.
|
||||
- Admin notices for missing API keys, budget warnings, and configuration hints.
|
||||
- Stub providers for TikTok, Instagram, and YouTube (future implementation).
|
||||
- Encrypted storage for API keys using AES-256-CBC with wp_salt().
|
||||
- AJAX-powered "Generate Now" and "Test Connections" buttons.
|
||||
- Custom database tables for source data, analysis results, generation logs, and content scores.
|
||||
- Clean uninstall handler removing all plugin data.
|
||||
- ARCHITECTURE.md and CONVENTIONS.md for AI-readable codebase documentation.
|
||||
|
||||
65
CONTEXT.md
65
CONTEXT.md
@@ -157,68 +157,3 @@ Full table names are defined in `class-activator.php`.
|
||||
| **inspector rail** | The persistent right-hand panel that opens when a run row is clicked. Fetches per-stage I/O via AJAX (`prautoblogger_board_inspector`) and renders the full stage breakdown — status dots, model, cost, expandable prompt/response text, total cost receipt, and an "Open dossier" CTA — without navigating away from the board. Class: `PRAutoBlogger_Board_Inspector_Handler`. |
|
||||
| **dot-rail** | The lightweight row-level stage-progress indicator in the Mission Brief run list. Each stage in the run is represented by a small dot coloured by its status (e.g. complete = teal, failed = red). Populated by `PRAutoBlogger_Board_Stage_Dots::enrich()`, which adds a `run_stages_summary` key to each card. |
|
||||
| **run_stages_summary** | A card-level enrichment key added by `PRAutoBlogger_Board_Stage_Dots::enrich()`. Array of `{stage, status}` objects for each stage in the run, sourced from the `wp_prautoblogger_run_stages` table in a single batched query per board section. Consumed by `board.js` to render the dot-rail. |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2b P2b.1 — Research_Fanout + Research_Judge (v0.28.0)
|
||||
|
||||
These terms are introduced by the Authority-tier research pipeline (additive; not wired
|
||||
into the live Economy path until P2b.4).
|
||||
|
||||
| Term | Definition |
|
||||
|------|-----------|
|
||||
| **fan-out** | The pattern of dispatching N independent LLM requests in parallel (via `curl_multi`), each covering a different specialist angle of the same topic, then collecting and filtering results. Contrasts with the Economy single-pass where one LLM call produces all research. |
|
||||
| **Research_Fanout** | `PRAutoBlogger_Research_Fanout` — the orchestrator class that implements the fan-out pattern. Reserves the SUMMED worst-case cost of all N agents from the cost governor before dispatch, applies quorum enforcement after, and returns per-agent result arrays to the caller. Not live until P2b.4 wires it into the tier router. |
|
||||
| **curate stage** | The pipeline stage (`stage = 'curate'`) that follows fan-out. Implemented by `PRAutoBlogger_Research_Judge`. Deduplicates sources from all agents, scores each by `quality_score`, and writes the top-12 results to `run_sources`. Already present in `Stage_Display_Map` since v0.18.0. |
|
||||
| **Research_Judge** | `PRAutoBlogger_Research_Judge` — the class that executes the curate stage. Dedup chain: URL-canonical → semantic cosine (OpenRouter embeddings) → keyword-overlap fallback. Scores via `Research_Source_Scorer`. Writes `kept=1` / `kept=0` rows to `run_sources` with `quality_score` and `reason`. |
|
||||
| **quorum** | Minimum number of research agents that must return usable results for the fan-out to proceed. Formula: ⌈N/2⌉+1 (one strict majority). Example: N=3 → quorum=3; N=5 → quorum=4. If fewer than quorum agents return valid results, `Research_Fanout::dispatch()` returns an empty array and the pipeline holds the run (status `held-quorum`). v0.32.2: quorum-miss hold no longer calls Publisher — only a `run_decisions` row is written (no draft post created; Publisher would throw on empty content). |
|
||||
| **specialist role** | One of `{mechanisms, clinical, safety, comparison, practical}`. Each agent in the fan-out is assigned a distinct role and receives a role-specific user prompt. The first N roles in priority order are used (N = `prautoblogger_research_agent_count`). Written to `run_stages` as `agent_role = 'researcher:<role>'`. |
|
||||
| **quality_score** | A composite score for a source: `relevance × authority_weight`. `relevance` comes from the agent's self-reported score (0.0–1.0). `authority_weight` is the source-type multiplier from `Research_Source_Scorer`. Higher scores sort the source closer to the top-12 kept set. Stored in `run_sources.quality_score`. |
|
||||
| **authority weight** | A source-type multiplier applied by `PRAutoBlogger_Research_Source_Scorer` to produce `quality_score`. Values: DOI/PubMed/NCBI peer-reviewed → 1.0; .gov/.edu/WHO/FDA institutional → 0.85; preprint → 0.70; HTTPS → 0.60; HTTP → 0.40. Reflects the editorial preference for peer-reviewed sources in Authority-tier articles. |
|
||||
| **MIN_AGENTS floor** | `Research_Fanout::MIN_AGENTS = 2`. The minimum configurable agent count. Set to 2 (not 1) because quorum = ⌈N/2⌉+1 = 2 for N=1, making a single-agent dispatch mathematically impossible to satisfy. Clamping to 2 ensures at least a 2-agent fan-out where quorum=2 is achievable (both must succeed). |
|
||||
|
||||
## Phase 2b P2b.2 — Editorial_Loop (v0.29.0)
|
||||
|
||||
These terms are introduced by the Authority-tier bounded editorial loop (additive; not wired
|
||||
into the live Economy path until P2b.4).
|
||||
|
||||
| Term | Definition |
|
||||
|------|-----------|
|
||||
| **editorial loop** | The iterative editor↔writer review pattern used in the Authority tier. Each round: `Chief_Editor::review()` critiques the draft → if approved, loop exits; if not, the writer revises the draft via `Editorial_Revision_Caller::call()` → repeat. After max rounds without approval the article is **escalated to the Review Queue**. Contrasts with the Economy single-pass where `Chief_Editor::review()` runs once, no revision loop. |
|
||||
| **editorial_max_rounds** | The `prautoblogger_editorial_max_rounds` WordPress option (int, default 3, clamped to [1, 10]). Controls how many editor↔writer rounds may occur before escalation. Configurable without a code change. |
|
||||
| **round record** | An instance of `PRAutoBlogger_Editorial_Round` capturing one completed loop iteration. Fields: `round_number` (1-based), `editor_notes` (critique), `editor_verdict` ('approved'/'revised'/'rejected'), `revised_content` (writer output for that round, '' on approval), `quality_score`, `seo_score`. Serialised via `to_array()` for JSON audit snapshots in `run_stages` and the loop escalation record. |
|
||||
| **Review Queue escalation** | When `editorial_max_rounds` are exhausted without the editor approving, the article is saved as a WordPress draft (Review Queue) rather than published. `Editorial_Loop::run()` returns `''` and `was_escalated()` returns `true`. One `run_decisions` row is written with `verdict='escalated'` and a rationale noting the max rounds exhausted. |
|
||||
| **inline revision** | When `Chief_Editor::review()` returns a non-null `revised_content` in the `PRAutoBlogger_Editorial_Review` object (verdict='revised'), that content is used directly for the next round without invoking `Editorial_Revision_Caller`. Reduces LLM calls when the editor can self-correct. |
|
||||
| **Editorial_Revision_Caller** | `PRAutoBlogger_Editorial_Revision_Caller` — the extracted writer revision step. Builds revision prompts via `Content_Prompts::build_revision_system/user()`, dispatches the writer LLM call (model from `prautoblogger_writing_model`), manages `run_stages` start→done for `role='writer'`, and logs cost. Extracted from `Editorial_Loop` to keep both classes under 300 lines. |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2b P2b.3 — SEO Stage + citation_score (v0.30.0)
|
||||
|
||||
These terms are introduced by the Authority-tier SEO stage (additive; not wired
|
||||
into the live Economy path until P2b.4).
|
||||
|
||||
| Term | Definition |
|
||||
|------|-----------|
|
||||
| **SEO stage** | The pipeline stage (`stage = 'seo'`) that writes `_prab_*` post-meta to the published post so that peptide-repo-core can emit JSON-LD schema (Drug/MedicalWebPage). Implemented by `PRAutoBlogger_Seo_Stage`. Deterministic — no LLM calls. |
|
||||
| **JSON-LD contract v1** | The ratified set of `_prab_*` meta keys agreed between PRAB (writer) and prcore (reader). Canonical source: `convo/prcore/decisions/2026-06-11-jsonld-contract-v1.md`. The SEO stage writes all keys except `_prab_reviewed_by` (P2b.4 only). |
|
||||
| **citation_score** | Average `quality_score` of kept research sources. Formula: `sum(quality_score) / count(sources)`. Returns `0.0` for empty source lists. Stored as `_prab_citation_score` post-meta (string representation of float). Ranges 0.0–1.0. |
|
||||
| **citation_score_threshold** | The minimum `citation_score` required to pass the publish gate. Option key: `prautoblogger_citation_score_threshold`. Default `0.0` — intentionally uncalibrated until ~10 Authority runs provide a distribution baseline. The gate itself is in P2b.4; this stage reads and logs the threshold but does not enforce it. |
|
||||
| **_prab_schema_version** | Integer `1`. The presence of this key on a post is the opt-in trigger for prcore's JSON-LD emission. Absent = no schema emitted (Economy posts never have it). |
|
||||
| **_prab_review_mode** | String: `editorial-system` (automated SEO stage, set here) or `human` (set in P2b.4 when a human approves via the Review Queue). Only one value is written per post. |
|
||||
| **_prab_reviewed_at** | ISO 8601 datetime of when the SEO stage executed. Set by `gmdate('Y-m-d\TH:i:s')` at run time. |
|
||||
| **_prab_reviewed_by** | WP user ID of the Review Queue approver. **NOT written by P2b.3**. Set exclusively by P2b.4 on human approval. |
|
||||
| **_prab_citations** | JSON-encoded array of kept research sources: `[{url, title, doi?, quality_score?}]`. Derived from the `$kept_sources` array passed by the curate stage. |
|
||||
| **_prab_about_peptides** | JSON-encoded array of related peptide post IDs. Populated from P2b.4 peptide linkage; defaults to `[]` in P2b.3 (the SEO stage accepts the array as a parameter for future-compatibility). |
|
||||
|
||||
## P2b.4 Glossary (Tier Router + Authority Pipeline Orchestrator)
|
||||
|
||||
| Term | Definition |
|
||||
|------|-----------|
|
||||
| **Tier routing** | The process of routing an article idea to Authority or Economy (single-pass) path. Implemented in `PRAutoBlogger_Tier_Router::resolve()`. |
|
||||
| **Master flag** | `prautoblogger_authority_pipeline_enabled` (default `false`). When OFF, ALL generation uses Economy path — zero behaviour change from pre-P2b.4. Must be explicitly enabled by an operator. |
|
||||
| **Category tier map** | `prautoblogger_category_tiers` — serialized array `[category_slug => economy]`. Only `economy` is a meaningful explicit demote; unclassified categories default to Authority. |
|
||||
| **Authority Pipeline** | `PRAutoBlogger_Authority_Pipeline` — 6-stage orchestrator: research -> curate -> draft -> editorial -> seo -> publish gate. Wires together P2b.1+P2b.2+P2b.3 subsystems. |
|
||||
| **Citation gate** | Publish gate in the Authority pipeline: `citation_score >= threshold` to publish; else hold as draft with imagery suppressed. Default threshold `0.0` (gate always passes until calibrated). |
|
||||
| **Imagery gate** | `_prautoblogger_imagery_suppressed = 1` written on held articles. Signals image pipeline to skip generation. Not set on published articles. |
|
||||
| **Cost ceiling halt** | `PRAutoBlogger_Cost_Ceiling_Exception` caught at Authority pipeline top level. Article saved as draft with status `halted`; never force-completed. |
|
||||
|
||||
193
CONVENTIONS.md
193
CONVENTIONS.md
@@ -711,196 +711,3 @@ Every pipeline-style page must have PHPUnit coverage for:
|
||||
|
||||
Place tests under `tests/unit/Admin/PipelineSettings*Test.php`, extending
|
||||
`PRAutoBlogger\Tests\BaseTestCase`.
|
||||
|
||||
---
|
||||
|
||||
## Authority Pipeline — Phase 2b conventions (v0.28.0)
|
||||
|
||||
### Adding a new pipeline stage
|
||||
|
||||
Each stage is a self-contained class behind an interface. Pattern:
|
||||
|
||||
1. Create `includes/providers/interface-<stage>-<role>.php` (the contract).
|
||||
2. Create `includes/core/class-<stage>-<role>.php` implementing it.
|
||||
3. If the implementation grows past 200 lines of logic, extract helpers into a
|
||||
`class-<stage>-<helper>.php` — see `Research_Batch` (curl_multi layer) and
|
||||
`Research_Source_Scorer` (weighting math) as examples.
|
||||
4. Register the stage in `Stage_Display_Map::MAP` if it is net-new vocabulary.
|
||||
5. PHPUnit tests must cover: quorum/gating logic, cost-reserve call verification,
|
||||
invalid-output exclusion, graceful absent-table degradation.
|
||||
|
||||
### Research fan-out settings
|
||||
|
||||
| Option key | Default | Notes |
|
||||
|---|---|---|
|
||||
| `prautoblogger_research_agent_count` | 3 | Clamped 1–5. |
|
||||
| `prautoblogger_research_model` | `PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL` | Per-run; read at call time. |
|
||||
|
||||
### Cost-reserve discipline for fan-out batches
|
||||
|
||||
Before dispatching N parallel calls, always reserve the SUMMED worst-case estimate:
|
||||
|
||||
```php
|
||||
$per_estimate = PRAutoBlogger_Cost_Governor::estimate_chat_cost( $model, $messages[0], $options );
|
||||
$reservation = PRAutoBlogger_Cost_Governor::open_amount_reservation(
|
||||
$per_estimate * $n,
|
||||
"stage_name:n={$n}:{$model}"
|
||||
);
|
||||
// ... dispatch ...
|
||||
PRAutoBlogger_Cost_Governor::settle( $reservation, $actual_total );
|
||||
```
|
||||
|
||||
Never reserve inside the per-agent loop — that creates a race between the ceiling
|
||||
check and concurrent call dispatch.
|
||||
|
||||
---
|
||||
|
||||
## How To: Add a Bounded Editorial Loop Caller (P2b.2+)
|
||||
|
||||
The Authority-tier editorial pipeline uses a bounded editor<->writer loop
|
||||
(`PRAutoBlogger_Editorial_Loop`) instead of the single-pass `Chief_Editor::review()`.
|
||||
|
||||
### Key patterns
|
||||
|
||||
- **Loop bound** is driven by `get_option('prautoblogger_editorial_max_rounds', 3)`,
|
||||
clamped to `[1, 10]`. Never hardcode round counts.
|
||||
- **Escalation**: when max rounds are exhausted without approval, `run()` returns `''`
|
||||
and `was_escalated()` is `true`. The caller must handle this (save as draft to Review Queue).
|
||||
- **Inline revision path**: if the editor's `PRAutoBlogger_Editorial_Review` object
|
||||
carries a non-null `revised_content`, use it directly without calling the writer LLM.
|
||||
- **Writer revision**: when no inline content is available, delegate to
|
||||
`PRAutoBlogger_Editorial_Revision_Caller::call()`. Never put writer LLM logic inside
|
||||
`Editorial_Loop` itself -- keeps it under 300 lines.
|
||||
- **Round recording**: every round (including escalation) writes one `run_decisions` row
|
||||
via `PRAutoBlogger_Audit_Writer::record_decision()`. Round rows carry `stage='editorial'`,
|
||||
`verdict='approved'|'revised'|'rejected'`, `rationale='Round N: <notes>'`. Escalation
|
||||
rows carry `verdict='escalated'`.
|
||||
- **Stage lifecycle**: each editor check opens `run_stages` with `role='editor'` via
|
||||
`Run_Stage_State::start()`; each writer revision opens with `role='writer'`. Both close
|
||||
on `Run_Stage_State::done()` after their respective result is available.
|
||||
- **Cost**: all LLM calls flow through the existing provider seam; cost governor reserves
|
||||
are handled per-call inside `Chief_Editor::review()` and `Editorial_Revision_Caller::call()`.
|
||||
No additional reservation needed in `Editorial_Loop::run()`.
|
||||
|
||||
### Splits to preserve the 300-line cap
|
||||
|
||||
| Class | Responsibility |
|
||||
|---|---|
|
||||
| `PRAutoBlogger_Editorial_Loop` | Loop orchestration, verdict routing, round recording, escalation |
|
||||
| `PRAutoBlogger_Editorial_Revision_Caller` | Writer LLM call, stage lifecycle for 'writer' role |
|
||||
| `PRAutoBlogger_Content_Prompts` | Static `build_revision_system()` + `build_revision_user()` |
|
||||
| `PRAutoBlogger_Editorial_Round` | Immutable value object (one round snapshot) |
|
||||
|
||||
---
|
||||
|
||||
## Authority Pipeline — SEO Stage (P2b.3, v0.30.0)
|
||||
|
||||
### Design rules
|
||||
|
||||
The SEO stage is **deterministic** — no LLM calls, no external I/O beyond WordPress
|
||||
post-meta writes and DB audit rows. It is the contract point between PRAB (writes
|
||||
`_prab_*` meta) and prcore (reads meta to emit JSON-LD schema).
|
||||
|
||||
### Key patterns
|
||||
|
||||
- **No LLM calls**: `PRAutoBlogger_Seo_Stage::run()` is pure computation +
|
||||
meta-write. All data comes from arguments already produced by earlier stages.
|
||||
- **citation_score**: `sum(quality_score) / count(kept_sources)`. Returns `0.0`
|
||||
when `$kept_sources` is empty — never divide-by-zero.
|
||||
- **_prab_reviewed_by omitted**: the automated path never writes this key. Only
|
||||
P2b.4 (human Review Queue approval) sets it. Absence signals `editorial-system` mode.
|
||||
- **Threshold is additive**: `prautoblogger_citation_score_threshold` is read and
|
||||
logged but the publish gate itself is in P2b.4. This stage stores the score only.
|
||||
- **Stage lifecycle**: `Run_Stage_State::start('seo', 'seo', $item_key)` before writes;
|
||||
`::done()` after. Decision row via `Audit_Writer::record_decision()` with
|
||||
`stage='seo'`, `verdict='scored'`, `citation_score=$score`.
|
||||
- **JSON values**: always use `wp_json_encode()`, never `json_encode()` directly.
|
||||
- **Timestamps**: always use `gmdate('Y-m-d\TH:i:s')`, never `date()`.
|
||||
|
||||
### _prab_* key reference (ratified contract v1)
|
||||
|
||||
| Key | Type | Notes |
|
||||
|---|---|---|
|
||||
| `_prab_schema_version` | int `1` | Opt-in trigger — prcore emits JSON-LD only when present |
|
||||
| `_prab_citations` | JSON string | `[{url, title, doi?, quality_score?}]` — kept sources only |
|
||||
| `_prab_about_peptides` | JSON string | `[post_id, ...]` — related peptide IDs |
|
||||
| `_prab_review_mode` | string | `editorial-system` (automated) or `human` (P2b.4) |
|
||||
| `_prab_reviewed_at` | string | ISO 8601 datetime |
|
||||
| `_prab_reviewed_by` | int | WP user ID — written only by P2b.4 on human approval |
|
||||
| `_prab_citation_score` | string (float) | Avg quality_score of kept sources; publish gate in P2b.4 |
|
||||
|
||||
---
|
||||
|
||||
## Tier Routing + Master Flag Pattern (P2b.4, v0.31.0)
|
||||
|
||||
### Design rules
|
||||
|
||||
The master flag `prautoblogger_authority_pipeline_enabled` (default `false`) gates
|
||||
the entire Authority pipeline. When OFF, `PRAutoBlogger_Tier_Router::resolve()` MUST
|
||||
return `economy` unconditionally — no category-map reads, no secondary checks. This
|
||||
guarantees zero behaviour change in production on deploy.
|
||||
|
||||
### Key patterns
|
||||
|
||||
- **Master flag check first**: always the first branch in `resolve()`. If false, return
|
||||
economy immediately without reading the category map.
|
||||
- **Economy is the safe default**: if the category map is empty, absent, or corrupt,
|
||||
the tier defaults to authority (the additive default when flag is ON); but the flag
|
||||
being OFF is the primary safety gate.
|
||||
- **Per-category demotion**: the `prautoblogger_category_tiers` option stores a
|
||||
serialized PHP array `[category_slug => economy]`. Only economy is a meaningful
|
||||
explicit value — other values fall through to the Authority default.
|
||||
- **Article_Worker integration**: three-line check before the Economy path:
|
||||
```php
|
||||
$tier = ( new PRAutoBlogger_Tier_Router() )->resolve( $idea );
|
||||
if ( authority === $tier ) {
|
||||
return ( new PRAutoBlogger_Authority_Pipeline( $cost_tracker ) )->run( $run_id, $idea, $cost_tracker );
|
||||
}
|
||||
```
|
||||
The Economy path below the check is UNCHANGED — the branch is additive-only.
|
||||
- **Imagery gate**: held articles (citation gate miss, escalation, or cost ceiling) get
|
||||
`_prautoblogger_imagery_suppressed = 1` post-meta. Image pipeline checks this key
|
||||
before running. Publish-gate-passed articles never have this key set.
|
||||
|
||||
---
|
||||
|
||||
## Authority Pipeline Options (P2b.5, v0.32.0)
|
||||
|
||||
### Option naming pattern
|
||||
|
||||
All Authority-pipeline controls follow the `prautoblogger_` prefix convention:
|
||||
|
||||
| Option | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| `prautoblogger_authority_pipeline_enabled` | toggle (`'0'`/`'1'`) | Master switch; default `'0'` (OFF) |
|
||||
| `prautoblogger_citation_score_threshold` | int 0–100 | Publish gate; 0 = skip gate while calibrating |
|
||||
| `prautoblogger_category_tiers` | serialized PHP array | `[slug => 'economy']`; managed via parse_and_save_category_tiers() |
|
||||
| `prautoblogger_category_tiers_input` | virtual textarea | Display-only; not a stored wp_option — see renderer special-case |
|
||||
| `prautoblogger_research_agent_count` | int 1–5 | Fan-out agent count; default 3 |
|
||||
| `prautoblogger_editorial_max_rounds` | int 1–5 | Editorial loop bound; default 3 |
|
||||
| `prautoblogger_curate_model` | string | Model for the Curate step (research judge) |
|
||||
| `prautoblogger_seo_model` | string | Model for the SEO stage LLM call |
|
||||
| `prautoblogger_seo_instructions` | textarea | Custom instructions for SEO stage |
|
||||
|
||||
### parse_and_save_category_tiers() pattern
|
||||
|
||||
The category tier map is a special two-key surface:
|
||||
|
||||
1. **Display** (`prautoblogger_category_tiers_input`): The renderer reads
|
||||
`prautoblogger_category_tiers` (a serialized array) and converts it to
|
||||
`"slug: tier"` lines for the textarea. This textarea key is NOT a stored option.
|
||||
2. **Save** (`prautoblogger_category_tiers`): After the normal field loop,
|
||||
`Save_Handler::handle_step_settings_save()` calls `parse_and_save_category_tiers()`
|
||||
which reads the `prautoblogger_category_tiers_input` POST value, splits on newlines,
|
||||
validates each line, and writes the array to `prautoblogger_category_tiers`.
|
||||
3. **Additive-safety rule**: any tier value other than `'economy'` is stored as
|
||||
`'authority'`. This means a typo or empty tier always chooses the more conservative
|
||||
path (more vetting, not less).
|
||||
|
||||
### contexts() extension rule
|
||||
|
||||
Adding a new Authority-pipeline admin step requires:
|
||||
1. Add the step to `PRAutoBlogger_Pipeline_Settings_Step_Map::steps()`.
|
||||
2. Add the context string to `PRAutoBlogger_Pipeline_Settings_Option_Fields::contexts()`.
|
||||
3. Add the field data to `PRAutoBlogger_Pipeline_Settings_Option_Fields_Data_Authority::fields_for()`.
|
||||
4. Update ARCHITECTURE.md options table and this section.
|
||||
|
||||
@@ -11,7 +11,7 @@ Maintained per DoD v1.1.0 (same-PR update rule).
|
||||
|------|-------|----------|---------|
|
||||
| `prautoblogger-settings` | `PRAutoBlogger_Admin_Page` | `admin-page.php` | Main settings: API Keys, Schedule & Budget, Publishing, Analytics, Display, Images. AI Models / Content / Sources retired to Pipeline Settings in M2 (v0.24.0). |
|
||||
| `prautoblogger-board` | `PRAutoBlogger_Board_Page` | `board-page.php` | Mission Brief board (M5): vertical run-list + right-rail inspector. Status sections: Generating / In review / Published / Failed. New Article button. Stage dot-rail. Inspector: per-stage I/O, cost receipt, dossier link (v0.21.0, v0.27.0). |
|
||||
| `prautoblogger-pipeline` | `PRAutoBlogger_Pipeline_Settings_Page` | `pipeline-settings-page.php` | Per-step pipeline config: Global Content Context (niche), step option fields, model picker, system instructions, agent prompts, params (M1 v0.23.0; M2 v0.24.0 adds editable step options, retires AI Models/Content/Sources Settings tabs). M3 v0.25.0 adds Template/Preview toggle (assembled-instructions preview from last-run gen_log), version history accordion, and inline diff panel. Three new AJAX endpoints: `prautoblogger_pipeline_preview`, `prautoblogger_pipeline_history`, `prautoblogger_pipeline_diff` (all `manage_options` + nonce gated). P2b.5 v0.32.0 adds three Authority-tier steps to the step rail: **Curate** (research judge model/prompts, Authority only), **SEO** (post-meta model/prompts, Authority only), and **Authority Settings** (master toggle + citation gate + per-category tier map editor). |
|
||||
| `prautoblogger-pipeline` | `PRAutoBlogger_Pipeline_Settings_Page` | `pipeline-settings-page.php` | Per-step pipeline config: Global Content Context (niche), step option fields, model picker, system instructions, agent prompts, params (M1 v0.23.0; M2 v0.24.0 adds editable step options, retires AI Models/Content/Sources Settings tabs). M3 v0.25.0 adds Template/Preview toggle (assembled-instructions preview from last-run gen_log), version history accordion, and inline diff panel. Three new AJAX endpoints: `prautoblogger_pipeline_preview`, `prautoblogger_pipeline_history`, `prautoblogger_pipeline_diff` (all `manage_options` + nonce gated). |
|
||||
| `prautoblogger-gen-history` | `PRAutoBlogger_Gen_History_Page` | `gen-history-page.php` | M4: Paginated list of all generation runs — title, date, status, models, cost, duration; Stage I/O toggle for inline per-step input/output drill-down (AJAX). Hidden options.php-parent page. `manage_options` + `prautoblogger_gen_run_io` nonce. |
|
||||
| `prautoblogger-dossier` | `PRAutoBlogger_Dossier_Page` | `dossier-page.php` | Per-article generation log + stage edit/re-run (M3). Preferred per-step I/O surface for runs with a linked post. |
|
||||
| `prautoblogger-ideas` | `PRAutoBlogger_Ideas_Browser` | `ideas-browser.php` | Analysis results browser with per-idea generation |
|
||||
@@ -70,13 +70,6 @@ DB version history: 1.0→1.1 (schema normalisation), 1.1→1.2 (M2 dossier), 1.
|
||||
| `prautoblogger_article_queue` | array | — | Checkpoint queue (run_id + ideas + results) |
|
||||
| `prautoblogger_checkpoint_run_id` | string | — | Current run UUID for Tick 2..N finalize path |
|
||||
| `prautoblogger_db_version` | string | — | Installed schema version (triggers dbDelta on mismatch) |
|
||||
| `prautoblogger_authority_pipeline_enabled` | toggle | 0 | Master switch: ON routes Authority-tier categories through the 6-stage pipeline; OFF = Economy single-pass for all (default, no behaviour change on upgrade) |
|
||||
| `prautoblogger_category_tiers` | array | [] | Per-category slug → tier map (`authority`\|`economy`). Parsed from the tier-map textarea editor on save. Empty = all categories default to Authority. |
|
||||
| `prautoblogger_citation_score_threshold` | float | 0 | Minimum source quality score (0–100) for auto-publish gate. 0 = gate disabled while calibrating. |
|
||||
| `prautoblogger_research_agent_count` | int | 3 | Number of parallel research fan-out agents (1–5). Authority tier only. |
|
||||
| `prautoblogger_editorial_max_rounds` | int | 3 | Max rounds in the bounded editorial loop (1–5). Authority tier only. |
|
||||
| `prautoblogger_curate_model` | string | — | Model used for the Curate stage (research judge LLM). Authority tier only. |
|
||||
| `prautoblogger_seo_model` | string | — | Model used for the SEO stage post-meta enrichment. Authority tier only. |
|
||||
|
||||
---
|
||||
|
||||
@@ -89,4 +82,4 @@ DB version history: 1.0→1.1 (schema normalisation), 1.1→1.2 (M2 dossier), 1.
|
||||
|
||||
---
|
||||
|
||||
*Last updated: v0.32.1 (2026-06-24)*
|
||||
*Last updated: v0.24.0 (2026-06-23)*
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
---
|
||||
seq: 5
|
||||
from: engineer-prautoblogger
|
||||
to: cto+qa
|
||||
date: 2026-06-23
|
||||
thread: phase2b-authority
|
||||
status: awaiting-qa
|
||||
in-reply-to: 04-qa-p2b1-verdict.md
|
||||
superseded-by: null
|
||||
---
|
||||
|
||||
# P2b.1 QA fixes applied — REQUEST-CHANGES resolved
|
||||
|
||||
All five findings from the REQUEST-CHANGES verdict (1 P1 + 4 P2) addressed as a
|
||||
single new commit on the same branch (`claude/prab-phase2b-1-20260623`).
|
||||
No merge; awaiting re-QA.
|
||||
|
||||
---
|
||||
|
||||
## What was fixed
|
||||
|
||||
### P1-1 — CONTEXT.md (DoD v1.2.0 §7) ✓ FIXED
|
||||
|
||||
Added `## Phase 2b P2b.1 — Research_Fanout + Research_Judge (v0.28.0)` section
|
||||
to `CONTEXT.md`. Defines all eight missing domain terms:
|
||||
|
||||
- **fan-out** — pattern of N parallel LLM dispatches via curl_multi
|
||||
- **Research_Fanout** — orchestrator class (cost-reserve, quorum, additive-only)
|
||||
- **curate stage** — pipeline stage executed by Research_Judge after fan-out
|
||||
- **Research_Judge** — dedup → score → run_sources writes
|
||||
- **quorum** — ⌈N/2⌉+1 usable results required; below quorum → caller holds run
|
||||
- **specialist role** — one of {mechanisms, clinical, safety, comparison, practical}
|
||||
- **quality_score** — relevance × authority_weight composite
|
||||
- **authority weight** — source-type multiplier (DOI→1.0 … HTTP→0.40)
|
||||
- **MIN_AGENTS floor** — why MIN_AGENTS=2 (not 1); links to P2-3 fix
|
||||
|
||||
---
|
||||
|
||||
### P2-2 — try/finally guard on settle() ✓ FIXED
|
||||
|
||||
`class-research-fanout.php` dispatch() method now wraps the entire
|
||||
`batch->execute()` + results foreach + `settle()` block in `try/finally`:
|
||||
|
||||
```php
|
||||
try {
|
||||
$raw_results = $this->batch->execute( $model, $messages_per_agent, $options, $roles );
|
||||
|
||||
foreach ( $raw_results as $idx => $raw ) {
|
||||
// ... validate, accumulate $actual_total ...
|
||||
}
|
||||
} finally {
|
||||
// Always settle the reservation — charges actual cost incurred (0 on throw).
|
||||
PRAutoBlogger_Cost_Governor::settle( $reservation, $actual_total );
|
||||
}
|
||||
```
|
||||
|
||||
If `batch->execute()` or any downstream code throws an unexpected exception,
|
||||
`settle()` is still called with `$actual_total = 0.0` (nothing successfully
|
||||
dispatched/completed), releasing the hold without charging phantom cost.
|
||||
Without this guard an uncaught throw would leave `reserved_usd` open in the run
|
||||
ledger until the run reaper sweeps it, silently eroding the cost ceiling.
|
||||
|
||||
---
|
||||
|
||||
### P2-1 — Ceiling-breach abort test ✓ FIXED
|
||||
|
||||
Added `test_ceiling_breach_exception_aborts_before_dispatch()` to
|
||||
`ResearchFanoutTest.php`:
|
||||
|
||||
```php
|
||||
public function test_ceiling_breach_exception_aborts_before_dispatch(): void {
|
||||
$batch = $this->getMockBuilder( \PRAutoBlogger_Research_Batch::class )
|
||||
->disableOriginalConstructor()
|
||||
->onlyMethods( array( 'execute' ) )
|
||||
->getMock();
|
||||
|
||||
// execute() must NEVER be called when the ceiling is breached.
|
||||
$batch->expects( $this->never() )
|
||||
->method( 'execute' );
|
||||
|
||||
// Force ceiling breach: ceiling_usd=0.0, UPDATE affects 0 rows → exception thrown.
|
||||
$this->wpdb->method( 'get_row' )->willReturn( (object) array(
|
||||
'run_id' => 'run-ceiling',
|
||||
'ceiling_usd' => 0.0,
|
||||
'reserved_usd' => 0.0,
|
||||
'settled_usd' => 0.0,
|
||||
'status' => 'running',
|
||||
) );
|
||||
$this->wpdb->method( 'query' )->willReturn( 0 );
|
||||
|
||||
\PRAutoBlogger_Run_Context::set( 'run-ceiling' );
|
||||
|
||||
$fanout = $this->make_fanout( $batch );
|
||||
|
||||
$this->expectException( \PRAutoBlogger_Cost_Ceiling_Exception::class );
|
||||
$fanout->dispatch( 'run-ceiling', 'idea:abc', $this->make_idea(), $this->make_cost_tracker() );
|
||||
// PHPUnit enforces $batch->execute() was never called via expects($this->never()).
|
||||
}
|
||||
```
|
||||
|
||||
PHPUnit's `expects($this->never())` enforces the call-count invariant independently
|
||||
of the exception assertion — if `execute()` were called before the throw, the test
|
||||
would fail with an unexpected method call error.
|
||||
|
||||
---
|
||||
|
||||
### P2-3 — MIN_AGENTS raised to 2 ✓ FIXED
|
||||
|
||||
`class-research-fanout.php`: `MIN_AGENTS` constant changed from `1` to `2`,
|
||||
with an inline docblock explaining why:
|
||||
|
||||
```php
|
||||
/**
|
||||
* Minimum configurable agent count.
|
||||
*
|
||||
* Set to 2 (not 1): quorum = ⌈N/2⌉+1 = 2 for N=1, which makes a
|
||||
* single-agent dispatch mathematically impossible to satisfy. Clamping
|
||||
* to 2 ensures at least a 2-agent fan-out where quorum=2 is achievable
|
||||
* (both agents must succeed). See CONTEXT.md §Phase 2b "MIN_AGENTS floor".
|
||||
*/
|
||||
private const MIN_AGENTS = 2;
|
||||
```
|
||||
|
||||
`resolve_agent_count()` docblock updated to reference this rationale.
|
||||
CONTEXT.md glossary entry "MIN_AGENTS floor" cross-links to the constant.
|
||||
|
||||
---
|
||||
|
||||
### P2-4 — Inaccurate settle description in seq-03 ✓ CORRECTED HERE
|
||||
|
||||
The seq-03 report stated:
|
||||
|
||||
> "On quorum failure the reservation is released (not settled) — no cost charged."
|
||||
|
||||
This is inaccurate. The correct behavior (confirmed by code read + QA verdict):
|
||||
|
||||
`settle()` is ALWAYS called after the dispatch loop, passing `$actual_total` —
|
||||
the sum of `actual_cost` for all agents that returned valid results. On quorum
|
||||
failure this is a PARTIAL cost (the agents that ran before failing are charged),
|
||||
not zero. On a total failure (all agents fail/invalid-schema) `$actual_total = 0.0`
|
||||
and settle correctly charges zero. "Released" is wrong terminology — settle()
|
||||
records the actual spend against the ledger; it does not simply drop the reservation.
|
||||
|
||||
The try/finally fix (P2-2) extends this: even if `batch->execute()` itself throws,
|
||||
settle is still called with `$actual_total = 0.0` (correct: nothing ran).
|
||||
|
||||
This correction is the authoritative record. Seq-03 is superseded by this note on
|
||||
the settle semantics.
|
||||
|
||||
---
|
||||
|
||||
## Self-checks
|
||||
|
||||
**Static-stub grep (must be empty):**
|
||||
|
||||
```
|
||||
grep -rn 'Functions\\when(' tests/ | grep '::'
|
||||
```
|
||||
→ No output. Zero matches. `Functions\when()` is used only on WP global functions
|
||||
(`wp_json_encode`, `sanitize_url`, `sanitize_text_field`, `sanitize_textarea_field`,
|
||||
`get_option`) — never on `Class::method`.
|
||||
|
||||
**Brace balance on touched test file (`ResearchFanoutTest.php`):**
|
||||
- Opening `{` count: class declaration (1) + setUp (1) + tearDown (1) +
|
||||
test_all_agents_succeed (1) + test_partial_failure (1) + test_invalid_schema (1) +
|
||||
test_quorum_n5 (1) + test_no_run_context (1) + test_ceiling_breach (1) +
|
||||
make_stub_batch (1) + make_fanout (1) + make_valid_raw_results (1) + make_idea (1) +
|
||||
make_cost_tracker (1) = 14 method/class opens + inner closures/arrays
|
||||
- Confirmed structurally sound: each method opens with `{` on the `public function`
|
||||
line and closes with `}` at same indentation; no unmatched braces.
|
||||
|
||||
**No static method monkey-patching:** `Functions\when()` calls are on:
|
||||
`wp_json_encode`, `sanitize_url`, `sanitize_text_field`, `sanitize_textarea_field`,
|
||||
`get_option` — all global WordPress functions. None are `Class::method` references.
|
||||
|
||||
---
|
||||
|
||||
## Files changed in this fixup commit
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `CONTEXT.md` | Added Phase 2b P2b.1 glossary section (P1-1) |
|
||||
| `includes/core/class-research-fanout.php` | MIN_AGENTS 1→2 (P2-3); try/finally on settle (P2-2); docblock updates |
|
||||
| `tests/unit/Core/ResearchFanoutTest.php` | Added ceiling-breach test (P2-1) |
|
||||
| `CHANGELOG.md` | Fixed note in [0.28.0]: MIN_AGENTS range corrected, try/finally noted, P2 sweep listed |
|
||||
|
||||
---
|
||||
|
||||
## Version
|
||||
|
||||
Still v0.28.0 — these are DoD-compliance fixes on the same milestone, not a new feature.
|
||||
|
||||
— Engineer PRAutoBlogger
|
||||
@@ -1,83 +0,0 @@
|
||||
---
|
||||
seq: 28
|
||||
from: engineer-prautoblogger
|
||||
to: cto+qa
|
||||
date: 2026-06-24
|
||||
thread: phase2b-authority
|
||||
status: awaiting-qa
|
||||
in-reply-to: null
|
||||
superseded-by: null
|
||||
---
|
||||
|
||||
# Hold-path bugfix + pipeline_mode label — v0.32.2 ready for QA
|
||||
|
||||
Branch `claude/prab-phase2b-holdfix-20260624` off `origin/main` (v0.32.1).
|
||||
PR opened; not merged. VPS verified: PHPCS clean (exit 0), PHPUnit 556/556 OK.
|
||||
|
||||
---
|
||||
|
||||
## P1 fix — quorum-miss HOLD no longer crashes as `failed`
|
||||
|
||||
**Root cause:** `Authority_Pipeline_Stages::hold_as_draft()` called
|
||||
`Publisher::save_as_draft('')` unconditionally, even when no draft content
|
||||
existed yet (quorum miss / pre-draft failure). Publisher's v0.18.1 empty-content
|
||||
guard threw `RuntimeException("Refusing to create draft post")`, which propagated
|
||||
up and caused Article_Worker to mark the run `failed`.
|
||||
|
||||
**Fix:** `hold_as_draft()` now branches on content:
|
||||
|
||||
- **No content** (empty after `wp_strip_all_tags`): writes a `run_decisions`
|
||||
HOLD row only. No post created, no exception thrown. Run lands `held-quorum`.
|
||||
- **With content** (editorial escalation, citation gate, cost ceiling after draft):
|
||||
saves draft + suppresses imagery — behaviour unchanged.
|
||||
|
||||
The branch is a single `if` at the top of `hold_as_draft()`:
|
||||
```php
|
||||
if ( '' === trim( wp_strip_all_tags( $content ) ) ) {
|
||||
PRAutoBlogger_Audit_Writer::record_decision( $run_id, 'publish-gate', $verdict, $hold_reason );
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
## P2 fix — Authority posts get `pipeline_mode = authority`
|
||||
|
||||
**Root cause:** `Publisher::build_meta()` read `get_option('prautoblogger_writing_pipeline')`
|
||||
unconditionally, which returns `'single_pass'` for Authority runs (the setting value,
|
||||
not the actual pipeline used).
|
||||
|
||||
**Fix:** `save_as_draft()` and `publish()` accept a new optional `string $pipeline_mode = ''`
|
||||
parameter. When non-empty it overrides the option. All Authority pipeline call sites pass
|
||||
`'authority'`. Economy callers pass nothing (option fallback unchanged).
|
||||
|
||||
Files changed:
|
||||
- `includes/core/class-authority-pipeline-stages.php` — branch in `hold_as_draft()`; pass
|
||||
`'authority'` to `save_as_draft()` for content-present holds.
|
||||
- `includes/core/class-publisher.php` — `$pipeline_mode` param on `publish()`,
|
||||
`save_as_draft()`, `create_post()`, `build_meta()`. 299 lines (within 300-line rule).
|
||||
- `includes/core/class-authority-pipeline.php` — pass `'authority'` to `save_as_draft()`
|
||||
in stage 3b (draft for SEO stage).
|
||||
- `ARCHITECTURE.md` — `_prautoblogger_pipeline_mode` values updated to include `'authority'`.
|
||||
- `CONTEXT.md` — quorum entry updated with v0.32.2 no-post hold note.
|
||||
|
||||
## Tests added (all pass)
|
||||
|
||||
`AuthorityPipelineTest`:
|
||||
- `test_quorum_miss_hold_records_decision_no_post_created` — wp_insert_post never called;
|
||||
HOLD decision row written; status = held-quorum.
|
||||
- `test_quorum_miss_does_not_throw` — run() must not throw Throwable on quorum miss.
|
||||
- `test_editorial_escalation_hold_saves_draft_with_content` — content-present path
|
||||
unchanged: draft created, imagery suppressed.
|
||||
- `test_authority_published_post_gets_authority_pipeline_mode` — meta = `authority`.
|
||||
|
||||
`PublisherTest`:
|
||||
- `test_publish_pipeline_mode_defaults_to_option_value` — Economy: option value written.
|
||||
- `test_publish_explicit_pipeline_mode_overrides_option` — Authority: `'authority'` in meta.
|
||||
|
||||
## VPS CI results
|
||||
|
||||
```
|
||||
vendor/bin/phpcs --standard=phpcs.xml (3 modified files): exit 0
|
||||
vendor/bin/phpunit --testsuite unit: Tests: 556, Assertions: 2046, Skipped: 5. OK
|
||||
```
|
||||
|
||||
— engineer-prautoblogger
|
||||
@@ -160,8 +160,7 @@ class PRAutoBlogger_Gen_History_Query {
|
||||
request_json, response_status, error_message, created_at
|
||||
FROM {$log_table}
|
||||
WHERE run_id = %s
|
||||
ORDER BY id ASC
|
||||
LIMIT 100", // Safety cap: a run has ~6-10 stage rows; 100 is generous.
|
||||
ORDER BY id ASC",
|
||||
$run_id
|
||||
),
|
||||
ARRAY_A
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Authority pipeline field-array data for PRAutoBlogger_Pipeline_Settings_Option_Fields.
|
||||
*
|
||||
* What: Declarative field arrays for the three P2b.5 Authority-pipeline contexts:
|
||||
* curate, seo, and authority. Extracted from Option_Fields_Data to keep
|
||||
* that class under the 300-line rule. Contains no logic — only data.
|
||||
* Who calls it: PRAutoBlogger_Pipeline_Settings_Option_Fields_Data::fields_for() (curate/seo/authority).
|
||||
* Dependencies: WP i18n helpers (__) only.
|
||||
*
|
||||
* @see admin/class-pipeline-settings-option-fields-data.php — Core context router.
|
||||
* @see CONVENTIONS.md §Authority pipeline options — naming + parse pattern.
|
||||
*/
|
||||
class PRAutoBlogger_Pipeline_Settings_Option_Fields_Data_Authority {
|
||||
|
||||
/**
|
||||
* Route authority-tier context identifiers to their field definitions.
|
||||
*
|
||||
* @param string $context One of: curate|seo|authority.
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function fields_for( string $context ): array {
|
||||
switch ( $context ) {
|
||||
case 'curate':
|
||||
return self::curate_fields();
|
||||
case 'seo':
|
||||
return self::seo_fields();
|
||||
case 'authority':
|
||||
return self::authority_fields();
|
||||
default:
|
||||
return array();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Curate step context fields (P2b.5).
|
||||
* The model picker and prompt editors in the step panel handle the main
|
||||
* configuration. This textarea exposes optional override instructions
|
||||
* that are appended to the curate stage system prompt.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private static function curate_fields(): array {
|
||||
return array(
|
||||
array(
|
||||
'id' => 'prautoblogger_curate_instructions',
|
||||
'label' => __( 'Curate Instructions', 'prautoblogger' ),
|
||||
'type' => 'textarea',
|
||||
'default' => '',
|
||||
'description' => __( 'Optional custom instructions appended to the research judge (curate) system prompt. Authority tier only.', 'prautoblogger' ),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* SEO step context fields (P2b.5).
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private static function seo_fields(): array {
|
||||
return array(
|
||||
array(
|
||||
'id' => 'prautoblogger_seo_instructions',
|
||||
'label' => __( 'SEO Instructions', 'prautoblogger' ),
|
||||
'type' => 'textarea',
|
||||
'default' => '',
|
||||
'description' => __( 'Custom instructions for the SEO stage LLM call.', 'prautoblogger' ),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authority Settings context fields (P2b.5).
|
||||
* Controls the master switch, citation gate, and per-category tier map.
|
||||
* The tier-map textarea is a special read/write surface: the renderer
|
||||
* converts prautoblogger_category_tiers (serialised array) back to
|
||||
* 'slug: tier' lines; the save handler calls parse_and_save_category_tiers()
|
||||
* to convert the textarea back to the array — see Save_Handler::handle_step_settings_save().
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private static function authority_fields(): array {
|
||||
return array(
|
||||
array(
|
||||
'id' => 'prautoblogger_authority_pipeline_enabled',
|
||||
'label' => __( 'Authority Pipeline', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'default' => '0',
|
||||
'description' => __( 'When ON, categories mapped to Authority tier use the full 6-stage pipeline (research to curate to draft to editorial to SEO to publish). Leave OFF until you have reviewed the configuration.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_citation_score_threshold',
|
||||
'label' => __( 'Citation Score Gate', 'prautoblogger' ),
|
||||
'type' => 'number',
|
||||
'default' => 0,
|
||||
'min' => 0,
|
||||
'max' => 100,
|
||||
'description' => __( 'Minimum source quality score (0-100) for auto-publish. Set 0 to skip the gate while calibrating. Calibrate after ~10 Authority runs.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_category_tiers_input',
|
||||
'label' => __( 'Category Tier Map', 'prautoblogger' ),
|
||||
'type' => 'textarea',
|
||||
'default' => '',
|
||||
'description' => __( 'One line per category slug. Format: slug: authority or slug: economy. New, YMYL, and unclassified categories default to Authority.', 'prautoblogger' ),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,24 +10,19 @@ declare(strict_types=1);
|
||||
* from Option_Fields to satisfy the 300-line/file rule. Contains no
|
||||
* logic — only data. Option keys are the same wp_option names that were
|
||||
* previously registered in the retired AI Models, Content, and Sources
|
||||
* Settings tabs. P2b.5 adds curate, seo, and authority contexts (data in
|
||||
* class-pipeline-settings-option-fields-data-authority.php per 300-line rule),
|
||||
* and inserts new fields into the research and editorial contexts.
|
||||
* Settings tabs.
|
||||
* Who calls it: PRAutoBlogger_Pipeline_Settings_Option_Fields::get_fields_for_context().
|
||||
* Dependencies: WP i18n helpers (__) only;
|
||||
* PRAutoBlogger_Pipeline_Settings_Option_Fields_Data_Authority (curate/seo/authority).
|
||||
* Dependencies: WP i18n helpers (__) only.
|
||||
*
|
||||
* @see admin/class-pipeline-settings-option-fields.php — Public API wrapper + sanitizer.
|
||||
* @see admin/class-pipeline-settings-option-fields-data-authority.php — Authority/curate/seo fields.
|
||||
* @see CONVENTIONS.md §Retired Settings Tabs — retirement pattern.
|
||||
* @see CONVENTIONS.md §Authority pipeline options — naming pattern for Authority controls.
|
||||
* @see admin/class-pipeline-settings-option-fields.php — Public API wrapper + sanitizer.
|
||||
* @see CONVENTIONS.md §Retired Settings Tabs — retirement pattern.
|
||||
*/
|
||||
class PRAutoBlogger_Pipeline_Settings_Option_Fields_Data {
|
||||
|
||||
/**
|
||||
* Route a context string to its field definitions array.
|
||||
*
|
||||
* @param string $context One of: global|research|analysis|writer|editorial|curate|seo|authority.
|
||||
* @param string $context One of: global|research|analysis|writer|editorial.
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function fields_for( string $context ): array {
|
||||
@@ -42,10 +37,6 @@ class PRAutoBlogger_Pipeline_Settings_Option_Fields_Data {
|
||||
return self::writer_fields();
|
||||
case 'editorial':
|
||||
return self::editorial_fields();
|
||||
case 'curate':
|
||||
case 'seo':
|
||||
case 'authority':
|
||||
return PRAutoBlogger_Pipeline_Settings_Option_Fields_Data_Authority::fields_for( $context );
|
||||
default:
|
||||
return array();
|
||||
}
|
||||
@@ -70,21 +61,11 @@ class PRAutoBlogger_Pipeline_Settings_Option_Fields_Data {
|
||||
|
||||
/**
|
||||
* Research step context fields (moved from Sources Settings tab).
|
||||
* P2b.5: prepends Research Agent Count for the Authority fan-out.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private static function research_fields(): array {
|
||||
return array(
|
||||
array(
|
||||
'id' => 'prautoblogger_research_agent_count',
|
||||
'label' => __( 'Research Agent Count', 'prautoblogger' ),
|
||||
'type' => 'number',
|
||||
'default' => 3,
|
||||
'min' => 1,
|
||||
'max' => 5,
|
||||
'description' => __( 'Number of specialist research agents in the Authority fan-out (1–5). Default 3. Economy tier ignores this setting.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_enabled_sources',
|
||||
'label' => __( 'Enabled Sources', 'prautoblogger' ),
|
||||
@@ -245,21 +226,11 @@ class PRAutoBlogger_Pipeline_Settings_Option_Fields_Data {
|
||||
|
||||
/**
|
||||
* Editorial step context fields (moved from Content Settings tab).
|
||||
* P2b.5: prepends Max Editorial Rounds for the Authority bounded loop.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private static function editorial_fields(): array {
|
||||
return array(
|
||||
array(
|
||||
'id' => 'prautoblogger_editorial_max_rounds',
|
||||
'label' => __( 'Max Editorial Rounds', 'prautoblogger' ),
|
||||
'type' => 'number',
|
||||
'default' => 3,
|
||||
'min' => 1,
|
||||
'max' => 5,
|
||||
'description' => __( 'Maximum editor↔writer iterations in the Authority bounded loop. Default 3. Economy tier ignores this setting.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_editor_instructions',
|
||||
'label' => __( 'Editor Instructions', 'prautoblogger' ),
|
||||
|
||||
@@ -29,7 +29,7 @@ class PRAutoBlogger_Pipeline_Settings_Option_Fields {
|
||||
* @return string[]
|
||||
*/
|
||||
public static function contexts(): array {
|
||||
return array( 'global', 'research', 'analysis', 'writer', 'editorial', 'curate', 'seo', 'authority' );
|
||||
return array( 'global', 'research', 'analysis', 'writer', 'editorial' );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,9 +15,6 @@ declare(strict_types=1);
|
||||
* M3 additions: passes preview/history nonces and version-list data per
|
||||
* prompt panel so the Template/Preview toggle and version history
|
||||
* accordion have everything they need server-side.
|
||||
* P2b.5 addition: build_option_field_values() has a special case for
|
||||
* prautoblogger_category_tiers_input — converts the stored serialised
|
||||
* array back to "slug: tier" lines for the textarea.
|
||||
* Who calls it: PRAutoBlogger_Pipeline_Settings_Page::render_page() after save.
|
||||
* Dependencies: PRAutoBlogger_Pipeline_Settings_Step_Map (step list),
|
||||
* PRAutoBlogger_Pipeline_Settings_Option_Fields (step option defs),
|
||||
@@ -82,27 +79,12 @@ class PRAutoBlogger_Pipeline_Settings_Renderer {
|
||||
/**
|
||||
* Build current option values for all fields in a context.
|
||||
*
|
||||
* Special case: prautoblogger_category_tiers_input is a virtual field whose
|
||||
* value is derived from the serialised prautoblogger_category_tiers array —
|
||||
* not a stored string option. The renderer converts the array back to
|
||||
* "slug: tier" textarea lines here so the template needs no extra logic.
|
||||
*
|
||||
* @param string $context Step context identifier.
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function build_option_field_values( string $context ): array {
|
||||
$fields = PRAutoBlogger_Pipeline_Settings_Option_Fields::get_fields_for_context( $context );
|
||||
foreach ( $fields as &$field ) {
|
||||
if ( 'prautoblogger_category_tiers_input' === $field['id'] ) {
|
||||
// Convert the stored array back to "slug: tier" lines for the textarea.
|
||||
$tiers = get_option( 'prautoblogger_category_tiers', array() );
|
||||
$lines = array();
|
||||
foreach ( $tiers as $slug => $tier ) {
|
||||
$lines[] = esc_attr( $slug ) . ': ' . esc_attr( $tier );
|
||||
}
|
||||
$field['current'] = implode( "\n", $lines );
|
||||
continue;
|
||||
}
|
||||
$field['current'] = get_option( $field['id'], $field['default'] ?? '' );
|
||||
}
|
||||
unset( $field );
|
||||
|
||||
@@ -13,10 +13,7 @@ declare(strict_types=1);
|
||||
* - save_prompt — creates a new immutable prompt version in the registry.
|
||||
* - reset_prompt — creates a new version with the canonical default body.
|
||||
* - save_step_settings — persists one or more relocated option fields for
|
||||
* a step context (global/research/analysis/writer/editorial/curate/seo/authority).
|
||||
* For the authority context, also parses the tier-map textarea into
|
||||
* prautoblogger_category_tiers (serialised array) via
|
||||
* parse_and_save_category_tiers().
|
||||
* a step context (global/research/analysis/writer/editorial).
|
||||
* Saving a prompt NEVER mutates the existing version — the registry's core
|
||||
* invariant. Model + step-option values read from POST keys matching the
|
||||
* option name. Prompt keys are slug-matched against the allowlist.
|
||||
@@ -92,7 +89,6 @@ class PRAutoBlogger_Pipeline_Settings_Save_Handler {
|
||||
* context reads the corresponding POST value, sanitizes via
|
||||
* PRAutoBlogger_Pipeline_Settings_Option_Fields::sanitize_option(), and
|
||||
* calls update_option(). Only allowlisted option names are written.
|
||||
* For the authority context, also parses the tier-map textarea.
|
||||
*
|
||||
* @return array{status: string, message: string}
|
||||
*/
|
||||
@@ -109,16 +105,7 @@ class PRAutoBlogger_Pipeline_Settings_Save_Handler {
|
||||
$fields = PRAutoBlogger_Pipeline_Settings_Option_Fields::get_fields_for_context( $context );
|
||||
|
||||
foreach ( $fields as $field ) {
|
||||
$id = (string) $field['id'];
|
||||
|
||||
// Virtual display fields are not persisted directly — they are parsed
|
||||
// into a canonical option by a dedicated handler. Skip them here.
|
||||
// prautoblogger_category_tiers_input is the textarea that parse_and_save_category_tiers()
|
||||
// converts to the prautoblogger_category_tiers serialised array.
|
||||
if ( str_ends_with( $id, '_input' ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$id = (string) $field['id'];
|
||||
$raw = isset( $_POST[ $id ] ) ? wp_unslash( $_POST[ $id ] ) : ( $field['default'] ?? '' );
|
||||
// For checkboxes the POST value is an array (or absent = empty array).
|
||||
if ( 'checkboxes' === ( $field['type'] ?? '' ) ) {
|
||||
@@ -129,49 +116,12 @@ class PRAutoBlogger_Pipeline_Settings_Save_Handler {
|
||||
update_option( $id, $sanitized );
|
||||
}
|
||||
|
||||
// For the authority context, also parse the category-tier textarea into the tiers array.
|
||||
if ( 'authority' === $context ) {
|
||||
self::parse_and_save_category_tiers();
|
||||
}
|
||||
|
||||
return array(
|
||||
'status' => 'saved',
|
||||
'message' => __( 'Settings saved.', 'prautoblogger' ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the prautoblogger_category_tiers_input textarea and persist
|
||||
* the result as the prautoblogger_category_tiers serialised array.
|
||||
*
|
||||
* Format: one line per category, "slug: authority|economy".
|
||||
* Invalid tier values default to 'authority' (additive-safety rule).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function parse_and_save_category_tiers(): void {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified by caller
|
||||
$raw = isset( $_POST['prautoblogger_category_tiers_input'] )
|
||||
? wp_unslash( $_POST['prautoblogger_category_tiers_input'] )
|
||||
: '';
|
||||
$raw = sanitize_textarea_field( $raw );
|
||||
$lines = array_filter( array_map( 'trim', explode( "\n", $raw ) ) );
|
||||
$tiers = array();
|
||||
foreach ( $lines as $line ) {
|
||||
if ( ! str_contains( $line, ':' ) ) {
|
||||
continue;
|
||||
}
|
||||
[ $slug, $tier ] = explode( ':', $line, 2 );
|
||||
$slug = sanitize_key( trim( $slug ) );
|
||||
$tier = sanitize_key( trim( $tier ) );
|
||||
if ( '' === $slug ) {
|
||||
continue;
|
||||
}
|
||||
$tiers[ $slug ] = ( 'economy' === $tier ) ? 'economy' : 'authority';
|
||||
}
|
||||
update_option( 'prautoblogger_category_tiers', $tiers );
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a model selection for one step.
|
||||
*
|
||||
|
||||
@@ -48,16 +48,6 @@ class PRAutoBlogger_Pipeline_Settings_Step_Map {
|
||||
'agent_keys' => array(),
|
||||
'description' => __( 'LLM deep-research call. The user-side research brief is a setting below, not a registry prompt.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'curate',
|
||||
'label' => __( 'Curate', 'prautoblogger' ),
|
||||
'icon' => 'dashicons-filter',
|
||||
'model_option' => 'prautoblogger_curate_model',
|
||||
'capability' => 'text→text',
|
||||
'system_key' => 'curate.system',
|
||||
'agent_keys' => array(),
|
||||
'description' => __( 'Research judge: deduplicates and scores fan-out results. Authority tier only.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'analysis',
|
||||
'label' => __( 'Analysis', 'prautoblogger' ),
|
||||
@@ -88,16 +78,6 @@ class PRAutoBlogger_Pipeline_Settings_Step_Map {
|
||||
'agent_keys' => array( 'editor.review' ),
|
||||
'description' => __( 'Single-pass editorial review — approves, revises, or rejects the draft.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'seo',
|
||||
'label' => __( 'SEO', 'prautoblogger' ),
|
||||
'icon' => 'dashicons-chart-line',
|
||||
'model_option' => 'prautoblogger_seo_model',
|
||||
'capability' => 'text→text',
|
||||
'system_key' => 'seo.system',
|
||||
'agent_keys' => array(),
|
||||
'description' => __( 'Writes post-meta SEO fields and computes citation score for the publish gate. Authority tier only.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'image',
|
||||
'label' => __( 'Image', 'prautoblogger' ),
|
||||
@@ -108,16 +88,6 @@ class PRAutoBlogger_Pipeline_Settings_Step_Map {
|
||||
'agent_keys' => array( 'image.style_template' ),
|
||||
'description' => __( 'Image prompt rewriter + diffusion generation. The rewriter LLM uses the Analysis model.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'authority',
|
||||
'label' => __( 'Authority', 'prautoblogger' ),
|
||||
'icon' => 'dashicons-admin-site-alt3',
|
||||
'model_option' => null,
|
||||
'capability' => 'settings',
|
||||
'system_key' => null,
|
||||
'agent_keys' => array(),
|
||||
'description' => __( 'Master switch and per-category tier assignments for the Authority pipeline.', 'prautoblogger' ),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -83,14 +83,6 @@ class PRAutoBlogger_Article_Worker {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// P2b.4: tier routing — flag OFF always returns 'economy' (zero behaviour change).
|
||||
$tier = ( new PRAutoBlogger_Tier_Router() )->resolve( $idea );
|
||||
if ( 'authority' === $tier ) {
|
||||
$pipeline = new PRAutoBlogger_Authority_Pipeline( $this->cost_tracker );
|
||||
return $pipeline->run( $run_id, $idea, $this->cost_tracker );
|
||||
}
|
||||
// Economy path continues unchanged below...
|
||||
|
||||
// Initialize Opik trace context for this article generation.
|
||||
$opik = $this->should_trace_with_opik();
|
||||
if ( $opik ) {
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Stage execution helpers for the Authority pipeline orchestrator.
|
||||
*
|
||||
* What: Extracted from PRAutoBlogger_Authority_Pipeline to keep the
|
||||
* orchestrator under 300 lines. Contains per-stage call wrappers,
|
||||
* the imagery gate, and the hold-article helper. No external I/O
|
||||
* beyond what the individual stage classes already require.
|
||||
*
|
||||
* Who triggers it: PRAutoBlogger_Authority_Pipeline (P2b.4).
|
||||
* Dependencies: All P2b.1-P2b.3 stage classes + Content_Generator,
|
||||
* Publisher, Image_Pipeline, Run_Stage_State, Audit_Writer.
|
||||
*
|
||||
* @see core/class-authority-pipeline.php -- Orchestrator that calls these helpers.
|
||||
* @see ARCHITECTURE.md -- Phase 2b data flow.
|
||||
*/
|
||||
class PRAutoBlogger_Authority_Pipeline_Stages {
|
||||
|
||||
/**
|
||||
* Run the research fan-out stage.
|
||||
*
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @param string $item_key Article-scoped item key.
|
||||
* @param PRAutoBlogger_Article_Idea $idea The idea being researched.
|
||||
* @param PRAutoBlogger_Cost_Tracker $cost_tracker Pipeline cost tracker.
|
||||
* @param PRAutoBlogger_Research_Fanout $fanout Fan-out instance.
|
||||
* @return array<int, array{sources: array, agent_role: string}> Per-agent results, or empty on quorum miss.
|
||||
*/
|
||||
public static function run_research(
|
||||
string $run_id,
|
||||
string $item_key,
|
||||
PRAutoBlogger_Article_Idea $idea,
|
||||
PRAutoBlogger_Cost_Tracker $cost_tracker,
|
||||
PRAutoBlogger_Research_Fanout $fanout
|
||||
): array {
|
||||
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Authority: running specialist research fan-out...', 'prautoblogger' ) );
|
||||
PRAutoBlogger_Run_Stage_State::start( $run_id, 'research', 'researcher', $item_key );
|
||||
$results = $fanout->dispatch( $run_id, $item_key, $idea, $cost_tracker );
|
||||
if ( ! empty( $results ) ) {
|
||||
PRAutoBlogger_Run_Stage_State::done( $run_id, 'research', 'researcher', $item_key, (string) wp_json_encode( array( 'agent_count' => count( $results ) ) ) );
|
||||
} else {
|
||||
PRAutoBlogger_Run_Stage_State::fail( $run_id, 'research', 'researcher', $item_key );
|
||||
}
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the curate (Research_Judge) stage.
|
||||
*
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @param string $item_key Article-scoped item key.
|
||||
* @param array $fanout_results Results from research stage.
|
||||
* @param PRAutoBlogger_Research_Judge $judge Judge instance.
|
||||
* @return array<int, array{url: string, title: string, quality_score?: float}> Kept sources.
|
||||
*/
|
||||
public static function run_curate(
|
||||
string $run_id,
|
||||
string $item_key,
|
||||
array $fanout_results,
|
||||
PRAutoBlogger_Research_Judge $judge
|
||||
): array {
|
||||
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Authority: curating research sources...', 'prautoblogger' ) );
|
||||
return $judge->curate( $run_id, $item_key, $fanout_results );
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the draft (Content_Generator) stage.
|
||||
*
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @param string $item_key Article-scoped item key.
|
||||
* @param PRAutoBlogger_Article_Idea $idea The idea to draft.
|
||||
* @param PRAutoBlogger_Cost_Tracker $cost_tracker Pipeline cost tracker.
|
||||
* @param PRAutoBlogger_Content_Generator|null $generator Optional override (tests).
|
||||
* @return string Draft HTML content.
|
||||
*/
|
||||
public static function run_draft(
|
||||
string $run_id,
|
||||
string $item_key,
|
||||
PRAutoBlogger_Article_Idea $idea,
|
||||
PRAutoBlogger_Cost_Tracker $cost_tracker,
|
||||
?PRAutoBlogger_Content_Generator $generator = null
|
||||
): string {
|
||||
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Authority: generating article draft...', 'prautoblogger' ) );
|
||||
if ( null === $generator ) {
|
||||
$llm = new PRAutoBlogger_OpenRouter_Provider();
|
||||
$generator = new PRAutoBlogger_Content_Generator( $llm, $cost_tracker );
|
||||
$generator->set_run_item( $run_id, $item_key );
|
||||
}
|
||||
return $generator->generate( $idea );
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the editorial loop stage.
|
||||
*
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @param string $item_key Article-scoped item key.
|
||||
* @param string $content Draft HTML.
|
||||
* @param PRAutoBlogger_Article_Idea $idea The idea under review.
|
||||
* @param PRAutoBlogger_Cost_Tracker $cost_tracker Pipeline cost tracker.
|
||||
* @param PRAutoBlogger_Editorial_Loop $editorial Editorial loop instance.
|
||||
* @return array{content: string, escalated: bool} Result: content and escalation flag.
|
||||
*/
|
||||
public static function run_editorial(
|
||||
string $run_id,
|
||||
string $item_key,
|
||||
string $content,
|
||||
PRAutoBlogger_Article_Idea $idea,
|
||||
PRAutoBlogger_Cost_Tracker $cost_tracker,
|
||||
PRAutoBlogger_Editorial_Loop $editorial
|
||||
): array {
|
||||
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Authority: running editorial loop...', 'prautoblogger' ) );
|
||||
$reviewed = $editorial->run( $run_id, $item_key, $content, $idea, $cost_tracker );
|
||||
return array(
|
||||
'content' => '' === $reviewed ? $content : $reviewed,
|
||||
'escalated' => $editorial->was_escalated(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the SEO stage.
|
||||
*
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @param string $item_key Article-scoped item key.
|
||||
* @param int $post_id Published/draft post ID.
|
||||
* @param array $kept_sources Kept research sources from curate stage.
|
||||
* @param array $peptide_ids Related peptide post IDs.
|
||||
* @return float The computed citation_score.
|
||||
*/
|
||||
public static function run_seo(
|
||||
string $run_id,
|
||||
string $item_key,
|
||||
int $post_id,
|
||||
array $kept_sources,
|
||||
array $peptide_ids
|
||||
): float {
|
||||
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Authority: writing SEO meta...', 'prautoblogger' ) );
|
||||
$seo_stage = new PRAutoBlogger_Seo_Stage();
|
||||
return $seo_stage->run( $run_id, $item_key, $post_id, $kept_sources, $peptide_ids );
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold an article (citation gate, escalation, or cost ceiling).
|
||||
*
|
||||
* Behaviour depends on whether draft content is available:
|
||||
*
|
||||
* - NO content (empty after tag-strip): quorum miss or other pre-draft
|
||||
* failure. No post is created. The hold is recorded as a run_decisions
|
||||
* row and logged. This path never calls Publisher (avoids the
|
||||
* empty-content RuntimeException introduced in v0.18.1).
|
||||
*
|
||||
* - WITH content (editorial escalation, citation gate, or cost ceiling
|
||||
* after draft stage): saves a draft post, suppresses imagery, and
|
||||
* records the decision row. Behaviour unchanged from pre-v0.32.2.
|
||||
*
|
||||
* Side effects (no-content path): Logger info + Audit_Writer decision row.
|
||||
* Side effects (content path): Logger info + Publisher draft + post-meta
|
||||
* (_prautoblogger_imagery_suppressed) + Audit_Writer decision row.
|
||||
*
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @param string $item_key Article-scoped item key.
|
||||
* @param string $content Draft HTML ('' = pre-draft hold).
|
||||
* @param PRAutoBlogger_Article_Idea $idea The article idea.
|
||||
* @param string $hold_reason Short human-readable hold reason.
|
||||
* @param string $verdict Decision verdict for audit row.
|
||||
* @param string $run_id_for_publisher Run ID passed to Publisher (content path).
|
||||
* @return void
|
||||
*/
|
||||
public static function hold_as_draft(
|
||||
string $run_id,
|
||||
string $item_key,
|
||||
string $content,
|
||||
PRAutoBlogger_Article_Idea $idea,
|
||||
string $hold_reason,
|
||||
string $verdict,
|
||||
string $run_id_for_publisher
|
||||
): void {
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf( 'Authority pipeline HOLD for "%s": %s', $idea->get_topic(), $hold_reason ),
|
||||
'authority-pipeline'
|
||||
);
|
||||
|
||||
// v0.32.2: when there is no draft content yet (quorum miss or other
|
||||
// pre-draft failure), skip Publisher entirely -- Publisher::save_as_draft()
|
||||
// throws RuntimeException on empty content (v0.18.1 guard). Record the
|
||||
// hold decision only; the run state is returned as 'held' by the caller.
|
||||
if ( '' === trim( wp_strip_all_tags( $content ) ) ) {
|
||||
PRAutoBlogger_Audit_Writer::record_decision( $run_id, 'publish-gate', $verdict, $hold_reason );
|
||||
return;
|
||||
}
|
||||
|
||||
// Content is present: save as draft and suppress imagery (editorial
|
||||
// escalation, citation gate, or cost ceiling after the draft stage).
|
||||
$publisher = new PRAutoBlogger_Publisher();
|
||||
$review = new PRAutoBlogger_Editorial_Review(
|
||||
array(
|
||||
'verdict' => 'rejected',
|
||||
'notes' => $hold_reason,
|
||||
'revised_content' => null,
|
||||
'quality_score' => 0.0,
|
||||
'seo_score' => 0.0,
|
||||
'issues' => array(),
|
||||
)
|
||||
);
|
||||
$post_id = $publisher->save_as_draft( $content, $idea, $review, $run_id_for_publisher, null, 'authority' );
|
||||
|
||||
// Imagery gate: suppress image generation on held articles.
|
||||
if ( $post_id > 0 ) {
|
||||
update_post_meta( $post_id, '_prautoblogger_imagery_suppressed', '1' );
|
||||
}
|
||||
|
||||
// Record the hold decision.
|
||||
PRAutoBlogger_Audit_Writer::record_decision( $run_id, 'publish-gate', $verdict, $hold_reason );
|
||||
}
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Authority-tier article generation pipeline orchestrator.
|
||||
*
|
||||
* What: Wires together P2b.1 (Research_Fanout + Research_Judge), P2b.2
|
||||
* (Editorial_Loop), and P2b.3 (Seo_Stage) into the 6-stage Authority
|
||||
* generation path. Gated behind the master flag
|
||||
* `prautoblogger_authority_pipeline_enabled` (default FALSE via
|
||||
* Tier_Router) — never runs in production until an operator opts in.
|
||||
*
|
||||
* Stage chain:
|
||||
* 1. research — Research_Fanout::dispatch() → quorum miss → HOLD
|
||||
* 2. curate — Research_Judge::curate() → kept sources
|
||||
* 3. draft — Content_Generator::generate() → creates WP post
|
||||
* 4. editorial — Editorial_Loop::run() → escalated → HOLD
|
||||
* 5. seo — Seo_Stage::run() → citation_score
|
||||
* 6. gate — citation_score >= threshold → publish; else HOLD
|
||||
*
|
||||
* Cost ceiling: PRAutoBlogger_Cost_Ceiling_Exception → HOLD (never
|
||||
* force-complete). Per-run $0.50 ceiling inherited from Cost_Governor.
|
||||
*
|
||||
* Who triggers it: PRAutoBlogger_Article_Worker::generate() when tier='authority'.
|
||||
* Dependencies: All P2b.1–P2b.3 stage classes, Publisher, Cost_Governor,
|
||||
* Run_Stage_State, Audit_Writer, Image_Pipeline (via Post_Assembler).
|
||||
*
|
||||
* @see providers/interface-authority-pipeline.php — Interface.
|
||||
* @see core/class-authority-pipeline-stages.php — Stage helpers (split for 300-line rule).
|
||||
* @see core/class-tier-router.php — Routes here on 'authority'.
|
||||
* @see core/class-article-worker.php — Economy fallback path.
|
||||
* @see ARCHITECTURE.md — Phase 2b data flow.
|
||||
*/
|
||||
class PRAutoBlogger_Authority_Pipeline implements PRAutoBlogger_Authority_Pipeline_Interface {
|
||||
|
||||
/** Option key for the citation score publish gate threshold. */
|
||||
public const OPTION_THRESHOLD = 'prautoblogger_citation_score_threshold';
|
||||
|
||||
/** Default threshold: 0.0 — gate always passes until calibrated. */
|
||||
public const DEFAULT_THRESHOLD = 0.0;
|
||||
|
||||
/** @var PRAutoBlogger_Cost_Tracker Pipeline cost tracker. */
|
||||
private PRAutoBlogger_Cost_Tracker $cost_tracker;
|
||||
|
||||
/** @var PRAutoBlogger_Research_Fanout|null Injected for tests. */
|
||||
private ?PRAutoBlogger_Research_Fanout $fanout;
|
||||
|
||||
/** @var PRAutoBlogger_Research_Judge|null Injected for tests. */
|
||||
private ?PRAutoBlogger_Research_Judge $judge;
|
||||
|
||||
/** @var PRAutoBlogger_Editorial_Loop|null Injected for tests. */
|
||||
private ?PRAutoBlogger_Editorial_Loop $editorial;
|
||||
|
||||
/** @var PRAutoBlogger_Content_Generator|null Injected for tests. */
|
||||
private ?PRAutoBlogger_Content_Generator $generator;
|
||||
|
||||
/**
|
||||
* @param PRAutoBlogger_Cost_Tracker $cost_tracker Pipeline cost tracker.
|
||||
* @param PRAutoBlogger_Research_Fanout|null $fanout Optional fan-out override (tests).
|
||||
* @param PRAutoBlogger_Research_Judge|null $judge Optional judge override (tests).
|
||||
* @param PRAutoBlogger_Editorial_Loop|null $editorial Optional editorial loop override (tests).
|
||||
* @param PRAutoBlogger_Content_Generator|null $generator Optional generator override (tests).
|
||||
*/
|
||||
public function __construct(
|
||||
PRAutoBlogger_Cost_Tracker $cost_tracker,
|
||||
?PRAutoBlogger_Research_Fanout $fanout = null,
|
||||
?PRAutoBlogger_Research_Judge $judge = null,
|
||||
?PRAutoBlogger_Editorial_Loop $editorial = null,
|
||||
?PRAutoBlogger_Content_Generator $generator = null
|
||||
) {
|
||||
$this->cost_tracker = $cost_tracker;
|
||||
$this->fanout = $fanout;
|
||||
$this->judge = $judge;
|
||||
$this->editorial = $editorial;
|
||||
$this->generator = $generator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full Authority-tier pipeline for one article.
|
||||
*
|
||||
* Catches PRAutoBlogger_Cost_Ceiling_Exception at the top level and
|
||||
* converts it to a HOLD (never a force-complete). All other exceptions
|
||||
* propagate to the Article_Worker's outer Throwable catch (which logs
|
||||
* + marks open stages as failed).
|
||||
*
|
||||
* Side effects: LLM API calls, DB writes, WP post creation, imagery
|
||||
* gate post-meta, audit rows (run_stages + run_decisions), Logger calls.
|
||||
*
|
||||
* @param string $run_id Unique run identifier.
|
||||
* @param PRAutoBlogger_Article_Idea $idea The article idea to process.
|
||||
* @param PRAutoBlogger_Cost_Tracker $cost_tracker Active cost tracker for this run.
|
||||
* @return array{generated: int, published: int, rejected: int, cost: float, status: string}
|
||||
*/
|
||||
public function run(
|
||||
string $run_id,
|
||||
PRAutoBlogger_Article_Idea $idea,
|
||||
PRAutoBlogger_Cost_Tracker $cost_tracker
|
||||
): array {
|
||||
$item_key = PRAutoBlogger_Run_Stage_State::item_key_for_idea( $idea );
|
||||
$result = array(
|
||||
'generated' => 0,
|
||||
'published' => 0,
|
||||
'rejected' => 0,
|
||||
'cost' => 0.0,
|
||||
'status' => 'pending',
|
||||
);
|
||||
|
||||
try {
|
||||
$result = $this->execute_pipeline( $run_id, $item_key, $idea, $cost_tracker, $result );
|
||||
} catch ( PRAutoBlogger_Cost_Ceiling_Exception $e ) {
|
||||
// Ceiling breach: hold the article as draft — never force-complete.
|
||||
PRAutoBlogger_Authority_Pipeline_Stages::hold_as_draft(
|
||||
$run_id,
|
||||
$item_key,
|
||||
'<p>[Article generation halted by per-run cost ceiling.]</p>',
|
||||
$idea,
|
||||
$e->getMessage(),
|
||||
'halted',
|
||||
$run_id
|
||||
);
|
||||
$result['status'] = 'halted';
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
sprintf( 'Authority pipeline halted for "%s" (cost ceiling): %s', $idea->get_topic(), $e->getMessage() ),
|
||||
'authority-pipeline'
|
||||
);
|
||||
}
|
||||
|
||||
$result['cost'] = $cost_tracker->get_current_run_cost();
|
||||
return $result;
|
||||
}
|
||||
|
||||
// ── Private pipeline body ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Execute the 6-stage pipeline, returning the result array.
|
||||
*
|
||||
* Throws PRAutoBlogger_Cost_Ceiling_Exception on budget breach —
|
||||
* caller (run()) converts to HOLD.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $item_key Article-scoped item key.
|
||||
* @param PRAutoBlogger_Article_Idea $idea The article idea.
|
||||
* @param PRAutoBlogger_Cost_Tracker $cost_tracker Pipeline cost tracker.
|
||||
* @param array $result Result accumulator.
|
||||
* @return array{generated: int, published: int, rejected: int, cost: float, status: string}
|
||||
* @throws PRAutoBlogger_Cost_Ceiling_Exception When the cost ceiling is breached.
|
||||
*/
|
||||
private function execute_pipeline(
|
||||
string $run_id,
|
||||
string $item_key,
|
||||
PRAutoBlogger_Article_Idea $idea,
|
||||
PRAutoBlogger_Cost_Tracker $cost_tracker,
|
||||
array $result
|
||||
): array {
|
||||
// Stage 1: Research fan-out.
|
||||
$fanout = $this->fanout ?? new PRAutoBlogger_Research_Fanout();
|
||||
$fan_results = PRAutoBlogger_Authority_Pipeline_Stages::run_research( $run_id, $item_key, $idea, $cost_tracker, $fanout );
|
||||
if ( empty( $fan_results ) ) {
|
||||
PRAutoBlogger_Authority_Pipeline_Stages::hold_as_draft(
|
||||
$run_id,
|
||||
$item_key,
|
||||
'',
|
||||
$idea,
|
||||
'Research quorum not met — holding for re-run.',
|
||||
'quorum-miss',
|
||||
$run_id
|
||||
);
|
||||
$result['status'] = 'held-quorum';
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Stage 2: Curate.
|
||||
$judge = $this->judge ?? new PRAutoBlogger_Research_Judge();
|
||||
$kept_sources = PRAutoBlogger_Authority_Pipeline_Stages::run_curate( $run_id, $item_key, $fan_results, $judge );
|
||||
|
||||
// Stage 3: Draft.
|
||||
$draft_content = PRAutoBlogger_Authority_Pipeline_Stages::run_draft( $run_id, $item_key, $idea, $cost_tracker, $this->generator );
|
||||
$result['generated'] = 1;
|
||||
|
||||
// Stage 4: Editorial loop.
|
||||
$llm = new PRAutoBlogger_OpenRouter_Provider();
|
||||
$chief_editor = new PRAutoBlogger_Chief_Editor( $llm, $cost_tracker );
|
||||
$rev_caller = new PRAutoBlogger_Editorial_Revision_Caller( $llm, $cost_tracker );
|
||||
$editorial = $this->editorial ?? new PRAutoBlogger_Editorial_Loop( $chief_editor, $rev_caller );
|
||||
$editorial_out = PRAutoBlogger_Authority_Pipeline_Stages::run_editorial(
|
||||
$run_id,
|
||||
$item_key,
|
||||
$draft_content,
|
||||
$idea,
|
||||
$cost_tracker,
|
||||
$editorial
|
||||
);
|
||||
$final_content = $editorial_out['content'];
|
||||
$escalated = $editorial_out['escalated'];
|
||||
|
||||
if ( $escalated ) {
|
||||
PRAutoBlogger_Authority_Pipeline_Stages::hold_as_draft(
|
||||
$run_id,
|
||||
$item_key,
|
||||
$final_content,
|
||||
$idea,
|
||||
'Editorial loop exhausted max rounds — held for human review.',
|
||||
'escalated',
|
||||
$run_id
|
||||
);
|
||||
$result['status'] = 'held-escalated';
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Stage 3b: Save draft (needed for SEO stage and citation gate).
|
||||
$publisher = new PRAutoBlogger_Publisher();
|
||||
$stub_review = new PRAutoBlogger_Editorial_Review(
|
||||
array(
|
||||
'verdict' => 'approved',
|
||||
'notes' => 'Authority pipeline editorial approved.',
|
||||
'revised_content' => null,
|
||||
'quality_score' => 0.85,
|
||||
'seo_score' => 0.80,
|
||||
'issues' => array(),
|
||||
)
|
||||
);
|
||||
$auto_publish = in_array( get_option( 'prautoblogger_auto_publish', '0' ), array( '1', 'yes' ), true );
|
||||
$post_id = $publisher->save_as_draft( $final_content, $idea, $stub_review, $run_id, null, 'authority' );
|
||||
PRAutoBlogger_Run_Stage_State::start( $run_id, 'publish', (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( 'publish' ), $item_key );
|
||||
|
||||
// Stage 5: SEO.
|
||||
$citation_score = PRAutoBlogger_Authority_Pipeline_Stages::run_seo( $run_id, $item_key, $post_id, $kept_sources, array() );
|
||||
|
||||
// Stage 6: Publish gate.
|
||||
$threshold = (float) get_option( self::OPTION_THRESHOLD, self::DEFAULT_THRESHOLD );
|
||||
if ( $citation_score >= $threshold && ! $escalated ) {
|
||||
if ( $auto_publish ) {
|
||||
wp_publish_post( $post_id );
|
||||
// Image pipeline for published articles.
|
||||
PRAutoBlogger_Post_Assembler::attach_generated_images(
|
||||
$post_id,
|
||||
$idea,
|
||||
array(
|
||||
'post_title' => $idea->get_suggested_title(),
|
||||
'post_content' => $final_content,
|
||||
),
|
||||
$cost_tracker
|
||||
);
|
||||
$result['published'] = 1;
|
||||
}
|
||||
PRAutoBlogger_Run_Stage_State::done( $run_id, 'publish', '', $item_key );
|
||||
PRAutoBlogger_Audit_Writer::record_decision(
|
||||
$run_id,
|
||||
'publish-gate',
|
||||
'approved',
|
||||
sprintf( 'citation_score=%.4f >= threshold=%.4f', $citation_score, $threshold ),
|
||||
$citation_score
|
||||
);
|
||||
$result['status'] = 'published';
|
||||
} else {
|
||||
// Citation gate: hold (imagery suppressed).
|
||||
update_post_meta( $post_id, '_prautoblogger_imagery_suppressed', '1' );
|
||||
PRAutoBlogger_Run_Stage_State::done( $run_id, 'publish', '', $item_key );
|
||||
PRAutoBlogger_Audit_Writer::record_decision(
|
||||
$run_id,
|
||||
'publish-gate',
|
||||
'held',
|
||||
sprintf( 'citation_score=%.4f < threshold=%.4f — held as draft.', $citation_score, $threshold ),
|
||||
$citation_score
|
||||
);
|
||||
$result['status'] = 'held-citation';
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,8 @@ declare(strict_types=1);
|
||||
* 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.
|
||||
* @see core/class-content-generator.php — Consumer of these prompts.
|
||||
* @see models/class-content-request.php — Data bag passed to every builder.
|
||||
*/
|
||||
class PRAutoBlogger_Content_Prompts {
|
||||
|
||||
@@ -135,43 +135,6 @@ class PRAutoBlogger_Content_Prompts {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the system prompt for an Authority-tier editorial revision call.
|
||||
*
|
||||
* Used by Editorial_Revision_Caller when asking the writer to revise a
|
||||
* draft given the chief editor's critique. Keeps the revision focused on
|
||||
* applying specific feedback rather than a full re-draft.
|
||||
*
|
||||
* @param PRAutoBlogger_Article_Idea $idea The article idea under revision.
|
||||
* @return string System prompt for the revision call.
|
||||
*/
|
||||
public static function build_revision_system( PRAutoBlogger_Article_Idea $idea ): string {
|
||||
$niche = (string) get_option( 'prautoblogger_niche_description', '' );
|
||||
$niche_clause = '' !== $niche ? " specializing in {$niche}" : '';
|
||||
|
||||
return sprintf(
|
||||
"You are an expert writer%s. You have written a draft article and received feedback from your chief editor. Your task is to revise the article to address EVERY point raised in the editor's critique. Do NOT rewrite sections that were not criticised. Return ONLY the revised HTML article with no commentary, preamble, or metadata. Topic: %s. Title: %s.",
|
||||
$niche_clause,
|
||||
$idea->get_topic(),
|
||||
$idea->get_suggested_title()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the user prompt for an Authority-tier editorial revision call.
|
||||
*
|
||||
* @param string $current_draft Current HTML draft to be revised.
|
||||
* @param string $editor_notes Critique notes from the chief editor.
|
||||
* @return string User prompt for the revision call.
|
||||
*/
|
||||
public static function build_revision_user( string $current_draft, string $editor_notes ): string {
|
||||
return sprintf(
|
||||
"EDITOR CRITIQUE:\n%s\n\n---\nCURRENT DRAFT:\n%s\n\n---\nPlease revise the draft above to address every point in the editor critique. Return the complete revised article as HTML only.",
|
||||
$editor_notes,
|
||||
$current_draft
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the linking rules section (internal articles + peptide database).
|
||||
*
|
||||
@@ -185,7 +148,7 @@ class PRAutoBlogger_Content_Prompts {
|
||||
$rules .= "NEVER fabricate or invent URLs. You do NOT have access to external sources.\n";
|
||||
$rules .= "Only use the internal links listed below when linking within the article.\n";
|
||||
$rules .= "If no listed article is relevant to a section, do not insert a link.\n";
|
||||
$rules .= "Do NOT add links for peptide names -- those are injected automatically after generation.\n\n";
|
||||
$rules .= "Do NOT add links for peptide names — those are injected automatically after generation.\n\n";
|
||||
|
||||
$rules .= self::build_article_links();
|
||||
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Authority-tier editorial loop — bounded iterative editor↔writer review.
|
||||
*
|
||||
* What: Wraps Chief_Editor::review() into a bounded loop (≤
|
||||
* `prautoblogger_editorial_max_rounds`, default 3). Each round the
|
||||
* editor critiques; if not approved, Editorial_Revision_Caller runs
|
||||
* a targeted writer revision; if approved, the loop exits. After max
|
||||
* rounds without approval the article escalates to the Review Queue
|
||||
* (run() returns ''). Every round is recorded to the audit trail (one
|
||||
* run_decisions row per round; run_stages start→done per iteration).
|
||||
* Cost: each LLM call reserves worst-case cost via the existing
|
||||
* provider seam. Article_Worker (single-pass) is untouched — ADDITIVE.
|
||||
*
|
||||
* Who triggers it: PRAutoBlogger_Authority_Pipeline (P2b.4, NOT live yet).
|
||||
* Dependencies: PRAutoBlogger_Chief_Editor, PRAutoBlogger_Editorial_Revision_Caller,
|
||||
* PRAutoBlogger_Audit_Writer, PRAutoBlogger_Run_Stage_State,
|
||||
* PRAutoBlogger_Pipeline_Status, PRAutoBlogger_Logger.
|
||||
*
|
||||
* @see providers/interface-editorial-loop.php — Interface this implements.
|
||||
* @see core/class-chief-editor.php — Single-pass editor (reused).
|
||||
* @see core/class-editorial-revision-caller.php — Writer revision step (extracted).
|
||||
* @see core/class-audit-writer.php — Round records + decision audit.
|
||||
* @see ARCHITECTURE.md — Phase 2b editorial stage.
|
||||
*/
|
||||
class PRAutoBlogger_Editorial_Loop implements PRAutoBlogger_Editorial_Loop_Interface {
|
||||
|
||||
/** Default maximum rounds when the option is unset. */
|
||||
public const DEFAULT_MAX_ROUNDS = 3;
|
||||
|
||||
/** Minimum configurable rounds (safety floor). */
|
||||
private const MIN_ROUNDS = 1;
|
||||
|
||||
/** Maximum configurable rounds (sanity cap). */
|
||||
private const MAX_ROUNDS = 10;
|
||||
|
||||
/** @var PRAutoBlogger_Chief_Editor Editor agent. */
|
||||
private PRAutoBlogger_Chief_Editor $editor;
|
||||
|
||||
/** @var PRAutoBlogger_Editorial_Revision_Caller Writer revision step. */
|
||||
private PRAutoBlogger_Editorial_Revision_Caller $revision_caller;
|
||||
|
||||
/** @var bool Whether the last run() call escalated to the Review Queue. */
|
||||
private bool $escalated = false;
|
||||
|
||||
/** @var PRAutoBlogger_Editorial_Round[] Round records from the last run(). */
|
||||
private array $rounds = array();
|
||||
|
||||
/**
|
||||
* @param PRAutoBlogger_Chief_Editor $editor Editor agent.
|
||||
* @param PRAutoBlogger_Editorial_Revision_Caller $revision_caller Writer revision step.
|
||||
*/
|
||||
public function __construct(
|
||||
PRAutoBlogger_Chief_Editor $editor,
|
||||
PRAutoBlogger_Editorial_Revision_Caller $revision_caller
|
||||
) {
|
||||
$this->editor = $editor;
|
||||
$this->revision_caller = $revision_caller;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the bounded editorial loop for one article.
|
||||
*
|
||||
* Each round: editor critiques → writer revises if not approved → repeat.
|
||||
* Returns '' and sets was_escalated()=true after max rounds without approval.
|
||||
*
|
||||
* Side effects: LLM API calls, run_stages + run_decisions DB writes,
|
||||
* status broadcasts, Logger calls.
|
||||
*
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @param string $item_key Article-scoped stage key.
|
||||
* @param string $content Initial draft HTML.
|
||||
* @param PRAutoBlogger_Article_Idea $idea The article idea under review.
|
||||
* @param PRAutoBlogger_Cost_Tracker $cost_tracker Pipeline cost tracker.
|
||||
* @return string Approved/revised HTML, or '' on Review-Queue escalation.
|
||||
*/
|
||||
public function run(
|
||||
string $run_id,
|
||||
string $item_key,
|
||||
string $content,
|
||||
PRAutoBlogger_Article_Idea $idea,
|
||||
PRAutoBlogger_Cost_Tracker $cost_tracker
|
||||
): string {
|
||||
$this->escalated = false;
|
||||
$this->rounds = array();
|
||||
$max_rounds = $this->resolve_max_rounds();
|
||||
$current = $content;
|
||||
|
||||
for ( $round = 1; $round <= $max_rounds; $round++ ) {
|
||||
PRAutoBlogger_Pipeline_Status::broadcast(
|
||||
sprintf(
|
||||
/* translators: 1: current round, 2: max rounds. */
|
||||
__( 'Editorial review — round %1$d of %2$d…', 'prautoblogger' ),
|
||||
$round,
|
||||
$max_rounds
|
||||
)
|
||||
);
|
||||
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf( 'Editorial loop: round %d/%d for "%s".', $round, $max_rounds, mb_substr( $idea->get_topic(), 0, 60 ) ),
|
||||
'editorial-loop'
|
||||
);
|
||||
|
||||
PRAutoBlogger_Run_Stage_State::start( $run_id, 'editorial', 'editor', $item_key );
|
||||
$review = $this->editor->review( $current, $idea );
|
||||
$verdict = $review->get_verdict();
|
||||
$notes = $review->get_notes();
|
||||
|
||||
if ( 'approved' === $verdict ) {
|
||||
$record = $this->make_round( $round, $notes, $verdict, '', $review );
|
||||
$this->rounds[] = $record;
|
||||
$this->record_round( $run_id, $item_key, $record );
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf( 'Editorial loop: APPROVED at round %d.', $round ),
|
||||
'editorial-loop'
|
||||
);
|
||||
return $current;
|
||||
}
|
||||
|
||||
// Non-approved: get revised content (inline from editor, or a writer call).
|
||||
$revised = $this->get_revised_content( $round, $run_id, $item_key, $current, $idea, $review, $cost_tracker );
|
||||
|
||||
$record = $this->make_round( $round, $notes, $verdict, $revised, $review );
|
||||
$this->rounds[] = $record;
|
||||
$this->record_round( $run_id, $item_key, $record );
|
||||
$current = '' !== $revised ? $revised : $current;
|
||||
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf( 'Editorial loop: round %d done (verdict=%s).', $round, $verdict ),
|
||||
'editorial-loop'
|
||||
);
|
||||
}
|
||||
|
||||
// Exhausted all rounds without approval.
|
||||
$this->escalated = true;
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf( 'Editorial loop: max rounds (%d) exhausted for "%s". Escalating.', $max_rounds, mb_substr( $idea->get_topic(), 0, 80 ) ),
|
||||
'editorial-loop'
|
||||
);
|
||||
$this->record_escalation( $run_id, $item_key, $max_rounds );
|
||||
return '';
|
||||
}
|
||||
|
||||
/** @return bool Whether the last run() escalated to the Review Queue. */
|
||||
public function was_escalated(): bool {
|
||||
return $this->escalated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Round records from the most recent run() call.
|
||||
*
|
||||
* @return PRAutoBlogger_Editorial_Round[]
|
||||
*/
|
||||
public function get_rounds(): array {
|
||||
return $this->rounds;
|
||||
}
|
||||
|
||||
// ── Private helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the configured max rounds, clamped to [MIN_ROUNDS, MAX_ROUNDS].
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
private function resolve_max_rounds(): int {
|
||||
$n = (int) get_option( 'prautoblogger_editorial_max_rounds', self::DEFAULT_MAX_ROUNDS );
|
||||
return max( self::MIN_ROUNDS, min( self::MAX_ROUNDS, $n ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce revised content: prefers inline revised_content; falls back to Editorial_Revision_Caller.
|
||||
*
|
||||
* @param int $round Round number.
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $item_key Stage item key.
|
||||
* @param string $current Current draft HTML.
|
||||
* @param PRAutoBlogger_Article_Idea $idea Article idea.
|
||||
* @param PRAutoBlogger_Editorial_Review $review Editor's review for this round.
|
||||
* @param PRAutoBlogger_Cost_Tracker $cost_tracker Cost tracker for revision call.
|
||||
* @return string Revised HTML.
|
||||
*/
|
||||
private function get_revised_content(
|
||||
int $round,
|
||||
string $run_id,
|
||||
string $item_key,
|
||||
string $current,
|
||||
PRAutoBlogger_Article_Idea $idea,
|
||||
PRAutoBlogger_Editorial_Review $review,
|
||||
PRAutoBlogger_Cost_Tracker $cost_tracker
|
||||
): string {
|
||||
$inline = $review->get_revised_content();
|
||||
if ( 'revised' === $review->get_verdict() && null !== $inline && '' !== $inline ) {
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf( 'Editorial loop: round %d — using editor inline revision.', $round ),
|
||||
'editorial-loop'
|
||||
);
|
||||
return (string) $inline;
|
||||
}
|
||||
|
||||
return $this->revision_caller->call(
|
||||
$round,
|
||||
$run_id,
|
||||
$item_key,
|
||||
$current,
|
||||
$idea,
|
||||
$review->get_notes()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct an Editorial_Round value object for one loop iteration.
|
||||
*
|
||||
* @param int $round 1-based round number.
|
||||
* @param string $notes Editor critique notes.
|
||||
* @param string $verdict Editor verdict for this round.
|
||||
* @param string $revised Writer's revised content ('' for approved).
|
||||
* @param PRAutoBlogger_Editorial_Review $review Full review for score extraction.
|
||||
* @return PRAutoBlogger_Editorial_Round
|
||||
*/
|
||||
private function make_round(
|
||||
int $round,
|
||||
string $notes,
|
||||
string $verdict,
|
||||
string $revised,
|
||||
PRAutoBlogger_Editorial_Review $review
|
||||
): PRAutoBlogger_Editorial_Round {
|
||||
return new PRAutoBlogger_Editorial_Round(
|
||||
$round,
|
||||
$notes,
|
||||
$verdict,
|
||||
$revised,
|
||||
$review->get_quality_score(),
|
||||
$review->get_seo_score()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist one completed round to run_stages + run_decisions.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $item_key Stage item key.
|
||||
* @param PRAutoBlogger_Editorial_Round $record Round to persist.
|
||||
* @return void
|
||||
*/
|
||||
private function record_round(
|
||||
string $run_id,
|
||||
string $item_key,
|
||||
PRAutoBlogger_Editorial_Round $record
|
||||
): void {
|
||||
PRAutoBlogger_Run_Stage_State::done(
|
||||
$run_id,
|
||||
'editorial',
|
||||
'editor',
|
||||
$item_key,
|
||||
(string) wp_json_encode( $record->to_array() )
|
||||
);
|
||||
PRAutoBlogger_Audit_Writer::record_decision(
|
||||
$run_id,
|
||||
'editorial',
|
||||
$record->get_editor_verdict(),
|
||||
sprintf( 'Round %d: %s', $record->get_round_number(), $record->get_editor_notes() )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the escalation event to run_stages + run_decisions.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $item_key Stage item key.
|
||||
* @param int $max_rounds Max rounds that were exhausted.
|
||||
* @return void
|
||||
*/
|
||||
private function record_escalation( string $run_id, string $item_key, int $max_rounds ): void {
|
||||
PRAutoBlogger_Run_Stage_State::done(
|
||||
$run_id,
|
||||
'editorial',
|
||||
'editor',
|
||||
$item_key,
|
||||
(string) wp_json_encode(
|
||||
array(
|
||||
'escalated' => true,
|
||||
'max_rounds' => $max_rounds,
|
||||
'rounds' => array_map( fn( $r ) => $r->to_array(), $this->rounds ),
|
||||
)
|
||||
)
|
||||
);
|
||||
PRAutoBlogger_Audit_Writer::record_decision(
|
||||
$run_id,
|
||||
'editorial',
|
||||
'escalated',
|
||||
sprintf( 'Max editorial rounds (%d) exhausted. Escalated to Review Queue.', $max_rounds )
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Writer-revision step for the Authority editorial loop.
|
||||
*
|
||||
* What: Extracted from Editorial_Loop (300-line split, v0.29.0). Holds the
|
||||
* single-round writer revision logic — builds the revision prompts,
|
||||
* dispatches the LLM call, logs cost, and returns the revised HTML.
|
||||
* Editorial_Loop delegates to this class whenever the chief editor
|
||||
* returns a non-approved verdict and no inline revision was provided.
|
||||
* Who triggers it: PRAutoBlogger_Editorial_Loop::run_writer_revision().
|
||||
* Dependencies: PRAutoBlogger_LLM_Provider_Interface (writer model call),
|
||||
* PRAutoBlogger_Cost_Tracker (log_api_call), PRAutoBlogger_Content_Prompts
|
||||
* (build_revision_system / build_revision_user), PRAutoBlogger_Run_Stage_State
|
||||
* (stage lifecycle), PRAutoBlogger_Logger.
|
||||
*
|
||||
* @see core/class-editorial-loop.php — Orchestrator; creates this object.
|
||||
* @see core/class-content-prompts.php — Prompt builders for revision calls.
|
||||
* @see ARCHITECTURE.md — Phase 2b editorial loop design.
|
||||
*/
|
||||
class PRAutoBlogger_Editorial_Revision_Caller {
|
||||
|
||||
/** @var PRAutoBlogger_LLM_Provider_Interface LLM provider for the writer model. */
|
||||
private PRAutoBlogger_LLM_Provider_Interface $writer_llm;
|
||||
|
||||
/** @var PRAutoBlogger_Cost_Tracker Shared cost tracker. */
|
||||
private PRAutoBlogger_Cost_Tracker $cost_tracker;
|
||||
|
||||
/**
|
||||
* @param PRAutoBlogger_LLM_Provider_Interface $writer_llm LLM provider for revision calls.
|
||||
* @param PRAutoBlogger_Cost_Tracker $cost_tracker Pipeline cost tracker.
|
||||
*/
|
||||
public function __construct(
|
||||
PRAutoBlogger_LLM_Provider_Interface $writer_llm,
|
||||
PRAutoBlogger_Cost_Tracker $cost_tracker
|
||||
) {
|
||||
$this->writer_llm = $writer_llm;
|
||||
$this->cost_tracker = $cost_tracker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the writer LLM to revise the current draft given the editor's critique.
|
||||
*
|
||||
* Dispatches a targeted revision call via the injected writer_llm provider.
|
||||
* Cost is reserved before dispatch via the existing OpenRouter provider
|
||||
* seam (Cost_Governor::open_chat_reservation() is called inside
|
||||
* send_chat_completion) and logged via the shared cost tracker.
|
||||
* Returns the prior draft unchanged on empty LLM response (never silent
|
||||
* pass of an empty revision).
|
||||
*
|
||||
* Side effects: one OpenRouter API call, one generation_log row, two
|
||||
* run_stages transitions (start→done for role='writer').
|
||||
*
|
||||
* @param int $round 1-based round number (for logging and stage snapshot).
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @param string $item_key Article-scoped stage item key.
|
||||
* @param string $current Current draft HTML to revise.
|
||||
* @param PRAutoBlogger_Article_Idea $idea Article idea (for topic/title context).
|
||||
* @param string $editor_notes Chief editor critique to address.
|
||||
* @return string Revised HTML content, or $current if the LLM returned empty.
|
||||
*/
|
||||
public function call(
|
||||
int $round,
|
||||
string $run_id,
|
||||
string $item_key,
|
||||
string $current,
|
||||
PRAutoBlogger_Article_Idea $idea,
|
||||
string $editor_notes
|
||||
): string {
|
||||
PRAutoBlogger_Run_Stage_State::start( $run_id, 'editorial', 'writer', $item_key );
|
||||
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf( 'Editorial revision: round %d — dispatching writer revision.', $round ),
|
||||
'editorial-loop'
|
||||
);
|
||||
|
||||
$model = (string) get_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
|
||||
$messages = array(
|
||||
array(
|
||||
'role' => 'system',
|
||||
'content' => PRAutoBlogger_Content_Prompts::build_revision_system( $idea ),
|
||||
),
|
||||
array(
|
||||
'role' => 'user',
|
||||
'content' => PRAutoBlogger_Content_Prompts::build_revision_user( $current, $editor_notes ),
|
||||
),
|
||||
);
|
||||
|
||||
$response = $this->writer_llm->send_chat_completion(
|
||||
$messages,
|
||||
$model,
|
||||
array(
|
||||
'temperature' => 0.5,
|
||||
'max_tokens' => 5000,
|
||||
'stage' => 'editorial',
|
||||
'prompt_key' => 'content.revision',
|
||||
)
|
||||
);
|
||||
|
||||
$this->cost_tracker->log_api_call(
|
||||
null,
|
||||
'editorial',
|
||||
$this->writer_llm->get_provider_name(),
|
||||
(string) ( $response['model'] ?? $model ),
|
||||
(int) ( $response['prompt_tokens'] ?? 0 ),
|
||||
(int) ( $response['completion_tokens'] ?? 0 ),
|
||||
'success',
|
||||
'',
|
||||
null,
|
||||
'content.revision'
|
||||
);
|
||||
|
||||
$revised = trim( (string) ( $response['content'] ?? '' ) );
|
||||
if ( '' === $revised ) {
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf(
|
||||
'Editorial revision: round %d writer returned empty content; retaining prior draft.',
|
||||
$round
|
||||
),
|
||||
'editorial-loop'
|
||||
);
|
||||
$revised = $current;
|
||||
}
|
||||
|
||||
PRAutoBlogger_Run_Stage_State::done(
|
||||
$run_id,
|
||||
'editorial',
|
||||
'writer',
|
||||
$item_key,
|
||||
(string) wp_json_encode( array( 'round' => $round ) )
|
||||
);
|
||||
|
||||
return $revised;
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,6 @@ declare(strict_types=1);
|
||||
*
|
||||
* What: After wp_insert_post, assigns categories/tags, links generation logs,
|
||||
* generates and attaches images, and sanitizes raw LLM content.
|
||||
* P2-2: attach_generated_images() checks _prautoblogger_imagery_suppressed
|
||||
* post meta and returns early when set, preventing imagery leaking on
|
||||
* held Authority articles (citation gate miss, quorum miss, editorial
|
||||
* escalation, or cost ceiling halt).
|
||||
* Who calls it: PRAutoBlogger_Publisher::create_post().
|
||||
* Dependencies: WordPress taxonomy/meta APIs, PRAutoBlogger_Image_Pipeline, PRAutoBlogger_Logger.
|
||||
*
|
||||
@@ -239,10 +235,6 @@ class PRAutoBlogger_Post_Assembler {
|
||||
/**
|
||||
* Generate and attach images to a published post.
|
||||
* Non-blocking — logs errors but doesn't re-throw since the post is already created.
|
||||
* P2-2: Checks _prautoblogger_imagery_suppressed before entering the image
|
||||
* pipeline. When set, skips image generation entirely so held Authority
|
||||
* articles (citation gate miss, quorum miss, editorial escalation, or cost
|
||||
* ceiling halt) don't accidentally receive images.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param PRAutoBlogger_Article_Idea $idea Article idea (contains source IDs).
|
||||
@@ -250,16 +242,6 @@ class PRAutoBlogger_Post_Assembler {
|
||||
* @param ?PRAutoBlogger_Cost_Tracker $cost_tracker Optional cost tracker for image generation cost logging.
|
||||
*/
|
||||
public static function attach_generated_images( int $post_id, PRAutoBlogger_Article_Idea $idea, array $post_data, ?PRAutoBlogger_Cost_Tracker $cost_tracker = null ): void {
|
||||
// Honour the imagery-suppressed flag: skip image generation for held articles
|
||||
// (citation gate miss, quorum miss, editorial escalation, or cost ceiling halt).
|
||||
if ( '1' === get_post_meta( $post_id, '_prautoblogger_imagery_suppressed', true ) ) {
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf( 'Image generation skipped: post %d has _prautoblogger_imagery_suppressed set.', $post_id ),
|
||||
'publisher'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch the original Reddit source data for Image B's source-driven prompt.
|
||||
// The idea's source_ids point to rows in the source_data table collected
|
||||
// during the pipeline's collection step.
|
||||
|
||||
@@ -13,23 +13,22 @@ declare(strict_types=1);
|
||||
* Dependencies: PRAutoBlogger_Post_Assembler (taxonomy, images, sanitization),
|
||||
* WordPress wp_insert_post(), post meta API.
|
||||
*
|
||||
* @see core/class-post-assembler.php -- Post-creation helpers (taxonomy, images, logs).
|
||||
* @see core/class-peptide-linker.php -- Injects peptide database hyperlinks into content.
|
||||
* @see core/class-chief-editor.php -- Produces the editorial review we consume.
|
||||
* @see core/class-content-generator.php -- Produces the content we publish.
|
||||
* @see ARCHITECTURE.md -- Data flow step 6.
|
||||
* @see core/class-post-assembler.php — Post-creation helpers (taxonomy, images, logs).
|
||||
* @see core/class-peptide-linker.php — Injects peptide database hyperlinks into content.
|
||||
* @see core/class-chief-editor.php — Produces the editorial review we consume.
|
||||
* @see core/class-content-generator.php — Produces the content we publish.
|
||||
* @see ARCHITECTURE.md — Data flow step 6.
|
||||
*/
|
||||
class PRAutoBlogger_Publisher {
|
||||
|
||||
/**
|
||||
* Publish an editor-approved article.
|
||||
*
|
||||
* @param string $content The final HTML content.
|
||||
* @param PRAutoBlogger_Article_Idea $idea The original article idea.
|
||||
* @param PRAutoBlogger_Editorial_Review $review The editor's review.
|
||||
* @param string|null $run_id Pipeline run ID for log linking.
|
||||
* @param PRAutoBlogger_Cost_Tracker|null $cost_tracker Optional cost tracker for imagery.
|
||||
* @param string $pipeline_mode Override pipeline_mode meta ('' = option).
|
||||
* @param string $content The final HTML content.
|
||||
* @param PRAutoBlogger_Article_Idea $idea The original article idea.
|
||||
* @param PRAutoBlogger_Editorial_Review $review The editor's review.
|
||||
* @param string|null $run_id Pipeline run ID for log linking.
|
||||
* @param ?PRAutoBlogger_Cost_Tracker $cost_tracker Optional cost tracker for image generation cost logging.
|
||||
* @return int The created post ID.
|
||||
* @throws \RuntimeException If post creation fails.
|
||||
*/
|
||||
@@ -38,21 +37,19 @@ class PRAutoBlogger_Publisher {
|
||||
PRAutoBlogger_Article_Idea $idea,
|
||||
PRAutoBlogger_Editorial_Review $review,
|
||||
?string $run_id = null,
|
||||
?PRAutoBlogger_Cost_Tracker $cost_tracker = null,
|
||||
string $pipeline_mode = ''
|
||||
?PRAutoBlogger_Cost_Tracker $cost_tracker = null
|
||||
): int {
|
||||
return $this->create_post( $content, $idea, $review, 'publish', $run_id, $cost_tracker, $pipeline_mode );
|
||||
return $this->create_post( $content, $idea, $review, 'publish', $run_id, $cost_tracker );
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an editor-rejected article as a draft for human review.
|
||||
*
|
||||
* @param string $content The generated HTML content (pre-revision).
|
||||
* @param PRAutoBlogger_Article_Idea $idea The original article idea.
|
||||
* @param PRAutoBlogger_Editorial_Review $review The editor's review with rejection notes.
|
||||
* @param string|null $run_id Pipeline run ID for log linking.
|
||||
* @param PRAutoBlogger_Cost_Tracker|null $cost_tracker Optional cost tracker for imagery.
|
||||
* @param string $pipeline_mode Override pipeline_mode meta ('' = option).
|
||||
* @param string $content The generated HTML content (pre-revision).
|
||||
* @param PRAutoBlogger_Article_Idea $idea The original article idea.
|
||||
* @param PRAutoBlogger_Editorial_Review $review The editor's review with rejection notes.
|
||||
* @param string|null $run_id Pipeline run ID for log linking.
|
||||
* @param ?PRAutoBlogger_Cost_Tracker $cost_tracker Optional cost tracker for image generation cost logging.
|
||||
* @return int The created post ID.
|
||||
* @throws \RuntimeException If post creation fails.
|
||||
*/
|
||||
@@ -61,24 +58,21 @@ class PRAutoBlogger_Publisher {
|
||||
PRAutoBlogger_Article_Idea $idea,
|
||||
PRAutoBlogger_Editorial_Review $review,
|
||||
?string $run_id = null,
|
||||
?PRAutoBlogger_Cost_Tracker $cost_tracker = null,
|
||||
string $pipeline_mode = ''
|
||||
?PRAutoBlogger_Cost_Tracker $cost_tracker = null
|
||||
): int {
|
||||
return $this->create_post( $content, $idea, $review, 'draft', $run_id, $cost_tracker, $pipeline_mode );
|
||||
return $this->create_post( $content, $idea, $review, 'draft', $run_id, $cost_tracker );
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a WordPress post with generation metadata.
|
||||
*
|
||||
* @param string $content HTML content.
|
||||
* @param PRAutoBlogger_Article_Idea $idea Article idea.
|
||||
* @param PRAutoBlogger_Editorial_Review $review Editorial review.
|
||||
* @param string $post_status 'publish' or 'draft'.
|
||||
* @param string|null $run_id Pipeline run ID.
|
||||
* @param PRAutoBlogger_Cost_Tracker|null $cost_tracker Cost tracker for imagery.
|
||||
* @param string $pipeline_mode Override pipeline_mode meta ('' = option).
|
||||
* @param string $content HTML content.
|
||||
* @param PRAutoBlogger_Article_Idea $idea Article idea.
|
||||
* @param PRAutoBlogger_Editorial_Review $review Editorial review.
|
||||
* @param string $post_status 'publish' or 'draft'.
|
||||
* @param string|null $run_id Pipeline run ID for log linking.
|
||||
* @return int Post ID.
|
||||
* @throws \RuntimeException On wp_insert_post failure or empty content (v0.18.1 guard).
|
||||
* @throws \RuntimeException If wp_insert_post fails.
|
||||
*/
|
||||
private function create_post(
|
||||
string $content,
|
||||
@@ -86,11 +80,10 @@ class PRAutoBlogger_Publisher {
|
||||
PRAutoBlogger_Editorial_Review $review,
|
||||
string $post_status,
|
||||
?string $run_id = null,
|
||||
?PRAutoBlogger_Cost_Tracker $cost_tracker = null,
|
||||
string $pipeline_mode = ''
|
||||
?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
|
||||
// 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
|
||||
@@ -117,7 +110,7 @@ class PRAutoBlogger_Publisher {
|
||||
// v0.20.0: a re-run's regenerated content must land on the
|
||||
// existing UNPUBLISHED post (otherwise re-runs silently
|
||||
// discard their output). Published posts stay frozen
|
||||
// (CPO guardrail 5) -- refresh skips them untouched.
|
||||
// (CPO guardrail 5) — refresh skips them untouched.
|
||||
return $this->refresh_unpublished_post( $existing_id, $content, $idea, $review );
|
||||
}
|
||||
}
|
||||
@@ -134,10 +127,10 @@ class PRAutoBlogger_Publisher {
|
||||
'post_status' => $post_status,
|
||||
'post_type' => 'post',
|
||||
'post_author' => PRAutoBlogger_Post_Assembler::get_default_author_id(),
|
||||
'meta_input' => $this->build_meta( $idea, $review, $run_id, $idea_hash, $pipeline_mode ),
|
||||
'meta_input' => $this->build_meta( $idea, $review, $run_id, $idea_hash ),
|
||||
);
|
||||
|
||||
/** @see class-prautoblogger.php -- listeners registered in main loader. */
|
||||
/** @see class-prautoblogger.php — listeners registered in main loader. */
|
||||
$post_data = apply_filters( 'prautoblogger_filter_post_data', $post_data, $idea, $review );
|
||||
|
||||
$post_id = wp_insert_post( $post_data, true );
|
||||
@@ -165,11 +158,15 @@ class PRAutoBlogger_Publisher {
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh an existing post from re-run output -- unpublished only.
|
||||
* Refresh an existing post from re-run output — unpublished only.
|
||||
*
|
||||
* Idempotent-resume calls land here too (same content in = same
|
||||
* content out, so behavior is unchanged for plain resumes). The post
|
||||
* keeps its CURRENT status: a re-run never publishes an unpublished
|
||||
* post — publication stays an explicit Review Queue / WP editor
|
||||
* action. Published (and scheduled/private) posts are returned
|
||||
* untouched: the run is frozen (CPO guardrail 5).
|
||||
*
|
||||
* Idempotent-resume: same content in = same content out. The post keeps
|
||||
* its CURRENT status -- a re-run never publishes an unpublished post.
|
||||
* Published/scheduled/private posts are returned untouched (CPO guardrail 5).
|
||||
* Side effects: wp_update_post + post meta updates (unpublished only).
|
||||
*
|
||||
* @param int $post_id Existing post ID.
|
||||
@@ -182,7 +179,7 @@ class PRAutoBlogger_Publisher {
|
||||
$post = get_post( $post_id );
|
||||
if ( PRAutoBlogger_Rerun_Eligibility::post_frozen( $post ) ) {
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf( 'Post %d already exists and is published -- frozen, skipping content refresh.', $post_id ),
|
||||
sprintf( 'Post %d already exists and is published — frozen, skipping content refresh.', $post_id ),
|
||||
'publisher'
|
||||
);
|
||||
return $post_id;
|
||||
@@ -217,35 +214,28 @@ class PRAutoBlogger_Publisher {
|
||||
/**
|
||||
* Build post_meta array for generation metadata.
|
||||
*
|
||||
* `_prautoblogger_run_id` lets the orphan-research reaper attribute an orphan
|
||||
* `llm_research` row back to its article without re-walking gen_log.
|
||||
* `_prautoblogger_pipeline_mode`: Authority passes 'authority'; Economy passes
|
||||
* '' (falls back to the option). See core/class-research-reaper.php.
|
||||
* The `_prautoblogger_run_id` key (added v0.8.1) lets the orphan-research
|
||||
* reaper attribute an orphan `llm_research` row back to its sibling
|
||||
* articles without re-walking the gen_log table. See
|
||||
* core/class-research-reaper.php.
|
||||
*
|
||||
* @param PRAutoBlogger_Article_Idea $idea Article idea.
|
||||
* @param PRAutoBlogger_Editorial_Review $review Editorial review.
|
||||
* @param string|null $run_id Pipeline run UUID, or null in legacy paths.
|
||||
* @param string $idea_hash Idea hash ('' when not yet computed).
|
||||
* @param string $pipeline_mode Explicit pipeline mode ('' = read option).
|
||||
* @param PRAutoBlogger_Article_Idea $idea
|
||||
* @param PRAutoBlogger_Editorial_Review $review
|
||||
* @param string|null $run_id Pipeline run UUID, or null in legacy paths.
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function build_meta(
|
||||
PRAutoBlogger_Article_Idea $idea,
|
||||
PRAutoBlogger_Editorial_Review $review,
|
||||
?string $run_id = null,
|
||||
string $idea_hash = '',
|
||||
string $pipeline_mode = ''
|
||||
string $idea_hash = ''
|
||||
): array {
|
||||
$mode = '' !== $pipeline_mode
|
||||
? $pipeline_mode
|
||||
: get_option( 'prautoblogger_writing_pipeline', 'multi_step' );
|
||||
|
||||
$meta = array(
|
||||
'_prautoblogger_generated' => '1',
|
||||
'_prautoblogger_analysis_id' => $idea->get_analysis_id(),
|
||||
'_prautoblogger_source_ids' => wp_json_encode( $idea->get_source_ids() ),
|
||||
'_prautoblogger_model_used' => get_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL ),
|
||||
'_prautoblogger_pipeline_mode' => $mode,
|
||||
'_prautoblogger_pipeline_mode' => get_option( 'prautoblogger_writing_pipeline', 'multi_step' ),
|
||||
'_prautoblogger_editor_verdict' => $review->get_verdict(),
|
||||
'_prautoblogger_editor_notes' => $review->get_notes(),
|
||||
'_prautoblogger_quality_score' => $review->get_quality_score(),
|
||||
@@ -267,7 +257,7 @@ class PRAutoBlogger_Publisher {
|
||||
/**
|
||||
* Find a post already created for this (run, idea) pair.
|
||||
*
|
||||
* Direct postmeta self-join (any post_status, including trash) -- the
|
||||
* 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.
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Low-level curl_multi executor for the Authority research fan-out.
|
||||
*
|
||||
* What: Wraps PHP's curl_multi interface to dispatch N parallel LLM
|
||||
* chat-completion requests to OpenRouter and parse each handle's
|
||||
* response into a normalised result array. Mirrors the pattern of
|
||||
* PRAutoBlogger_OpenRouter_Image_Batch (image parallel dispatch).
|
||||
* All failures emit Logger::warning and fold the slot into an
|
||||
* `{error: string}` entry — the caller (Research_Fanout) applies
|
||||
* quorum logic. No retry: the 300s timeout + single-attempt
|
||||
* success rate on OpenRouter is sufficient at this fan-out size.
|
||||
* Who triggers it: PRAutoBlogger_Research_Fanout::dispatch() only.
|
||||
* Not wired into the Economy path.
|
||||
* Dependencies: PRAutoBlogger_OpenRouter_Config, Request_Builder,
|
||||
* PRAutoBlogger_OpenRouter_Pricing, Logger.
|
||||
*
|
||||
* @see core/class-research-fanout.php — Sole caller.
|
||||
* @see providers/class-open-router-image-batch.php — curl_multi pattern.
|
||||
* @see ARCHITECTURE.md — Phase 2b data flow.
|
||||
*/
|
||||
class PRAutoBlogger_Research_Batch {
|
||||
|
||||
/**
|
||||
* Execute multiple chat-completion requests concurrently via curl_multi.
|
||||
*
|
||||
* @param string $model OpenRouter model slug.
|
||||
* @param array<int, array<int, array{role: string, content: string}>> $messages_per_agent One message-set per agent slot.
|
||||
* @param array<string, mixed> $options Shared call options (temperature, max_tokens, response_format).
|
||||
* @param string[] $roles Role label per slot — for log attribution only.
|
||||
* @return array<int, array{content?: string, model?: string, prompt_tokens?: int, completion_tokens?: int, actual_cost?: float, error?: string}>
|
||||
* Indexed 0..N-1; error key present on failure.
|
||||
*
|
||||
* Side effects: outbound HTTP via curl_multi, Logger::warning on failures.
|
||||
*/
|
||||
public function execute(
|
||||
string $model,
|
||||
array $messages_per_agent,
|
||||
array $options,
|
||||
array $roles
|
||||
): array {
|
||||
if ( empty( $messages_per_agent ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$config = new PRAutoBlogger_OpenRouter_Config();
|
||||
$builder = new PRAutoBlogger_OpenRouter_Request_Builder();
|
||||
$api_key = $builder->resolve_api_key();
|
||||
$url = $config->get_api_base_url() . '/chat/completions';
|
||||
$headers = $builder->build_headers( $api_key, $config->is_via_gateway(), 0 );
|
||||
|
||||
$curl_headers = array();
|
||||
foreach ( $headers as $name => $value ) {
|
||||
$curl_headers[] = $name . ': ' . $value;
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_init
|
||||
$mh = curl_multi_init();
|
||||
$handles = array();
|
||||
|
||||
foreach ( $messages_per_agent as $idx => $msgs ) {
|
||||
$body = array(
|
||||
'model' => $model,
|
||||
'messages' => $msgs,
|
||||
'temperature' => $options['temperature'] ?? 0.3,
|
||||
'max_tokens' => $options['max_tokens'] ?? 3000,
|
||||
'response_format' => $options['response_format'] ?? array( 'type' => 'json_object' ),
|
||||
);
|
||||
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_init
|
||||
$ch = curl_init();
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_setopt
|
||||
curl_setopt( $ch, CURLOPT_URL, $url );
|
||||
curl_setopt( $ch, CURLOPT_POST, true );
|
||||
curl_setopt( $ch, CURLOPT_POSTFIELDS, wp_json_encode( $body ) );
|
||||
curl_setopt( $ch, CURLOPT_HTTPHEADER, $curl_headers );
|
||||
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
|
||||
curl_setopt( $ch, CURLOPT_TIMEOUT, PRAUTOBLOGGER_API_TIMEOUT_SECONDS );
|
||||
curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, 10 );
|
||||
// phpcs:enable WordPress.WP.AlternativeFunctions.curl_curl_setopt
|
||||
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_add_handle
|
||||
curl_multi_add_handle( $mh, $ch );
|
||||
$handles[ $idx ] = array(
|
||||
'handle' => $ch,
|
||||
'role' => $roles[ $idx ] ?? (string) $idx,
|
||||
);
|
||||
}
|
||||
|
||||
$this->run_multi( $mh );
|
||||
|
||||
$results = array();
|
||||
foreach ( $handles as $idx => $entry ) {
|
||||
$results[ $idx ] = $this->parse_handle(
|
||||
$entry['handle'],
|
||||
$model,
|
||||
$entry['role']
|
||||
);
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_remove_handle
|
||||
curl_multi_remove_handle( $mh, $entry['handle'] );
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_close
|
||||
curl_close( $entry['handle'] );
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_close
|
||||
curl_multi_close( $mh );
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
// ── Private helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Drive curl_multi_exec to completion.
|
||||
*
|
||||
* @param \CurlMultiHandle $mh Multi handle.
|
||||
* @return void
|
||||
*/
|
||||
private function run_multi( $mh ): void {
|
||||
$running = 0;
|
||||
do {
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_exec
|
||||
$status = curl_multi_exec( $mh, $running );
|
||||
if ( $running > 0 ) {
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_select
|
||||
curl_multi_select( $mh, 1.0 );
|
||||
}
|
||||
} while ( $running > 0 && CURLM_OK === $status );
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one completed curl handle into a normalised result.
|
||||
*
|
||||
* @param \CurlHandle $ch Completed handle.
|
||||
* @param string $model Model slug (for cost estimation fallback).
|
||||
* @param string $role Agent role label (for log attribution).
|
||||
* @return array{content?: string, model?: string, prompt_tokens?: int, completion_tokens?: int, actual_cost?: float, error?: string}
|
||||
*
|
||||
* Side effects: Logger::warning on failure.
|
||||
*/
|
||||
private function parse_handle( $ch, string $model, string $role ): array {
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_errno
|
||||
if ( 0 !== curl_errno( $ch ) ) {
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_error
|
||||
$err = curl_error( $ch );
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf( 'Research batch agent "%s" cURL error: %s', $role, $err ),
|
||||
'research-batch'
|
||||
);
|
||||
return array( 'error' => $err );
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_getinfo
|
||||
$http = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE );
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_getcontent
|
||||
$raw = (string) curl_multi_getcontent( $ch );
|
||||
|
||||
if ( $http >= 400 ) {
|
||||
$msg = sprintf(
|
||||
'Research batch agent "%s" HTTP %d: %s',
|
||||
$role,
|
||||
$http,
|
||||
substr( $raw, 0, 200 )
|
||||
);
|
||||
PRAutoBlogger_Logger::instance()->warning( $msg, 'research-batch' );
|
||||
return array( 'error' => $msg );
|
||||
}
|
||||
|
||||
$decoded = json_decode( $raw, true );
|
||||
if ( ! is_array( $decoded ) || ! isset( $decoded['choices'][0]['message']['content'] ) ) {
|
||||
$msg = sprintf( 'Research batch agent "%s" unexpected response shape.', $role );
|
||||
PRAutoBlogger_Logger::instance()->warning( $msg, 'research-batch' );
|
||||
return array( 'error' => $msg );
|
||||
}
|
||||
|
||||
$content = (string) $decoded['choices'][0]['message']['content'];
|
||||
$usage = $decoded['usage'] ?? array();
|
||||
$prompt_tokens = (int) ( $usage['prompt_tokens'] ?? 0 );
|
||||
$completion_tokens = (int) ( $usage['completion_tokens'] ?? 0 );
|
||||
$actual_model = (string) ( $decoded['model'] ?? $model );
|
||||
|
||||
$actual_cost = ( new PRAutoBlogger_OpenRouter_Pricing() )->estimate_cost(
|
||||
$actual_model,
|
||||
$prompt_tokens,
|
||||
$completion_tokens
|
||||
);
|
||||
|
||||
return array(
|
||||
'content' => $content,
|
||||
'model' => $actual_model,
|
||||
'prompt_tokens' => $prompt_tokens,
|
||||
'completion_tokens' => $completion_tokens,
|
||||
'actual_cost' => $actual_cost,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Parallel specialist research fan-out for Authority-tier articles.
|
||||
*
|
||||
* What: Dispatches N specialist LLM research agents in parallel (via
|
||||
* PRAutoBlogger_Research_Batch / curl_multi), each researching a
|
||||
* different angle of the article topic. Before dispatch, reserves
|
||||
* the SUMMED worst-case cost of the entire batch from the per-run
|
||||
* cost governor — a single atomic write so concurrent writers
|
||||
* cannot slip past the per-run ceiling between check and call.
|
||||
* Quorum check (⌈N/2⌉+1 usable results required) guards against
|
||||
* thin research proceeding on partial failures. Invalid or
|
||||
* schema-mismatched agent results are excluded and never silently
|
||||
* passed. Failing agents mark their run_stages rows as failed.
|
||||
* Who triggers it: PRAutoBlogger_Authority_Pipeline (Phase 2b.4, the
|
||||
* tier router). NOT wired into the Economy (generate_single_pass)
|
||||
* path — additive only, not live until P2b.4.
|
||||
* Dependencies: PRAutoBlogger_Cost_Governor (reserve-before-dispatch),
|
||||
* PRAutoBlogger_Research_Batch (curl_multi execution),
|
||||
* PRAutoBlogger_Run_Stage_State (per-agent stage rows),
|
||||
* PRAutoBlogger_Prompt_Registry (research.system prompt),
|
||||
* PRAutoBlogger_Json_Extractor (JSON schema validation),
|
||||
* PRAutoBlogger_Cost_Tracker (log_api_call), Logger.
|
||||
*
|
||||
* @see providers/interface-research-fanout.php — Interface this class implements.
|
||||
* @see core/class-research-batch.php — curl_multi execution delegate.
|
||||
* @see core/class-research-judge.php — Consumes these results (curate).
|
||||
* @see ARCHITECTURE.md — Phase 2b data flow.
|
||||
*/
|
||||
class PRAutoBlogger_Research_Fanout implements PRAutoBlogger_Research_Fanout_Interface {
|
||||
|
||||
/** Maximum configurable agent count. */
|
||||
private const MAX_AGENTS = 5;
|
||||
|
||||
/** Minimum configurable agent count. Floor=2: quorum=⌈N/2⌉+1=2 for N=1 is unsatisfiable. See CONTEXT.md §Phase 2b. */
|
||||
private const MIN_AGENTS = 2;
|
||||
|
||||
/** Specialist roles in priority order; first N are used per run. */
|
||||
private const SPECIALIST_ROLES = array(
|
||||
'mechanisms',
|
||||
'clinical',
|
||||
'safety',
|
||||
'comparison',
|
||||
'practical',
|
||||
);
|
||||
|
||||
/** @var PRAutoBlogger_Research_Batch curl_multi execution delegate. */
|
||||
private PRAutoBlogger_Research_Batch $batch;
|
||||
|
||||
/**
|
||||
* @param PRAutoBlogger_Research_Batch|null $batch Optional batch override (tests).
|
||||
*/
|
||||
public function __construct( ?PRAutoBlogger_Research_Batch $batch = null ) {
|
||||
$this->batch = $batch ?? new PRAutoBlogger_Research_Batch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch N specialist research agents in parallel and collect results.
|
||||
*
|
||||
* Returns an indexed array of per-agent result maps. If fewer than
|
||||
* quorum (⌈N/2⌉+1) agents return usable results, returns an empty
|
||||
* array — the caller should HOLD the run rather than proceeding on
|
||||
* thin research.
|
||||
*
|
||||
* Side effects: curl_multi HTTP calls to OpenRouter, run_stages DB
|
||||
* writes, cost-governor reservation + settlement, Logger calls.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $item_key Article-scoped item key.
|
||||
* @param PRAutoBlogger_Article_Idea $idea The idea being researched.
|
||||
* @param PRAutoBlogger_Cost_Tracker $cost_tracker Pipeline cost tracker.
|
||||
* @return array<int, array{sources: array<int, array{url: string, title: string, excerpt: string, relevance: float}>, agent_role: string}> Per-agent results, or empty when quorum not met.
|
||||
*/
|
||||
public function dispatch(
|
||||
string $run_id,
|
||||
string $item_key,
|
||||
PRAutoBlogger_Article_Idea $idea,
|
||||
PRAutoBlogger_Cost_Tracker $cost_tracker
|
||||
): array {
|
||||
$n = $this->resolve_agent_count();
|
||||
$model = $this->resolve_model();
|
||||
$quorum = (int) ceil( $n / 2 ) + 1;
|
||||
$roles = array_slice( self::SPECIALIST_ROLES, 0, $n );
|
||||
$topic = $idea->get_topic();
|
||||
$title = $idea->get_suggested_title();
|
||||
|
||||
$messages_per_agent = $this->build_agent_messages( $topic, $title, $roles );
|
||||
$options = array(
|
||||
'temperature' => 0.3,
|
||||
'max_tokens' => 3000,
|
||||
'response_format' => array( 'type' => 'json_object' ),
|
||||
);
|
||||
|
||||
// Reserve the SUMMED worst-case cost of all N agents before dispatch.
|
||||
$per_estimate = PRAutoBlogger_Cost_Governor::estimate_chat_cost( $model, $messages_per_agent[0], $options );
|
||||
$batch_estimate = $per_estimate * $n;
|
||||
$reservation = PRAutoBlogger_Cost_Governor::open_amount_reservation(
|
||||
$batch_estimate,
|
||||
sprintf( 'research_fanout:n=%d:%s', $n, $model )
|
||||
);
|
||||
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf(
|
||||
'Research fan-out: dispatching %d agents (quorum=%d) for "%s". Batch reserve: $%.4f.',
|
||||
$n,
|
||||
$quorum,
|
||||
mb_substr( $topic, 0, 60 ),
|
||||
$batch_estimate
|
||||
),
|
||||
'research-fanout'
|
||||
);
|
||||
|
||||
foreach ( $roles as $role ) {
|
||||
PRAutoBlogger_Run_Stage_State::start( $run_id, 'research', 'researcher:' . $role, $item_key );
|
||||
}
|
||||
|
||||
$valid_results = array();
|
||||
$actual_total = 0.0;
|
||||
|
||||
// try/finally: settle() ALWAYS runs — even if batch->execute() throws.
|
||||
// Without this, an uncaught exception leaves reserved_usd open in the ledger
|
||||
// until the run reaper, silently eroding the ceiling. Settles 0.0 on throw.
|
||||
try {
|
||||
$raw_results = $this->batch->execute( $model, $messages_per_agent, $options, $roles );
|
||||
|
||||
foreach ( $raw_results as $idx => $raw ) {
|
||||
$role = $roles[ $idx ];
|
||||
if ( isset( $raw['error'] ) ) {
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf( 'Research agent "%s" failed: %s', $role, $raw['error'] ),
|
||||
'research-fanout'
|
||||
);
|
||||
PRAutoBlogger_Run_Stage_State::fail( $run_id, 'research', 'researcher:' . $role, $item_key );
|
||||
continue;
|
||||
}
|
||||
|
||||
$parsed = PRAutoBlogger_Json_Extractor::decode( $raw['content'] ?? '' );
|
||||
$sources = $this->validate_sources( $parsed );
|
||||
|
||||
if ( null === $sources ) {
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf( 'Research agent "%s" returned invalid schema; excluding.', $role ),
|
||||
'research-fanout'
|
||||
);
|
||||
PRAutoBlogger_Run_Stage_State::fail( $run_id, 'research', 'researcher:' . $role, $item_key );
|
||||
continue;
|
||||
}
|
||||
|
||||
$actual_cost = (float) ( $raw['actual_cost'] ?? 0.0 );
|
||||
$actual_total += $actual_cost;
|
||||
|
||||
$cost_tracker->log_api_call(
|
||||
null,
|
||||
'research',
|
||||
'openrouter',
|
||||
$raw['model'] ?? $model,
|
||||
(int) ( $raw['prompt_tokens'] ?? 0 ),
|
||||
(int) ( $raw['completion_tokens'] ?? 0 )
|
||||
);
|
||||
|
||||
PRAutoBlogger_Run_Stage_State::done(
|
||||
$run_id,
|
||||
'research',
|
||||
'researcher:' . $role,
|
||||
$item_key,
|
||||
wp_json_encode( array( 'source_count' => count( $sources ) ) ),
|
||||
$actual_cost
|
||||
);
|
||||
|
||||
$valid_results[] = array(
|
||||
'sources' => $sources,
|
||||
'agent_role' => 'researcher:' . $role,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
PRAutoBlogger_Cost_Governor::settle( $reservation, $actual_total ); // Always settles (0 on throw).
|
||||
}
|
||||
|
||||
$valid_count = count( $valid_results );
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf(
|
||||
'Research fan-out complete: %d/%d agents usable (quorum=%d).',
|
||||
$valid_count,
|
||||
$n,
|
||||
$quorum
|
||||
),
|
||||
'research-fanout'
|
||||
);
|
||||
|
||||
if ( $valid_count < $quorum ) {
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf( 'Research quorum not met (%d/%d). Run should hold.', $valid_count, $quorum ),
|
||||
'research-fanout'
|
||||
);
|
||||
return array();
|
||||
}
|
||||
|
||||
return $valid_results;
|
||||
}
|
||||
|
||||
// ── Private helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read the configured agent count, clamped to [MIN_AGENTS, MAX_AGENTS].
|
||||
* MIN_AGENTS=2 since quorum=⌈N/2⌉+1=2 for N=1 is unsatisfiable; see CONTEXT.md §Phase 2b.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
private function resolve_agent_count(): int {
|
||||
$n = (int) get_option( 'prautoblogger_research_agent_count', 3 );
|
||||
return max( self::MIN_AGENTS, min( self::MAX_AGENTS, $n ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the configured research model.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function resolve_model(): string {
|
||||
return (string) get_option( 'prautoblogger_research_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL );
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the per-agent message sets (system + specialist user prompt).
|
||||
*
|
||||
* @param string $topic Topic text.
|
||||
* @param string $title Suggested article title.
|
||||
* @param string[] $roles Specialist role names.
|
||||
* @return array<int, array<int, array{role: string, content: string}>>
|
||||
*/
|
||||
private function build_agent_messages( string $topic, string $title, array $roles ): array {
|
||||
$system = PRAutoBlogger_Prompt_Registry::render( 'research.system' );
|
||||
$sets = array();
|
||||
foreach ( $roles as $role ) {
|
||||
$sets[] = array(
|
||||
array(
|
||||
'role' => 'system',
|
||||
'content' => $system,
|
||||
),
|
||||
array(
|
||||
'role' => 'user',
|
||||
'content' => sprintf(
|
||||
"Topic: %s\nProposed title: %s\n\nSpecialist focus: %s\n\n" .
|
||||
"Find 3-6 high-quality sources for this topic from a '%s' perspective. " .
|
||||
'Return JSON: {"sources":[{"url":"","title":"","excerpt":"","relevance":0.0}]}',
|
||||
$topic,
|
||||
$title,
|
||||
$role,
|
||||
$role
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return $sets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalise the parsed sources from one agent's response.
|
||||
*
|
||||
* Schema: `{sources: [{url, title, excerpt, relevance}]}`. Any entry
|
||||
* missing both url and title is silently skipped. Returns null when
|
||||
* the top-level schema is missing or sources is empty after filtering.
|
||||
*
|
||||
* @param array<string, mixed>|null $parsed Decoded JSON from one agent.
|
||||
* @return array<int, array{url: string, title: string, excerpt: string, relevance: float}>|null
|
||||
*
|
||||
* Side effects: none.
|
||||
*/
|
||||
private function validate_sources( ?array $parsed ): ?array {
|
||||
if ( ! is_array( $parsed ) || ! isset( $parsed['sources'] ) || ! is_array( $parsed['sources'] ) ) {
|
||||
return null;
|
||||
}
|
||||
$out = array();
|
||||
foreach ( $parsed['sources'] as $s ) {
|
||||
if ( ! is_array( $s ) ) {
|
||||
continue;
|
||||
}
|
||||
$url = sanitize_url( (string) ( $s['url'] ?? '' ) );
|
||||
$title = sanitize_text_field( (string) ( $s['title'] ?? '' ) );
|
||||
$excerpt = sanitize_textarea_field( (string) ( $s['excerpt'] ?? '' ) );
|
||||
$relevance = max( 0.0, min( 1.0, (float) ( $s['relevance'] ?? 0.0 ) ) );
|
||||
if ( '' === $url && '' === $title ) {
|
||||
continue;
|
||||
}
|
||||
$out[] = array(
|
||||
'url' => $url,
|
||||
'title' => $title,
|
||||
'excerpt' => $excerpt,
|
||||
'relevance' => $relevance,
|
||||
);
|
||||
}
|
||||
return count( $out ) > 0 ? $out : null;
|
||||
}
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Research Judge — the `curate` stage of the Authority pipeline.
|
||||
*
|
||||
* What: Deduplicates sources across all fan-out agent results using
|
||||
* URL-exact matching first, then semantic similarity (via the
|
||||
* OpenRouter embedding API + cosine similarity, matching the
|
||||
* Semantic_Dedup pattern) with keyword-overlap fallback. Assigns
|
||||
* a quality_score to each source (relevance × source-type weight).
|
||||
* Writes one run_sources row per source (kept=1 or kept=0) with
|
||||
* reason + quality_score via PRAutoBlogger_Research_Source_Writer.
|
||||
* Returns the kept, scored list for the draft stage.
|
||||
* Who triggers it: PRAutoBlogger_Authority_Pipeline (Phase 2b.4, the
|
||||
* tier router). NOT wired into the Economy path — additive only.
|
||||
* Dependencies: PRAutoBlogger_Research_Source_Writer (run_sources writes),
|
||||
* PRAutoBlogger_Research_Source_Scorer (quality scoring),
|
||||
* PRAutoBlogger_Audit_Writer (run_decisions write),
|
||||
* PRAutoBlogger_OpenRouter_Embedding_Provider (semantic dedup),
|
||||
* PRAutoBlogger_Run_Stage_State (curate stage row),
|
||||
* PRAutoBlogger_Stage_Display_Map (default agent role),
|
||||
* WordPress $wpdb, Logger.
|
||||
*
|
||||
* @see providers/interface-research-judge.php — Interface.
|
||||
* @see core/class-research-fanout.php — Produces the input.
|
||||
* @see core/class-research-source-writer.php — Writes run_sources rows.
|
||||
* @see core/class-research-source-scorer.php — Source-type weighting delegate.
|
||||
* @see core/class-semantic-dedup.php — Embedding pattern reference.
|
||||
* @see ARCHITECTURE.md — Phase 2b curate stage.
|
||||
*/
|
||||
class PRAutoBlogger_Research_Judge implements PRAutoBlogger_Research_Judge_Interface {
|
||||
|
||||
/** Cosine similarity threshold above which two sources are duplicates. */
|
||||
private const SEMANTIC_DEDUP_THRESHOLD = 0.90;
|
||||
|
||||
/** Maximum kept sources passed to the draft stage. */
|
||||
private const MAX_KEPT_SOURCES = 12;
|
||||
|
||||
/** @var PRAutoBlogger_Research_Source_Scorer Scores source authority. */
|
||||
private PRAutoBlogger_Research_Source_Scorer $scorer;
|
||||
|
||||
/** @var PRAutoBlogger_Research_Source_Writer Persists run_sources rows. */
|
||||
private PRAutoBlogger_Research_Source_Writer $writer;
|
||||
|
||||
/**
|
||||
* @param PRAutoBlogger_Research_Source_Scorer|null $scorer Optional scorer override.
|
||||
* @param PRAutoBlogger_Research_Source_Writer|null $writer Optional writer override.
|
||||
*/
|
||||
public function __construct(
|
||||
?PRAutoBlogger_Research_Source_Scorer $scorer = null,
|
||||
?PRAutoBlogger_Research_Source_Writer $writer = null
|
||||
) {
|
||||
$this->scorer = $scorer ?? new PRAutoBlogger_Research_Source_Scorer();
|
||||
$this->writer = $writer ?? new PRAutoBlogger_Research_Source_Writer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate and score fan-out results; write run_sources rows; return kept.
|
||||
*
|
||||
* Dedup order: (1) URL canonical match; (2) semantic similarity via embedding
|
||||
* API (fallback to keyword-overlap when the API is unavailable). Quality
|
||||
* score = relevance × source-type weight. Sources are sorted by quality_score
|
||||
* descending and capped at MAX_KEPT_SOURCES; overflow rows are written to
|
||||
* run_sources with kept=0 ("exceeded max").
|
||||
*
|
||||
* Side effects: run_sources DB writes, run_stages DB write (curate stage),
|
||||
* possible embedding API call, Logger calls.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $item_key Article-scoped item key.
|
||||
* @param array<int, array{sources: array<int, array{url: string, title: string, excerpt: string, relevance: float}>, agent_role: string}> $fanout_results Agent results from Research_Fanout::dispatch().
|
||||
* @return array<int, array{url: string, title: string, excerpt: string, relevance: float, quality_score: float}> Kept, scored sources.
|
||||
*/
|
||||
public function curate(
|
||||
string $run_id,
|
||||
string $item_key,
|
||||
array $fanout_results
|
||||
): array {
|
||||
PRAutoBlogger_Run_Stage_State::start(
|
||||
$run_id,
|
||||
'curate',
|
||||
(string) PRAutoBlogger_Stage_Display_Map::default_agent_role( 'curate' ),
|
||||
$item_key
|
||||
);
|
||||
|
||||
$all_sources = $this->flatten( $fanout_results );
|
||||
$deduped = $this->deduplicate( $all_sources );
|
||||
$scored = array_map( array( $this->scorer, 'score' ), $deduped );
|
||||
|
||||
usort(
|
||||
$scored,
|
||||
static fn( array $a, array $b ): int => $b['quality_score'] <=> $a['quality_score']
|
||||
);
|
||||
|
||||
$kept = array_slice( $scored, 0, self::MAX_KEPT_SOURCES );
|
||||
$discarded = array_slice( $scored, self::MAX_KEPT_SOURCES );
|
||||
|
||||
$this->writer->write( $run_id, $kept, $discarded );
|
||||
|
||||
PRAutoBlogger_Audit_Writer::record_decision(
|
||||
$run_id,
|
||||
'curate',
|
||||
'completed',
|
||||
sprintf(
|
||||
'Curated %d sources from %d candidates across %d agents (discarded %d).',
|
||||
count( $kept ),
|
||||
count( $all_sources ),
|
||||
count( $fanout_results ),
|
||||
count( $discarded )
|
||||
)
|
||||
);
|
||||
|
||||
PRAutoBlogger_Run_Stage_State::done(
|
||||
$run_id,
|
||||
'curate',
|
||||
(string) PRAutoBlogger_Stage_Display_Map::default_agent_role( 'curate' ),
|
||||
$item_key,
|
||||
wp_json_encode(
|
||||
array(
|
||||
'kept' => count( $kept ),
|
||||
'total' => count( $all_sources ),
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf(
|
||||
'Research Judge: kept %d/%d sources (max=%d).',
|
||||
count( $kept ),
|
||||
count( $all_sources ),
|
||||
self::MAX_KEPT_SOURCES
|
||||
),
|
||||
'research-judge'
|
||||
);
|
||||
|
||||
return $kept;
|
||||
}
|
||||
|
||||
// ── Private helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Flatten per-agent source lists into one array with agent_role attached.
|
||||
*
|
||||
* @param array<int, array{sources: array<int, array{url: string, title: string, excerpt: string, relevance: float}>, agent_role: string}> $fanout_results
|
||||
* @return array<int, array{url: string, title: string, excerpt: string, relevance: float, agent_role: string}>
|
||||
*/
|
||||
private function flatten( array $fanout_results ): array {
|
||||
$flat = array();
|
||||
foreach ( $fanout_results as $agent ) {
|
||||
foreach ( $agent['sources'] as $source ) {
|
||||
$flat[] = array_merge( $source, array( 'agent_role' => $agent['agent_role'] ) );
|
||||
}
|
||||
}
|
||||
return $flat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate: URL-exact first, then semantic similarity.
|
||||
*
|
||||
* @param array<int, array{url: string, title: string, excerpt: string, relevance: float, agent_role: string}> $sources
|
||||
* @return array<int, array{url: string, title: string, excerpt: string, relevance: float, agent_role: string}>
|
||||
*/
|
||||
private function deduplicate( array $sources ): array {
|
||||
$seen_urls = array();
|
||||
$unique = array();
|
||||
$unique_embeddings = array();
|
||||
$unique_kw = array();
|
||||
$embeddings_ok = true;
|
||||
$embed_provider = null;
|
||||
|
||||
foreach ( $sources as $source ) {
|
||||
$canonical = $this->canonical_url( $source['url'] );
|
||||
if ( '' !== $canonical ) {
|
||||
if ( isset( $seen_urls[ $canonical ] ) ) {
|
||||
continue;
|
||||
}
|
||||
$seen_urls[ $canonical ] = true;
|
||||
}
|
||||
|
||||
$text = trim( $source['title'] . ' ' . $source['excerpt'] );
|
||||
|
||||
if ( $embeddings_ok && '' !== $text ) {
|
||||
try {
|
||||
if ( null === $embed_provider ) {
|
||||
$embed_provider = new PRAutoBlogger_OpenRouter_Embedding_Provider();
|
||||
}
|
||||
$emb = $embed_provider->get_embeddings( array( $text ) );
|
||||
$vec = $emb[0] ?? null;
|
||||
if ( null !== $vec ) {
|
||||
foreach ( $unique_embeddings as $ev ) {
|
||||
if ( PRAutoBlogger_OpenRouter_Embedding_Provider::cosine_similarity( $vec, $ev ) >= self::SEMANTIC_DEDUP_THRESHOLD ) {
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
$unique_embeddings[] = $vec;
|
||||
}
|
||||
} catch ( \Throwable $e ) {
|
||||
$embeddings_ok = false;
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
'Research Judge: embedding unavailable, using keyword fallback. ' . $e->getMessage(),
|
||||
'research-judge'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! $embeddings_ok && '' !== $text ) {
|
||||
$kw = $this->extract_keywords( $text );
|
||||
// Mirror Semantic_Dedup: require >=3 keywords before applying overlap
|
||||
// check. Sparse keyword sets (e.g. generic titles like "Source 0")
|
||||
// are kept unconditionally; URL-exact dedup already filtered real
|
||||
// duplicates with identical URLs.
|
||||
if ( count( $kw ) >= 3 ) {
|
||||
foreach ( $unique_kw as $existing ) {
|
||||
$overlap = count( array_intersect( $kw, $existing ) );
|
||||
if ( ( $overlap / count( $kw ) ) >= 0.6 ) {
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
$unique_kw[] = $kw;
|
||||
}
|
||||
|
||||
$unique[] = $source;
|
||||
}
|
||||
|
||||
return $unique;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical URL: lowercase scheme+host+path, no query/fragment.
|
||||
*
|
||||
* @param string $url Raw URL.
|
||||
* @return string Normalised, or empty on unparseable.
|
||||
*/
|
||||
private function canonical_url( string $url ): string {
|
||||
if ( '' === $url ) {
|
||||
return '';
|
||||
}
|
||||
$parts = wp_parse_url( $url );
|
||||
if ( ! is_array( $parts ) ) {
|
||||
return strtolower( $url );
|
||||
}
|
||||
return strtolower( ( $parts['scheme'] ?? 'https' ) . '://' . ( $parts['host'] ?? '' ) . rtrim( $parts['path'] ?? '', '/' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyword extractor used for the embedding-unavailable fallback.
|
||||
*
|
||||
* @param string $text Input text.
|
||||
* @return string[] Lowercase unique keywords (3+ chars).
|
||||
*/
|
||||
private function extract_keywords( string $text ): array {
|
||||
static $sw = array( 'a', 'an', 'the', 'and', 'or', 'in', 'of', 'to', 'is', 'was', 'for', 'on', 'at' );
|
||||
$words = preg_split( '/[^a-z0-9-]+/', strtolower( $text ), -1, PREG_SPLIT_NO_EMPTY ) ?? array();
|
||||
return array_values(
|
||||
array_unique(
|
||||
array_filter( $words, static fn( string $w ): bool => strlen( $w ) >= 3 && ! in_array( $w, $sw, true ) )
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Source authority scorer for the Authority pipeline curate stage.
|
||||
*
|
||||
* What: Computes a quality_score for each research source by multiplying
|
||||
* its agent-assigned relevance by a source-type authority weight
|
||||
* derived from the URL scheme and hostname. Weight tiers:
|
||||
* peer-reviewed (DOI/PubMed/NCBI → 1.0), institutional (.gov /
|
||||
* .edu / WHO / FDA → 0.85), preprint (medRxiv/bioRxiv → 0.70),
|
||||
* general HTTPS (→ 0.60), HTTP or missing (→ 0.40).
|
||||
* Who triggers it: PRAutoBlogger_Research_Judge (curate stage) only.
|
||||
* Not wired into the Economy path.
|
||||
* Dependencies: none — pure computation.
|
||||
*
|
||||
* @see core/class-research-judge.php — Sole consumer.
|
||||
* @see ARCHITECTURE.md — Phase 2b curate stage.
|
||||
*/
|
||||
class PRAutoBlogger_Research_Source_Scorer {
|
||||
|
||||
/**
|
||||
* Add a quality_score field to a source array in place.
|
||||
*
|
||||
* @param array{url: string, title: string, excerpt: string, relevance: float, agent_role: string} $source Source from a research agent.
|
||||
* @return array{url: string, title: string, excerpt: string, relevance: float, agent_role: string, quality_score: float} Source with quality_score added.
|
||||
*
|
||||
* Side effects: none.
|
||||
*/
|
||||
public function score( array $source ): array {
|
||||
$source['quality_score'] = round(
|
||||
$source['relevance'] * $this->weight( $source['url'] ?? '' ),
|
||||
4
|
||||
);
|
||||
return $source;
|
||||
}
|
||||
|
||||
// ── Private helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Authority weight for a source URL.
|
||||
*
|
||||
* @param string $url Source URL.
|
||||
* @return float Weight in [0.40, 1.0].
|
||||
*/
|
||||
private function weight( string $url ): float {
|
||||
$u = strtolower( $url );
|
||||
|
||||
if (
|
||||
false !== strpos( $u, 'doi.org' ) ||
|
||||
false !== strpos( $u, 'pubmed.ncbi' ) ||
|
||||
false !== strpos( $u, 'ncbi.nlm.nih' )
|
||||
) {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
if (
|
||||
false !== strpos( $u, '.gov' ) ||
|
||||
false !== strpos( $u, '.edu' ) ||
|
||||
false !== strpos( $u, 'who.int' ) ||
|
||||
false !== strpos( $u, 'fda.gov' )
|
||||
) {
|
||||
return 0.85;
|
||||
}
|
||||
|
||||
if (
|
||||
false !== strpos( $u, 'medrxiv' ) ||
|
||||
false !== strpos( $u, 'biorxiv' ) ||
|
||||
false !== strpos( $u, 'preprint' )
|
||||
) {
|
||||
return 0.70;
|
||||
}
|
||||
|
||||
if ( 0 === strpos( $u, 'https://' ) ) {
|
||||
return 0.60;
|
||||
}
|
||||
|
||||
return 0.40;
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Run-sources DB writer for the Authority pipeline curate stage.
|
||||
*
|
||||
* What: Persists kept and discarded source records to the
|
||||
* wp_prautoblogger_run_sources table via PRAutoBlogger_Audit_Writer.
|
||||
* Builds the reason string for kept rows (quality/relevance) and the
|
||||
* fixed "exceeded max" reason for discarded rows. Also extracts DOI
|
||||
* identifiers from source URLs for storage.
|
||||
* Who triggers it: PRAutoBlogger_Research_Judge (curate stage) only.
|
||||
* Not wired into the Economy path.
|
||||
* Dependencies: PRAutoBlogger_Audit_Writer (availability check + DB insert),
|
||||
* WordPress $wpdb, current_time().
|
||||
*
|
||||
* @see core/class-research-judge.php — Sole consumer.
|
||||
* @see core/class-audit-writer.php — Underlying DB insert layer.
|
||||
* @see ARCHITECTURE.md — Phase 2b curate stage.
|
||||
*/
|
||||
class PRAutoBlogger_Research_Source_Writer {
|
||||
|
||||
/**
|
||||
* Write run_sources rows for kept (kept=1) and discarded (kept=0) sources.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param array<int, array{url: string, agent_role?: string, quality_score: float, relevance: float}> $kept Scored kept sources.
|
||||
* @param array<int, array{url: string, agent_role?: string, quality_score: float, relevance: float}> $discarded Scored discarded sources (over max).
|
||||
* @return void Side effects: DB inserts; no-op when audit tables absent.
|
||||
*/
|
||||
public function write( string $run_id, array $kept, array $discarded ): void {
|
||||
if ( ! PRAutoBlogger_Audit_Writer::is_available() ) {
|
||||
return;
|
||||
}
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'prautoblogger_run_sources';
|
||||
$now = current_time( 'mysql' );
|
||||
$sources = array_merge(
|
||||
array_map(
|
||||
static fn( $s ) => $s + array(
|
||||
'is_kept' => 1,
|
||||
'discard_reason' => '',
|
||||
),
|
||||
$kept
|
||||
),
|
||||
array_map(
|
||||
static fn( $s ) => $s + array(
|
||||
'is_kept' => 0,
|
||||
'discard_reason' => 'Exceeded maximum kept sources per run',
|
||||
),
|
||||
$discarded
|
||||
)
|
||||
);
|
||||
foreach ( $sources as $s ) {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->insert(
|
||||
$table,
|
||||
array(
|
||||
'run_id' => $run_id,
|
||||
'agent_role' => $s['agent_role'] ?? 'curator',
|
||||
'source_url' => $s['url'],
|
||||
'doi' => $this->extract_doi( $s['url'] ),
|
||||
'kept' => $s['is_kept'],
|
||||
'reason' => $s['is_kept'] ? sprintf( 'Quality %.3f (relevance=%.2f)', $s['quality_score'], $s['relevance'] ) : $s['discard_reason'],
|
||||
'quality_score' => $s['quality_score'],
|
||||
'created_at' => $now,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract a DOI from a URL if one is present.
|
||||
*
|
||||
* @param string $url Source URL.
|
||||
* @return string|null DOI, or null.
|
||||
*/
|
||||
private function extract_doi( string $url ): ?string {
|
||||
if ( preg_match( '#10\.\d{4,}/\S+#', $url, $matches ) ) {
|
||||
return rtrim( $matches[0], '.' );
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Authority-tier SEO stage — writes _prab_* post-meta and computes citation_score.
|
||||
*
|
||||
* What: Deterministic (no LLM calls) meta-writer for the Authority pipeline.
|
||||
* Writes every key from the ratified JSON-LD contract onto the published
|
||||
* post so that peptide-repo-core can emit JSON-LD schema from them.
|
||||
* Computes citation_score = average quality_score of kept sources (0.0–1.0).
|
||||
* Records a run_decisions row for the 'seo' stage and run_stages start→done.
|
||||
* The publish gate that acts on citation_score is P2b.4; this stage only
|
||||
* computes and stores it. Not wired into the live Economy path until P2b.4.
|
||||
*
|
||||
* Who triggers it: PRAutoBlogger_Authority_Pipeline (P2b.4, NOT live yet).
|
||||
* Dependencies: PRAutoBlogger_Run_Stage_State, PRAutoBlogger_Audit_Writer,
|
||||
* PRAutoBlogger_Logger, WordPress post-meta API (update_post_meta).
|
||||
*
|
||||
* @see providers/interface-seo-stage.php — Interface this implements.
|
||||
* @see core/class-audit-writer.php — Decision audit row writer.
|
||||
* @see core/class-run-stage-state.php — Stage lifecycle tracker.
|
||||
* @see convo/prcore/decisions/2026-06-11-jsonld-contract-v1.md — Ratified contract.
|
||||
* @see ARCHITECTURE.md — Phase 2b SEO stage.
|
||||
*/
|
||||
class PRAutoBlogger_Seo_Stage implements PRAutoBlogger_Seo_Stage_Interface {
|
||||
|
||||
/** Default citation threshold when option is unset (intentionally uncalibrated). */
|
||||
public const DEFAULT_THRESHOLD = 0.0;
|
||||
|
||||
/** Option key for the citation score threshold (calibrated after ~10 Authority runs). */
|
||||
public const OPTION_THRESHOLD = 'prautoblogger_citation_score_threshold';
|
||||
|
||||
/**
|
||||
* Execute the SEO stage for a published article.
|
||||
*
|
||||
* Writes _prab_* post-meta per the ratified JSON-LD contract, computes
|
||||
* + stores citation_score, records run_stages start→done and a
|
||||
* run_decisions row for the 'seo' stage.
|
||||
*
|
||||
* Side effects: update_post_meta() calls (7 keys max — 6 written here;
|
||||
* `_prab_reviewed_by` is P2b.4/human-approval only), run_stages DB
|
||||
* write (start + done), run_decisions DB insert, Logger::info() calls.
|
||||
*
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @param string $item_key Article-scoped stage key.
|
||||
* @param int $post_id Published WordPress post ID.
|
||||
* @param array<int, array{url: string, title: string, doi?: string, quality_score?: float}> $kept_sources Kept research sources from the curate stage.
|
||||
* @param array<int, int> $peptide_ids Related peptide post IDs (may be empty).
|
||||
* @return float The computed citation_score (0.0–1.0).
|
||||
*/
|
||||
public function run(
|
||||
string $run_id,
|
||||
string $item_key,
|
||||
int $post_id,
|
||||
array $kept_sources,
|
||||
array $peptide_ids
|
||||
): float {
|
||||
PRAutoBlogger_Run_Stage_State::start( $run_id, 'seo', 'seo', $item_key );
|
||||
|
||||
$score = $this->compute_citation_score( $kept_sources );
|
||||
$threshold = (float) get_option( self::OPTION_THRESHOLD, self::DEFAULT_THRESHOLD );
|
||||
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf(
|
||||
'SEO stage: post=%d citation_score=%.4f threshold=%.4f sources=%d',
|
||||
$post_id,
|
||||
$score,
|
||||
$threshold,
|
||||
count( $kept_sources )
|
||||
),
|
||||
'seo'
|
||||
);
|
||||
|
||||
$this->write_post_meta( $post_id, $kept_sources, $peptide_ids, $score );
|
||||
|
||||
$rationale = sprintf(
|
||||
'citation_score=%.4f threshold=%.4f sources=%d',
|
||||
$score,
|
||||
$threshold,
|
||||
count( $kept_sources )
|
||||
);
|
||||
|
||||
PRAutoBlogger_Audit_Writer::record_decision(
|
||||
$run_id,
|
||||
'seo',
|
||||
'scored',
|
||||
$rationale,
|
||||
$score
|
||||
);
|
||||
|
||||
$done_payload = wp_json_encode(
|
||||
array(
|
||||
'citation_score' => $score,
|
||||
'meta_keys_written' => 6,
|
||||
)
|
||||
);
|
||||
|
||||
PRAutoBlogger_Run_Stage_State::done( $run_id, 'seo', 'seo', $item_key, $done_payload, 0.0 );
|
||||
|
||||
return $score;
|
||||
}
|
||||
|
||||
// ── Private helpers ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute citation_score as the average quality_score of kept sources.
|
||||
*
|
||||
* Formula: sum(quality_score) / max(count, 1). Returns 0.0 when no
|
||||
* sources are provided (empty kept_sources array).
|
||||
*
|
||||
* @param array<int, array{url: string, title: string, doi?: string, quality_score?: float}> $kept_sources Kept sources from the curate stage.
|
||||
* @return float Score in range 0.0–1.0.
|
||||
*/
|
||||
private function compute_citation_score( array $kept_sources ): float {
|
||||
if ( empty( $kept_sources ) ) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$total = 0.0;
|
||||
foreach ( $kept_sources as $source ) {
|
||||
$total += (float) ( $source['quality_score'] ?? 0.0 );
|
||||
}
|
||||
|
||||
return $total / count( $kept_sources );
|
||||
}
|
||||
|
||||
/**
|
||||
* Write all _prab_* post-meta keys to the published post.
|
||||
*
|
||||
* Keys written per the ratified JSON-LD contract (v1):
|
||||
* _prab_schema_version — int 1 (opt-in trigger for prcore JSON-LD).
|
||||
* _prab_citations — JSON array of kept sources.
|
||||
* _prab_about_peptides — JSON array of related peptide post IDs.
|
||||
* _prab_review_mode — 'editorial-system' (automated stage).
|
||||
* _prab_reviewed_at — ISO 8601 datetime.
|
||||
* _prab_citation_score — float stored as string.
|
||||
*
|
||||
* Note: _prab_reviewed_by is NOT written by this stage (automated path).
|
||||
* It is set in P2b.4 when a human approves via the Review Queue.
|
||||
*
|
||||
* @param int $post_id Published WordPress post ID.
|
||||
* @param array<int, array{url: string, title: string, doi?: string, quality_score?: float}> $kept_sources Kept sources.
|
||||
* @param array<int, int> $peptide_ids Related peptide post IDs.
|
||||
* @param float $score Computed citation_score.
|
||||
* @return void
|
||||
*/
|
||||
private function write_post_meta( int $post_id, array $kept_sources, array $peptide_ids, float $score ): void {
|
||||
$citations = array_map(
|
||||
static function ( array $source ): array {
|
||||
$entry = array(
|
||||
'url' => (string) ( $source['url'] ?? '' ),
|
||||
'title' => (string) ( $source['title'] ?? '' ),
|
||||
);
|
||||
if ( ! empty( $source['doi'] ) ) {
|
||||
$entry['doi'] = (string) $source['doi'];
|
||||
}
|
||||
if ( isset( $source['quality_score'] ) ) {
|
||||
$entry['quality_score'] = (float) $source['quality_score'];
|
||||
}
|
||||
return $entry;
|
||||
},
|
||||
$kept_sources
|
||||
);
|
||||
|
||||
update_post_meta( $post_id, '_prab_schema_version', 1 );
|
||||
update_post_meta( $post_id, '_prab_citations', wp_json_encode( $citations ) );
|
||||
update_post_meta( $post_id, '_prab_about_peptides', wp_json_encode( array_values( $peptide_ids ) ) );
|
||||
update_post_meta( $post_id, '_prab_review_mode', 'editorial-system' );
|
||||
update_post_meta( $post_id, '_prab_reviewed_at', gmdate( 'Y-m-d\TH:i:s' ) );
|
||||
update_post_meta( $post_id, '_prab_citation_score', (string) $score );
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Per-category generation tier router for the Authority pipeline.
|
||||
*
|
||||
* What: Resolves whether a given article idea should enter the Authority
|
||||
* pipeline or fall through to the Economy single-pass. The master
|
||||
* switch (`prautoblogger_authority_pipeline_enabled`) defaults FALSE,
|
||||
* which guarantees byte-identical behaviour to today — no articles
|
||||
* ever touch the Authority path unless an operator explicitly enables
|
||||
* the flag. When the flag is ON, per-category overrides in
|
||||
* `prautoblogger_category_tiers` demote specific categories to
|
||||
* Economy; every other category (unclassified, new, missing) stays
|
||||
* at the Authority default.
|
||||
*
|
||||
* Who triggers it: PRAutoBlogger_Article_Worker::generate() (P2b.4).
|
||||
* Dependencies: WordPress get_option(), PRAutoBlogger_Article_Idea.
|
||||
*
|
||||
* @see providers/interface-tier-router.php — Interface this class implements.
|
||||
* @see core/class-article-worker.php — Call site (3-line tier check).
|
||||
* @see core/class-authority-pipeline.php — Authority path executed on 'authority'.
|
||||
* @see ARCHITECTURE.md — Phase 2b tier routing.
|
||||
*/
|
||||
class PRAutoBlogger_Tier_Router implements PRAutoBlogger_Tier_Router_Interface {
|
||||
|
||||
/** Option key for the master Authority pipeline feature flag. */
|
||||
public const OPTION_MASTER_FLAG = 'prautoblogger_authority_pipeline_enabled';
|
||||
|
||||
/** Option key for the per-category tier map (serialized array). */
|
||||
public const OPTION_CATEGORY_TIERS = 'prautoblogger_category_tiers';
|
||||
|
||||
/** Default tier when a category has no explicit override: Authority. */
|
||||
private const DEFAULT_TIER = 'authority';
|
||||
|
||||
/**
|
||||
* Resolve the generation tier for an article idea.
|
||||
*
|
||||
* Returns 'economy' immediately when the master flag is OFF — this is
|
||||
* the production default and must guarantee zero behaviour change
|
||||
* relative to the pre-P2b.4 codebase.
|
||||
*
|
||||
* When the master flag is ON, looks up the idea's article_type in the
|
||||
* per-category tier map. Values of 'economy' explicitly demote that
|
||||
* category. Any other value, or no entry at all, keeps the Authority
|
||||
* default.
|
||||
*
|
||||
* Side effects: two get_option() calls (cached by WordPress object cache).
|
||||
*
|
||||
* @param PRAutoBlogger_Article_Idea $idea The article idea to route.
|
||||
* @return string 'authority' or 'economy'
|
||||
*/
|
||||
public function resolve( PRAutoBlogger_Article_Idea $idea ): string {
|
||||
if ( ! $this->is_master_flag_on() ) {
|
||||
return 'economy';
|
||||
}
|
||||
|
||||
return $this->resolve_from_category( $idea->get_article_type() );
|
||||
}
|
||||
|
||||
// ── Private helpers ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Whether the Authority pipeline master switch is enabled.
|
||||
*
|
||||
* The option defaults to false (disabled). Operators must explicitly
|
||||
* set it to '1' or true via wp_options / WP admin to enable the path.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function is_master_flag_on(): bool {
|
||||
$flag = get_option( self::OPTION_MASTER_FLAG, false );
|
||||
return in_array( $flag, array( '1', true, 1 ), true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve tier from the per-category tier map.
|
||||
*
|
||||
* The map is a serialized PHP array (stored via WordPress options) of
|
||||
* `['category_slug' => 'economy']`. Only 'economy' is a meaningful
|
||||
* explicit value — anything else (including 'authority') is treated
|
||||
* as "keep the default".
|
||||
*
|
||||
* Unrecognised or empty maps → default Authority tier (additive safety).
|
||||
*
|
||||
* @param string $article_type Article type / category slug from the idea.
|
||||
* @return string 'authority' or 'economy'
|
||||
*/
|
||||
private function resolve_from_category( string $article_type ): string {
|
||||
$raw = get_option( self::OPTION_CATEGORY_TIERS, array() );
|
||||
$map = is_array( $raw ) ? $raw : array();
|
||||
|
||||
$tier = $map[ $article_type ] ?? self::DEFAULT_TIER;
|
||||
|
||||
// Only 'economy' is a valid explicit demote; any other value defaults.
|
||||
return 'economy' === $tier ? 'economy' : 'authority';
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Immutable value object representing one round of the editorial loop.
|
||||
*
|
||||
* Captures the editor's critique and the writer's response for that
|
||||
* round so the audit trail can reconstruct the full loop history.
|
||||
*
|
||||
* Created by: PRAutoBlogger_Editorial_Loop per round.
|
||||
* Persisted by: PRAutoBlogger_Audit_Writer::record_editorial_round().
|
||||
*
|
||||
* @see core/class-editorial-loop.php — Produces these objects.
|
||||
* @see core/class-audit-writer.php — Persists them to run_decisions.
|
||||
* @see ARCHITECTURE.md — Phase 2b editorial loop design.
|
||||
*/
|
||||
class PRAutoBlogger_Editorial_Round {
|
||||
|
||||
/** @var int Round number (1-based). */
|
||||
private int $round_number;
|
||||
|
||||
/** @var string Editor's critique / notes for this round. */
|
||||
private string $editor_notes;
|
||||
|
||||
/** @var string Editor's verdict for this round ('approved'|'revised'|'rejected'). */
|
||||
private string $editor_verdict;
|
||||
|
||||
/** @var string Content produced by the writer after this round (empty on approval without revision). */
|
||||
private string $revised_content;
|
||||
|
||||
/** @var float Editor quality score for this round (0.0–1.0). */
|
||||
private float $quality_score;
|
||||
|
||||
/** @var float Editor SEO score for this round (0.0–1.0). */
|
||||
private float $seo_score;
|
||||
|
||||
/**
|
||||
* @param int $round_number 1-based round index.
|
||||
* @param string $editor_notes Critique notes from the chief editor.
|
||||
* @param string $editor_verdict 'approved', 'revised', or 'rejected'.
|
||||
* @param string $revised_content Writer's revised content ('' if approved first time).
|
||||
* @param float $quality_score Editor quality score (0.0–1.0).
|
||||
* @param float $seo_score Editor SEO score (0.0–1.0).
|
||||
*/
|
||||
public function __construct(
|
||||
int $round_number,
|
||||
string $editor_notes,
|
||||
string $editor_verdict,
|
||||
string $revised_content,
|
||||
float $quality_score,
|
||||
float $seo_score
|
||||
) {
|
||||
$this->round_number = $round_number;
|
||||
$this->editor_notes = $editor_notes;
|
||||
$this->editor_verdict = $editor_verdict;
|
||||
$this->revised_content = $revised_content;
|
||||
$this->quality_score = $quality_score;
|
||||
$this->seo_score = $seo_score;
|
||||
}
|
||||
|
||||
/** @return int Round number (1-based). */
|
||||
public function get_round_number(): int {
|
||||
return $this->round_number;
|
||||
}
|
||||
|
||||
/** @return string Critique notes. */
|
||||
public function get_editor_notes(): string {
|
||||
return $this->editor_notes;
|
||||
}
|
||||
|
||||
/** @return string 'approved', 'revised', or 'rejected'. */
|
||||
public function get_editor_verdict(): string {
|
||||
return $this->editor_verdict;
|
||||
}
|
||||
|
||||
/** @return string Writer's revised content for this round. */
|
||||
public function get_revised_content(): string {
|
||||
return $this->revised_content;
|
||||
}
|
||||
|
||||
/** @return float Quality score (0.0–1.0). */
|
||||
public function get_quality_score(): float {
|
||||
return $this->quality_score;
|
||||
}
|
||||
|
||||
/** @return float SEO score (0.0–1.0). */
|
||||
public function get_seo_score(): float {
|
||||
return $this->seo_score;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialise round to array for JSON snapshot / audit persistence.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function to_array(): array {
|
||||
return array(
|
||||
'round_number' => $this->round_number,
|
||||
'editor_notes' => $this->editor_notes,
|
||||
'editor_verdict' => $this->editor_verdict,
|
||||
'revised_content' => $this->revised_content,
|
||||
'quality_score' => $this->quality_score,
|
||||
'seo_score' => $this->seo_score,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Authority Pipeline Interface
|
||||
*
|
||||
* Contract for the full Authority-tier article generation pipeline.
|
||||
*
|
||||
* @package PRAutoBlogger
|
||||
* @since 0.31.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full Authority-tier article generation pipeline.
|
||||
*
|
||||
* Implementations must orchestrate: research → curate → draft →
|
||||
* editorial → seo → publish gate, with cost-governor reserve/settle
|
||||
* on every stage and cost-ceiling halt → HOLD semantics.
|
||||
*
|
||||
* @see core/class-authority-pipeline.php — Production implementation.
|
||||
*/
|
||||
interface PRAutoBlogger_Authority_Pipeline_Interface {
|
||||
|
||||
/**
|
||||
* Run the full Authority-tier pipeline for one article.
|
||||
*
|
||||
* @param string $run_id Unique run identifier.
|
||||
* @param PRAutoBlogger_Article_Idea $idea The article idea to process.
|
||||
* @param PRAutoBlogger_Cost_Tracker $cost_tracker Active cost tracker for this run.
|
||||
* @return array{generated: int, published: int, rejected: int, cost: float, status: string} Pipeline result.
|
||||
*/
|
||||
public function run(
|
||||
string $run_id,
|
||||
PRAutoBlogger_Article_Idea $idea,
|
||||
PRAutoBlogger_Cost_Tracker $cost_tracker
|
||||
): array;
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Contract for the Authority-tier editorial loop.
|
||||
*
|
||||
* Wraps the Chief_Editor review into a bounded iterative loop (≤
|
||||
* `editorial_max_rounds`) where the editor critiques and the writer
|
||||
* revises until the article is approved or rounds are exhausted.
|
||||
*
|
||||
* @see core/class-editorial-loop.php — Primary implementation.
|
||||
* @see ARCHITECTURE.md — Phase 2b editorial stage design.
|
||||
*/
|
||||
interface PRAutoBlogger_Editorial_Loop_Interface {
|
||||
|
||||
/**
|
||||
* Run the iterative editorial loop for one article.
|
||||
*
|
||||
* Each round: editor critiques → writer revises → if approved, stop.
|
||||
* After max rounds without approval the article is escalated to the
|
||||
* Review Queue (saved as draft) and an empty string is returned so
|
||||
* the caller can detect the escalation.
|
||||
*
|
||||
* Side effects: multiple LLM API calls (reserved via cost governor),
|
||||
* `run_stages` + `run_decisions` DB writes per round.
|
||||
*
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @param string $item_key Article-scoped stage key.
|
||||
* @param string $content Initial draft HTML from the writer.
|
||||
* @param PRAutoBlogger_Article_Idea $idea The article idea under review.
|
||||
* @param PRAutoBlogger_Cost_Tracker $cost_tracker Pipeline cost tracker.
|
||||
* @return string Approved (or final-revised) HTML, or '' on escalation.
|
||||
*/
|
||||
public function run(
|
||||
string $run_id,
|
||||
string $item_key,
|
||||
string $content,
|
||||
PRAutoBlogger_Article_Idea $idea,
|
||||
PRAutoBlogger_Cost_Tracker $cost_tracker
|
||||
): string;
|
||||
|
||||
/**
|
||||
* Whether the last run() call resulted in an escalation to the Review Queue.
|
||||
*
|
||||
* Callers must check this after run() returns ''.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function was_escalated(): bool;
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Contract for parallel specialist research fan-out.
|
||||
*
|
||||
* @see core/class-research-fanout.php — Primary implementation.
|
||||
* @see ARCHITECTURE.md — Phase 2b design.
|
||||
*/
|
||||
interface PRAutoBlogger_Research_Fanout_Interface {
|
||||
|
||||
/**
|
||||
* Dispatch N specialist research agents in parallel and collect results.
|
||||
*
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @param string $item_key Article item key for stage scoping.
|
||||
* @param PRAutoBlogger_Article_Idea $idea The idea being researched.
|
||||
* @param PRAutoBlogger_Cost_Tracker $cost_tracker Pipeline cost tracker.
|
||||
* @return array<int, array{sources: array<int, array{url: string, title: string, excerpt: string, relevance: float}>, agent_role: string}> Results indexed by agent slot (0..N-1). Empty array means quorum not met (run should hold).
|
||||
*/
|
||||
public function dispatch(
|
||||
string $run_id,
|
||||
string $item_key,
|
||||
PRAutoBlogger_Article_Idea $idea,
|
||||
PRAutoBlogger_Cost_Tracker $cost_tracker
|
||||
): array;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Contract for the research judge that curates fan-out results.
|
||||
*
|
||||
* @see core/class-research-judge.php — Primary implementation.
|
||||
* @see ARCHITECTURE.md — Phase 2b curate stage.
|
||||
*/
|
||||
interface PRAutoBlogger_Research_Judge_Interface {
|
||||
|
||||
/**
|
||||
* Deduplicate and score the fan-out results; write keep/discard decisions
|
||||
* to run_sources; return kept sources for the draft stage.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $item_key Article-scoped item key.
|
||||
* @param array<int, array{sources: array<int, array{url: string, title: string, excerpt: string, relevance: float}>, agent_role: string}> $fanout_results Agent results from Research_Fanout::dispatch().
|
||||
* @return array<int, array{url: string, title: string, excerpt: string, relevance: float, quality_score: float}> Kept, deduplicated, scored sources.
|
||||
*/
|
||||
public function curate(
|
||||
string $run_id,
|
||||
string $item_key,
|
||||
array $fanout_results
|
||||
): array;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Contract for the SEO stage of the Authority pipeline.
|
||||
*
|
||||
* Implementations write _prab_* post-meta per the ratified JSON-LD contract
|
||||
* (convo/prcore/decisions/2026-06-11-jsonld-contract-v1.md) and compute
|
||||
* citation_score from kept research sources.
|
||||
*
|
||||
* @see core/class-seo-stage.php — Production implementation.
|
||||
*/
|
||||
interface PRAutoBlogger_Seo_Stage_Interface {
|
||||
|
||||
/**
|
||||
* Execute the SEO stage for a published article.
|
||||
*
|
||||
* Writes _prab_* post-meta, computes + stores citation_score, records
|
||||
* run_stages start→done and a run_decisions row.
|
||||
*
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @param string $item_key Article-scoped stage key.
|
||||
* @param int $post_id Published WordPress post ID.
|
||||
* @param array<int, array{url: string, title: string, doi?: string, quality_score?: float}> $kept_sources Kept research sources from the curate stage.
|
||||
* @param array<int, int> $peptide_ids Related peptide post IDs (may be empty).
|
||||
* @return float The computed citation_score (0.0–1.0).
|
||||
*/
|
||||
public function run(
|
||||
string $run_id,
|
||||
string $item_key,
|
||||
int $post_id,
|
||||
array $kept_sources,
|
||||
array $peptide_ids
|
||||
): float;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Tier Router Interface
|
||||
*
|
||||
* Contract for resolving the generation tier (authority / economy) for an article idea.
|
||||
*
|
||||
* @package PRAutoBlogger
|
||||
* @since 0.31.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves Authority vs Economy generation tier for a given article idea.
|
||||
*
|
||||
* Implementations read the per-category tier map and the master feature flag.
|
||||
* When the master flag is OFF, resolve() MUST return 'economy' unconditionally
|
||||
* to guarantee zero behaviour change in production.
|
||||
*
|
||||
* @see core/class-tier-router.php — Production implementation.
|
||||
*/
|
||||
interface PRAutoBlogger_Tier_Router_Interface {
|
||||
|
||||
/**
|
||||
* Resolve the generation tier for an article idea.
|
||||
*
|
||||
* @param PRAutoBlogger_Article_Idea $idea The article idea to route.
|
||||
* @return string 'authority' or 'economy'
|
||||
*/
|
||||
public function resolve( PRAutoBlogger_Article_Idea $idea ): string;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"redefinable-internals": [
|
||||
"time",
|
||||
"gmdate"
|
||||
"time"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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.32.2
|
||||
* Version: 0.27.0
|
||||
* Requires at least: 6.0
|
||||
* Requires PHP: 7.4
|
||||
* Author: PeptideRepo
|
||||
@@ -35,8 +35,8 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
| Defined here so every file in the plugin can reference paths, versions,
|
||||
| and limits without magic strings.
|
||||
*/
|
||||
define( 'PRAUTOBLOGGER_VERSION', '0.32.2' );
|
||||
define( 'PRAUTOBLOGGER_DB_VERSION', '1.4.0' ); // No schema change in v0.32.0 (P2b.5 admin controls are additive only).
|
||||
define( 'PRAUTOBLOGGER_VERSION', '0.27.0' );
|
||||
define( 'PRAUTOBLOGGER_DB_VERSION', '1.4.0' );
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD', 0.50 );
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS', 14 );
|
||||
// Default max cards shown per board column and per ideas-browser page.
|
||||
|
||||
@@ -194,29 +194,4 @@ class GenHistoryQueryTest extends BaseTestCase {
|
||||
$this->assertSame( 20, \PRAutoBlogger_Gen_History_Query::PAGE_SIZE );
|
||||
$this->assertSame( 100, \PRAutoBlogger_Gen_History_Query::PAGE_SIZE_MAX );
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// get_page() early return
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* When $wpdb is null, get_page() returns the empty guard shape immediately.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_get_page_returns_empty_when_wpdb_null(): void {
|
||||
// Force the global to null to exercise the guard at the top of get_page().
|
||||
$prev_wpdb = $GLOBALS['wpdb'] ?? null;
|
||||
$GLOBALS['wpdb'] = null;
|
||||
|
||||
try {
|
||||
$query = new \PRAutoBlogger_Gen_History_Query();
|
||||
$result = $query->get_page();
|
||||
} finally {
|
||||
// Always restore so later tests are unaffected.
|
||||
$GLOBALS['wpdb'] = $prev_wpdb;
|
||||
}
|
||||
|
||||
$this->assertSame( array( 'rows' => array(), 'total' => 0 ), $result );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,534 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Authority_Pipeline and tier routing integration.
|
||||
*
|
||||
* Covers: Economy path taken when flag OFF (proven via TierRouter unit tests),
|
||||
* cost ceiling halt -> draft hold, citation gate (below/at threshold),
|
||||
* imagery suppressed on held articles, and stage order via mock calls.
|
||||
*
|
||||
* All dependencies that would make HTTP calls are mocked or stubbed.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class AuthorityPipelineTest extends BaseTestCase {
|
||||
|
||||
/** @var object Mock $wpdb. */
|
||||
private object $wpdb;
|
||||
|
||||
/** @var array Rows inserted to run_decisions. */
|
||||
private array $decision_rows = array();
|
||||
|
||||
/** @var array Post meta written. */
|
||||
private array $meta_written = array();
|
||||
|
||||
/** @var array Posts that had wp_publish_post() called. */
|
||||
private array $published_posts = array();
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->decision_rows = array();
|
||||
$this->meta_written = array();
|
||||
$this->published_posts = array();
|
||||
|
||||
$this->wpdb = $this->create_mock_wpdb();
|
||||
$GLOBALS["wpdb"] = $this->wpdb;
|
||||
$this->wpdb->prefix = "wp_";
|
||||
|
||||
$this->wpdb->method( "prepare" )->willReturnCallback(
|
||||
static fn( $sql, ...$args ) => $sql . " /* " . implode( ",", array_map( "strval", $args ) ) . " */"
|
||||
);
|
||||
|
||||
$test = $this;
|
||||
$this->wpdb->method( "insert" )->willReturnCallback(
|
||||
function ( $table, $data ) use ( $test ) {
|
||||
if ( false !== strpos( $table, "run_decisions" ) ) {
|
||||
$test->decision_rows[] = $data;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
);
|
||||
|
||||
$this->wpdb->method( "get_var" )->willReturnCallback(
|
||||
static function ( $sql ) {
|
||||
if ( false !== strpos( $sql, "SHOW TABLES" ) ) {
|
||||
return "wp_prautoblogger_run_decisions";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
$this->wpdb->method( "query" )->willReturn( 1 );
|
||||
$this->wpdb->method( "get_row" )->willReturn( null );
|
||||
$this->wpdb->method( "get_col" )->willReturn( array() );
|
||||
|
||||
\PRAutoBlogger_Audit_Writer::flush_cache();
|
||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||
|
||||
// Common WP function stubs.
|
||||
Functions\when( "current_time" )->justReturn( "2026-06-24 00:00:00" );
|
||||
Functions\when( "wp_json_encode" )->alias( "json_encode" );
|
||||
Functions\when( "gmdate" )->alias( "gmdate" );
|
||||
Functions\when( "__" )->alias( static fn( $s ) => $s );
|
||||
Functions\when( "sanitize_text_field" )->alias( static fn( $s ) => $s );
|
||||
Functions\when( "wp_kses_post" )->alias( static fn( $s ) => $s );
|
||||
Functions\when( "wp_strip_all_tags" )->alias( "strip_tags" );
|
||||
Functions\when( "apply_filters" )->alias( static fn( $tag, $val ) => $val );
|
||||
Functions\when( "do_action" )->justReturn( null );
|
||||
Functions\when( "wp_insert_post" )->justReturn( 42 );
|
||||
Functions\when( "wp_set_post_terms" )->justReturn( array() );
|
||||
Functions\when( "wp_set_post_categories" )->justReturn( array() );
|
||||
Functions\when( "wp_set_post_tags" )->justReturn( array() );
|
||||
Functions\when( "post_type_exists" )->justReturn( false );
|
||||
Functions\when( "get_users" )->justReturn( array( 1 ) );
|
||||
Functions\when( "get_term_by" )->justReturn( false );
|
||||
Functions\when( "wp_insert_term" )->justReturn( array( "term_id" => 1 ) );
|
||||
Functions\when( "absint" )->alias( "intval" );
|
||||
Functions\when( "is_wp_error" )->justReturn( false );
|
||||
Functions\when( "get_post_meta" )->justReturn( "" );
|
||||
|
||||
$test = $this;
|
||||
Functions\when( "wp_publish_post" )->alias(
|
||||
function ( $id ) use ( $test ) {
|
||||
$test->published_posts[] = $id;
|
||||
}
|
||||
);
|
||||
|
||||
Functions\when( "update_post_meta" )->alias(
|
||||
function ( $post_id, $key, $value ) use ( $test ) {
|
||||
$test->meta_written[ $key ] = $value;
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
$this->stub_get_option( array(
|
||||
"prautoblogger_log_level" => "error",
|
||||
"prautoblogger_auto_publish" => "0",
|
||||
"prautoblogger_citation_score_threshold" => 0.0,
|
||||
"prautoblogger_writing_pipeline" => "single_pass",
|
||||
"prautoblogger_writing_model" => "openai/gpt-4o-mini",
|
||||
"prautoblogger_default_author" => 1,
|
||||
) );
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
\PRAutoBlogger_Audit_Writer::flush_cache();
|
||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||
unset( $GLOBALS["wpdb"] );
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// -- Cost ceiling halt ------------------------------------------------
|
||||
|
||||
/**
|
||||
* When the research stage throws PRAutoBlogger_Cost_Ceiling_Exception,
|
||||
* the pipeline must HOLD the article as draft (status="halted") and
|
||||
* must NOT force-complete or publish it.
|
||||
*/
|
||||
public function test_cost_ceiling_halt_holds_article(): void {
|
||||
$fanout = $this->make_fanout_throwing_ceiling();
|
||||
$tracker = $this->make_cost_tracker_stub();
|
||||
$pipeline = new \PRAutoBlogger_Authority_Pipeline( $tracker, $fanout );
|
||||
$result = $pipeline->run( "run-ceiling", $this->make_idea(), $tracker );
|
||||
|
||||
$this->assertSame( "halted", $result["status"] );
|
||||
$this->assertSame( 0, $result["published"] );
|
||||
$this->assertEmpty( $this->published_posts );
|
||||
}
|
||||
|
||||
// -- Citation gate ----------------------------------------------------
|
||||
|
||||
/**
|
||||
* When citation_score (computed by Seo_Stage from kept_sources) is below
|
||||
* the threshold, the article must be held as draft, not published.
|
||||
* auto_publish=1 is set so the only thing blocking publish is the gate.
|
||||
*/
|
||||
public function test_citation_gate_holds_below_threshold(): void {
|
||||
$this->stub_get_option( array(
|
||||
"prautoblogger_log_level" => "error",
|
||||
"prautoblogger_auto_publish" => "1",
|
||||
"prautoblogger_citation_score_threshold" => 0.8,
|
||||
"prautoblogger_writing_pipeline" => "single_pass",
|
||||
"prautoblogger_writing_model" => "openai/gpt-4o-mini",
|
||||
"prautoblogger_default_author" => 1,
|
||||
) );
|
||||
|
||||
$low_quality_sources = array(
|
||||
array( "url" => "https://a.com", "title" => "A", "quality_score" => 0.3 ),
|
||||
);
|
||||
|
||||
$fanout = $this->make_fanout_stub( $this->make_fanout_results( $low_quality_sources ) );
|
||||
$judge = $this->make_judge_stub( $low_quality_sources );
|
||||
$loop = $this->make_editorial_loop_stub( "<p>approved content</p>", false );
|
||||
$generator = $this->make_generator_stub( "<p>draft content</p>" );
|
||||
$tracker = $this->make_cost_tracker_stub();
|
||||
$pipeline = new \PRAutoBlogger_Authority_Pipeline( $tracker, $fanout, $judge, $loop, $generator );
|
||||
$result = $pipeline->run( "run-citation-low", $this->make_idea(), $tracker );
|
||||
|
||||
$this->assertSame( "held-citation", $result["status"] );
|
||||
$this->assertSame( 0, $result["published"] );
|
||||
$this->assertEmpty( $this->published_posts );
|
||||
$this->assertArrayHasKey( "_prautoblogger_imagery_suppressed", $this->meta_written );
|
||||
$this->assertSame( "1", $this->meta_written["_prautoblogger_imagery_suppressed"] );
|
||||
}
|
||||
|
||||
/**
|
||||
* When citation_score equals the threshold, the gate passes.
|
||||
* auto_publish=0 so we verify status is not "held-citation".
|
||||
*/
|
||||
public function test_citation_gate_passes_at_threshold(): void {
|
||||
$this->stub_get_option( array(
|
||||
"prautoblogger_log_level" => "error",
|
||||
"prautoblogger_auto_publish" => "0",
|
||||
"prautoblogger_citation_score_threshold" => 0.8,
|
||||
"prautoblogger_writing_pipeline" => "single_pass",
|
||||
"prautoblogger_writing_model" => "openai/gpt-4o-mini",
|
||||
"prautoblogger_default_author" => 1,
|
||||
) );
|
||||
|
||||
$high_quality_sources = array(
|
||||
array( "url" => "https://a.com", "title" => "A", "quality_score" => 0.8 ),
|
||||
);
|
||||
|
||||
$fanout = $this->make_fanout_stub( $this->make_fanout_results( $high_quality_sources ) );
|
||||
$judge = $this->make_judge_stub( $high_quality_sources );
|
||||
$loop = $this->make_editorial_loop_stub( "<p>approved content</p>", false );
|
||||
$generator = $this->make_generator_stub( "<p>draft content</p>" );
|
||||
$tracker = $this->make_cost_tracker_stub();
|
||||
$pipeline = new \PRAutoBlogger_Authority_Pipeline( $tracker, $fanout, $judge, $loop, $generator );
|
||||
$result = $pipeline->run( "run-citation-at", $this->make_idea(), $tracker );
|
||||
|
||||
$this->assertNotSame( "held-citation", $result["status"] );
|
||||
$this->assertArrayNotHasKey( "_prautoblogger_imagery_suppressed", $this->meta_written );
|
||||
}
|
||||
|
||||
// -- Imagery gate -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* When the editorial loop escalates (article held), imagery must be
|
||||
* suppressed via _prautoblogger_imagery_suppressed=1 post-meta.
|
||||
*/
|
||||
public function test_imagery_suppressed_on_hold(): void {
|
||||
$sources = array( array( "url" => "https://a.com", "title" => "A", "quality_score" => 0.9 ) );
|
||||
$fanout = $this->make_fanout_stub( $this->make_fanout_results( $sources ) );
|
||||
$judge = $this->make_judge_stub( $sources );
|
||||
$loop = $this->make_editorial_loop_stub( "", true );
|
||||
$generator = $this->make_generator_stub( "<p>draft content</p>" );
|
||||
$tracker = $this->make_cost_tracker_stub();
|
||||
$pipeline = new \PRAutoBlogger_Authority_Pipeline( $tracker, $fanout, $judge, $loop, $generator );
|
||||
$result = $pipeline->run( "run-imagery", $this->make_idea(), $tracker );
|
||||
|
||||
$this->assertSame( "held-escalated", $result["status"] );
|
||||
$this->assertArrayHasKey( "_prautoblogger_imagery_suppressed", $this->meta_written );
|
||||
$this->assertSame( "1", $this->meta_written["_prautoblogger_imagery_suppressed"] );
|
||||
}
|
||||
|
||||
// -- Economy path flag check ------------------------------------------
|
||||
|
||||
/**
|
||||
* When the master flag is OFF, Tier_Router returns "economy".
|
||||
* The Authority pipeline is never instantiated or called.
|
||||
*/
|
||||
public function test_master_flag_off_economy_path_used(): void {
|
||||
Functions\when( "get_option" )->alias(
|
||||
function ( $name, $default = false ) {
|
||||
if ( "prautoblogger_authority_pipeline_enabled" === $name ) {
|
||||
return false;
|
||||
}
|
||||
if ( "prautoblogger_log_level" === $name ) {
|
||||
return "error";
|
||||
}
|
||||
return $default;
|
||||
}
|
||||
);
|
||||
|
||||
$idea = $this->make_idea();
|
||||
$router = new \PRAutoBlogger_Tier_Router();
|
||||
$tier = $router->resolve( $idea );
|
||||
|
||||
$this->assertSame( "economy", $tier );
|
||||
$this->assertArrayNotHasKey( "_prautoblogger_imagery_suppressed", $this->meta_written );
|
||||
}
|
||||
|
||||
// -- Stage ordering ---------------------------------------------------
|
||||
|
||||
/**
|
||||
* Stage order: research -> curate -> draft -> editorial -> seo -> gate.
|
||||
* Verify that fanout, judge, generator, and editorial loop are called.
|
||||
*/
|
||||
public function test_authority_chain_runs_stages_in_order(): void {
|
||||
$sources = array( array( "url" => "https://a.com", "title" => "A", "quality_score" => 0.9 ) );
|
||||
$called = array();
|
||||
|
||||
$fanout = $this->getMockBuilder( \PRAutoBlogger_Research_Fanout::class )
|
||||
->disableOriginalConstructor()
|
||||
->onlyMethods( array( "dispatch" ) )
|
||||
->getMock();
|
||||
$fanout->expects( $this->once() )->method( "dispatch" )
|
||||
->willReturnCallback(
|
||||
function () use ( &$called, $sources ) {
|
||||
$called[] = "research";
|
||||
return $this->make_fanout_results( $sources );
|
||||
}
|
||||
);
|
||||
|
||||
$judge = $this->getMockBuilder( \PRAutoBlogger_Research_Judge::class )
|
||||
->disableOriginalConstructor()
|
||||
->onlyMethods( array( "curate" ) )
|
||||
->getMock();
|
||||
$judge->expects( $this->once() )->method( "curate" )
|
||||
->willReturnCallback(
|
||||
function () use ( &$called, $sources ) {
|
||||
$called[] = "curate";
|
||||
return $sources;
|
||||
}
|
||||
);
|
||||
|
||||
$generator = $this->getMockBuilder( \PRAutoBlogger_Content_Generator::class )
|
||||
->disableOriginalConstructor()
|
||||
->onlyMethods( array( "generate" ) )
|
||||
->getMock();
|
||||
$generator->expects( $this->once() )->method( "generate" )
|
||||
->willReturnCallback(
|
||||
function () use ( &$called ) {
|
||||
$called[] = "draft";
|
||||
return "<p>draft content</p>";
|
||||
}
|
||||
);
|
||||
|
||||
$loop = $this->getMockBuilder( \PRAutoBlogger_Editorial_Loop::class )
|
||||
->disableOriginalConstructor()
|
||||
->onlyMethods( array( "run", "was_escalated" ) )
|
||||
->getMock();
|
||||
$loop->expects( $this->once() )->method( "run" )
|
||||
->willReturnCallback(
|
||||
function () use ( &$called ) {
|
||||
$called[] = "editorial";
|
||||
return "<p>approved content</p>";
|
||||
}
|
||||
);
|
||||
$loop->method( "was_escalated" )->willReturn( false );
|
||||
|
||||
$tracker = $this->make_cost_tracker_stub();
|
||||
$pipeline = new \PRAutoBlogger_Authority_Pipeline( $tracker, $fanout, $judge, $loop, $generator );
|
||||
$result = $pipeline->run( "run-order", $this->make_idea(), $tracker );
|
||||
|
||||
$this->assertSame( array( "research", "curate", "draft", "editorial" ), $called );
|
||||
$this->assertIsArray( $result );
|
||||
$this->assertArrayHasKey( "status", $result );
|
||||
}
|
||||
|
||||
|
||||
// -- P1: Quorum-miss hold (no content) --------------------------------
|
||||
|
||||
/**
|
||||
* When fanout returns empty (quorum miss), the pipeline must record a
|
||||
* HOLD decision row and return status='held-quorum'. No post must be
|
||||
* created (wp_insert_post must NOT be called), and no exception must
|
||||
* be thrown.
|
||||
*
|
||||
* Regression: before v0.32.2, hold_as_draft('') called Publisher which
|
||||
* threw RuntimeException, causing the run to land as 'failed' instead.
|
||||
*/
|
||||
public function test_quorum_miss_hold_records_decision_no_post_created(): void {
|
||||
// Fanout returns empty -- quorum miss.
|
||||
$fanout = $this->make_fanout_stub( array() );
|
||||
$tracker = $this->make_cost_tracker_stub();
|
||||
$pipeline = new \PRAutoBlogger_Authority_Pipeline( $tracker, $fanout );
|
||||
|
||||
// wp_insert_post must never be called on a quorum-miss hold.
|
||||
Functions\expect( "wp_insert_post" )->never();
|
||||
|
||||
$result = $pipeline->run( "run-quorum", $this->make_idea(), $tracker );
|
||||
|
||||
$this->assertSame( "held-quorum", $result["status"] );
|
||||
$this->assertSame( 0, $result["published"] );
|
||||
$this->assertEmpty( $this->published_posts );
|
||||
|
||||
// A run_decisions row must be written for the hold.
|
||||
$hold_decisions = array_filter(
|
||||
$this->decision_rows,
|
||||
static fn( $row ) => "quorum-miss" === $row["verdict"]
|
||||
);
|
||||
$this->assertNotEmpty( $hold_decisions, "Expected a quorum-miss decision row in run_decisions" );
|
||||
}
|
||||
|
||||
/**
|
||||
* When fanout returns empty (quorum miss), the run state must be
|
||||
* 'held-quorum', not 'failed'. The P1 bug caused an uncaught
|
||||
* RuntimeException which Article_Worker caught and marked 'failed'.
|
||||
*/
|
||||
public function test_quorum_miss_does_not_throw(): void {
|
||||
$fanout = $this->make_fanout_stub( array() );
|
||||
$tracker = $this->make_cost_tracker_stub();
|
||||
$pipeline = new \PRAutoBlogger_Authority_Pipeline( $tracker, $fanout );
|
||||
|
||||
// No exception must propagate out of run().
|
||||
$exception_thrown = false;
|
||||
try {
|
||||
$result = $pipeline->run( "run-nothrow", $this->make_idea(), $tracker );
|
||||
} catch ( \Throwable $e ) {
|
||||
$exception_thrown = true;
|
||||
}
|
||||
|
||||
$this->assertFalse( $exception_thrown, "run() must not throw on quorum miss (was RuntimeException before v0.32.2)" );
|
||||
}
|
||||
|
||||
// -- P1: Hold WITH content (editorial escalation) ---------------------
|
||||
|
||||
/**
|
||||
* When the editorial loop escalates with content present, hold_as_draft
|
||||
* must save a draft post (wp_insert_post called once) and suppress
|
||||
* imagery. Verifies the content-present path is unchanged by the fix.
|
||||
*/
|
||||
public function test_editorial_escalation_hold_saves_draft_with_content(): void {
|
||||
$sources = array( array( "url" => "https://a.com", "title" => "A", "quality_score" => 0.9 ) );
|
||||
$fanout = $this->make_fanout_stub( $this->make_fanout_results( $sources ) );
|
||||
$judge = $this->make_judge_stub( $sources );
|
||||
$loop = $this->make_editorial_loop_stub( "<p>escalated draft content</p>", true );
|
||||
$generator = $this->make_generator_stub( "<p>draft content</p>" );
|
||||
$tracker = $this->make_cost_tracker_stub();
|
||||
$pipeline = new \PRAutoBlogger_Authority_Pipeline( $tracker, $fanout, $judge, $loop, $generator );
|
||||
|
||||
$posts_inserted = 0;
|
||||
$original_when = null;
|
||||
Functions\when( "wp_insert_post" )->alias(
|
||||
function ( $args ) use ( &$posts_inserted ) {
|
||||
++$posts_inserted;
|
||||
return 55;
|
||||
}
|
||||
);
|
||||
|
||||
$result = $pipeline->run( "run-escalated", $this->make_idea(), $tracker );
|
||||
|
||||
$this->assertSame( "held-escalated", $result["status"] );
|
||||
$this->assertGreaterThanOrEqual( 1, $posts_inserted, "wp_insert_post must be called for escalated hold with content" );
|
||||
$this->assertArrayHasKey( "_prautoblogger_imagery_suppressed", $this->meta_written );
|
||||
$this->assertSame( "1", $this->meta_written["_prautoblogger_imagery_suppressed"] );
|
||||
}
|
||||
|
||||
// -- P2: pipeline_mode label ------------------------------------------
|
||||
|
||||
/**
|
||||
* Authority published posts must carry _prautoblogger_pipeline_mode='authority'.
|
||||
* The Economy single-pass label is validated separately in PublisherTest.
|
||||
*/
|
||||
public function test_authority_published_post_gets_authority_pipeline_mode(): void {
|
||||
$this->stub_get_option( array(
|
||||
"prautoblogger_log_level" => "error",
|
||||
"prautoblogger_auto_publish" => "0",
|
||||
"prautoblogger_citation_score_threshold" => 0.0,
|
||||
"prautoblogger_writing_pipeline" => "single_pass",
|
||||
"prautoblogger_writing_model" => "openai/gpt-4o-mini",
|
||||
"prautoblogger_default_author" => 1,
|
||||
) );
|
||||
|
||||
$sources = array( array( "url" => "https://a.com", "title" => "A", "quality_score" => 0.8 ) );
|
||||
$fanout = $this->make_fanout_stub( $this->make_fanout_results( $sources ) );
|
||||
$judge = $this->make_judge_stub( $sources );
|
||||
$loop = $this->make_editorial_loop_stub( "<p>approved content</p>", false );
|
||||
$generator = $this->make_generator_stub( "<p>draft content</p>" );
|
||||
$tracker = $this->make_cost_tracker_stub();
|
||||
$pipeline = new \PRAutoBlogger_Authority_Pipeline( $tracker, $fanout, $judge, $loop, $generator );
|
||||
|
||||
$captured_meta = null;
|
||||
Functions\when( "wp_insert_post" )->alias(
|
||||
function ( $args ) use ( &$captured_meta ) {
|
||||
$captured_meta = $args["meta_input"] ?? array();
|
||||
return 66;
|
||||
}
|
||||
);
|
||||
|
||||
$result = $pipeline->run( "run-pmode", $this->make_idea(), $tracker );
|
||||
|
||||
$this->assertNotNull( $captured_meta, "wp_insert_post must have been called" );
|
||||
$this->assertArrayHasKey( "_prautoblogger_pipeline_mode", $captured_meta );
|
||||
$this->assertSame(
|
||||
"authority",
|
||||
$captured_meta["_prautoblogger_pipeline_mode"],
|
||||
"Authority pipeline posts must carry pipeline_mode=authority, not single_pass"
|
||||
);
|
||||
}
|
||||
|
||||
// -- Helpers ----------------------------------------------------------
|
||||
|
||||
private function make_idea(): \PRAutoBlogger_Article_Idea {
|
||||
return new \PRAutoBlogger_Article_Idea( array(
|
||||
"topic" => "BPC-157",
|
||||
"article_type" => "guide",
|
||||
"suggested_title" => "BPC-157 Guide",
|
||||
"summary" => "Comprehensive guide.",
|
||||
"score" => 0.9,
|
||||
) );
|
||||
}
|
||||
|
||||
private function make_cost_tracker_stub(): object {
|
||||
$tracker = $this->getMockBuilder( \PRAutoBlogger_Cost_Tracker::class )
|
||||
->disableOriginalConstructor()
|
||||
->onlyMethods( array( "get_run_id", "get_current_run_cost", "log_api_call" ) )
|
||||
->getMock();
|
||||
$tracker->method( "get_run_id" )->willReturn( "run-test" );
|
||||
$tracker->method( "get_current_run_cost" )->willReturn( 0.01 );
|
||||
return $tracker;
|
||||
}
|
||||
|
||||
private function make_fanout_throwing_ceiling(): object {
|
||||
$fanout = $this->getMockBuilder( \PRAutoBlogger_Research_Fanout::class )
|
||||
->disableOriginalConstructor()
|
||||
->onlyMethods( array( "dispatch" ) )
|
||||
->getMock();
|
||||
$fanout->method( "dispatch" )->willThrowException(
|
||||
new \PRAutoBlogger_Cost_Ceiling_Exception( "Test cost ceiling breach." )
|
||||
);
|
||||
return $fanout;
|
||||
}
|
||||
|
||||
private function make_fanout_stub( array $results ): object {
|
||||
$fanout = $this->getMockBuilder( \PRAutoBlogger_Research_Fanout::class )
|
||||
->disableOriginalConstructor()
|
||||
->onlyMethods( array( "dispatch" ) )
|
||||
->getMock();
|
||||
$fanout->method( "dispatch" )->willReturn( $results );
|
||||
return $fanout;
|
||||
}
|
||||
|
||||
private function make_judge_stub( array $kept_sources ): object {
|
||||
$judge = $this->getMockBuilder( \PRAutoBlogger_Research_Judge::class )
|
||||
->disableOriginalConstructor()
|
||||
->onlyMethods( array( "curate" ) )
|
||||
->getMock();
|
||||
$judge->method( "curate" )->willReturn( $kept_sources );
|
||||
return $judge;
|
||||
}
|
||||
|
||||
private function make_editorial_loop_stub( string $content, bool $escalated ): object {
|
||||
$loop = $this->getMockBuilder( \PRAutoBlogger_Editorial_Loop::class )
|
||||
->disableOriginalConstructor()
|
||||
->onlyMethods( array( "run", "was_escalated" ) )
|
||||
->getMock();
|
||||
$loop->method( "run" )->willReturn( $content );
|
||||
$loop->method( "was_escalated" )->willReturn( $escalated );
|
||||
return $loop;
|
||||
}
|
||||
|
||||
private function make_generator_stub( string $content ): object {
|
||||
$gen = $this->getMockBuilder( \PRAutoBlogger_Content_Generator::class )
|
||||
->disableOriginalConstructor()
|
||||
->onlyMethods( array( "generate" ) )
|
||||
->getMock();
|
||||
$gen->method( "generate" )->willReturn( $content );
|
||||
return $gen;
|
||||
}
|
||||
|
||||
private function make_fanout_results( array $sources ): array {
|
||||
return array(
|
||||
array( "sources" => $sources, "agent_role" => "mechanisms" ),
|
||||
array( "sources" => $sources, "agent_role" => "clinical" ),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Authority pipeline stage vocabulary and admin integration tests.
|
||||
*
|
||||
* Tests 1–3 were originally stubs shipping before the Authority pipeline classes.
|
||||
* Since P2b.4 + P2b.5 are now on main, test 1 is upgraded to a real assertion:
|
||||
* Stage_Display_Map must return non-empty labels for every v2 Authority stage.
|
||||
* Tests 2–4 exercise live admin classes (step rail + model option allowlist).
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
* @group authority
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
|
||||
class AuthorityStageOrderStubTest extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* Stage_Display_Map must return non-empty, distinct labels for the six
|
||||
* canonical Authority v2 stages: research, curate, draft, editorial, seo, publish.
|
||||
*
|
||||
* This replaces the tautological "$stage is a string" stub shipped in P2b.5.
|
||||
* We verify the map has real entries (not the humanized fallback) for every stage
|
||||
* so the audit/dossier UI never renders a blank or auto-generated label.
|
||||
*/
|
||||
public function test_authority_stage_vocabulary_is_defined(): void {
|
||||
$v2_stages = array( 'research', 'curate', 'draft', 'editorial', 'seo', 'publish' );
|
||||
|
||||
// All 6 must be registered as known (avoids the humanize fallback).
|
||||
foreach ( $v2_stages as $stage ) {
|
||||
$this->assertTrue(
|
||||
\PRAutoBlogger_Stage_Display_Map::is_known( $stage ),
|
||||
"Stage '$stage' must be known to Stage_Display_Map (not falling through to humanizer)."
|
||||
);
|
||||
}
|
||||
|
||||
// Each must have a non-empty label returned by the map.
|
||||
foreach ( $v2_stages as $stage ) {
|
||||
$label = \PRAutoBlogger_Stage_Display_Map::label( $stage );
|
||||
$this->assertNotSame( '', $label, "Stage '$stage' must produce a non-empty label." );
|
||||
}
|
||||
|
||||
// Labels must be distinct (no two stages share the same display label).
|
||||
$labels = array_map(
|
||||
static fn( string $s ) => \PRAutoBlogger_Stage_Display_Map::label( $s ),
|
||||
$v2_stages
|
||||
);
|
||||
$this->assertCount(
|
||||
count( $v2_stages ),
|
||||
array_unique( $labels ),
|
||||
'Every Authority stage must have a unique display label.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step rail contexts include curate, seo, and authority after P2b.5.
|
||||
*/
|
||||
public function test_pipeline_settings_contexts_include_authority_steps(): void {
|
||||
$contexts = \PRAutoBlogger_Pipeline_Settings_Option_Fields::contexts();
|
||||
$this->assertContains( 'curate', $contexts, 'curate context must be registered' );
|
||||
$this->assertContains( 'seo', $contexts, 'seo context must be registered' );
|
||||
$this->assertContains( 'authority', $contexts, 'authority context must be registered' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Step map includes curate, seo, and authority steps after P2b.5.
|
||||
*/
|
||||
public function test_step_map_includes_authority_steps(): void {
|
||||
$step_ids = array_column( \PRAutoBlogger_Pipeline_Settings_Step_Map::steps(), 'id' );
|
||||
$this->assertContains( 'curate', $step_ids, 'curate step must appear in step rail' );
|
||||
$this->assertContains( 'seo', $step_ids, 'seo step must appear in step rail' );
|
||||
$this->assertContains( 'authority', $step_ids, 'authority step must appear in step rail' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Authority allowed model options includes curate_model and seo_model.
|
||||
*/
|
||||
public function test_allowed_model_options_includes_curate_and_seo_models(): void {
|
||||
$opts = \PRAutoBlogger_Pipeline_Settings_Step_Map::allowed_model_options();
|
||||
$this->assertContains( 'prautoblogger_curate_model', $opts );
|
||||
$this->assertContains( 'prautoblogger_seo_model', $opts );
|
||||
}
|
||||
}
|
||||
@@ -1,330 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Editorial_Loop.
|
||||
*
|
||||
* Covers: bound enforcement (max rounds respected), approval on first round,
|
||||
* escalation when max rounds exhausted without approval, round recording to
|
||||
* run_decisions, inline revision path vs writer revision path.
|
||||
*
|
||||
* The LLM calls are made via Chief_Editor (editor) and Editorial_Revision_Caller
|
||||
* (writer revision). Both are mocked in these unit tests.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class EditorialLoopTest extends BaseTestCase {
|
||||
|
||||
/** @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb. */
|
||||
private $wpdb;
|
||||
|
||||
/** @var array<int, array> Rows inserted to run_decisions via Audit_Writer. */
|
||||
private array $decision_rows = array();
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->decision_rows = array();
|
||||
$this->wpdb = $this->create_mock_wpdb();
|
||||
$GLOBALS["wpdb"] = $this->wpdb;
|
||||
$this->wpdb->prefix = "wp_";
|
||||
|
||||
$this->wpdb->method( "prepare" )->willReturnCallback(
|
||||
static function ( $sql, ...$args ) {
|
||||
return $sql . " /* " . implode( ",", array_map( "strval", $args ) ) . " */";
|
||||
}
|
||||
);
|
||||
|
||||
// Capture run_decisions inserts (Audit_Writer::record_decision).
|
||||
$test = $this;
|
||||
$this->wpdb->method( "insert" )->willReturnCallback(
|
||||
function ( $table, $data ) use ( $test ) {
|
||||
$test->decision_rows[] = $data;
|
||||
return 1;
|
||||
}
|
||||
);
|
||||
|
||||
// SHOW TABLES returns the table name to simulate "tables available".
|
||||
$this->wpdb->method( "get_var" )->willReturnCallback(
|
||||
function ( $sql ) {
|
||||
if ( false !== strpos( $sql, "SHOW TABLES" ) ) {
|
||||
return "wp_prautoblogger_run_decisions";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
\PRAutoBlogger_Audit_Writer::flush_cache();
|
||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||
|
||||
Functions\when( "current_time" )->justReturn( "2026-06-24 00:00:00" );
|
||||
Functions\when( "wp_json_encode" )->alias( "json_encode" );
|
||||
|
||||
$this->stub_get_option( array(
|
||||
"prautoblogger_log_level" => "error",
|
||||
"prautoblogger_editorial_max_rounds" => 3,
|
||||
"prautoblogger_writing_model" => "openai/gpt-4o-mini",
|
||||
) );
|
||||
|
||||
// Pipeline_Status::broadcast is a no-op in tests (WP functions stubbed).
|
||||
Functions\when( "__" )->alias( static fn( $s ) => $s );
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
\PRAutoBlogger_Audit_Writer::flush_cache();
|
||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||
unset( $GLOBALS["wpdb"] );
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ── Approval on first round ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* When the editor approves on round 1, the loop exits immediately and
|
||||
* the original content is returned unchanged.
|
||||
*/
|
||||
public function test_approved_on_first_round_returns_content(): void {
|
||||
$loop = $this->make_loop_with_results( array( "approved" ) );
|
||||
$content = "<p>Draft content.</p>";
|
||||
$idea = $this->make_idea();
|
||||
|
||||
$result = $loop->run( "run-1", "idea:abc", $content, $idea, $this->make_cost_tracker() );
|
||||
|
||||
$this->assertSame( $content, $result );
|
||||
$this->assertFalse( $loop->was_escalated() );
|
||||
$this->assertCount( 1, $loop->get_rounds() );
|
||||
$this->assertSame( "approved", $loop->get_rounds()[0]->get_editor_verdict() );
|
||||
}
|
||||
|
||||
// ── Max-rounds bound ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The loop never exceeds `editorial_max_rounds` regardless of verdict.
|
||||
* After max rounds without approval, was_escalated() is true and "" returned.
|
||||
*/
|
||||
public function test_escalation_after_max_rounds(): void {
|
||||
// All 3 rounds return "revised" → escalation.
|
||||
$loop = $this->make_loop_with_results( array( "revised", "revised", "revised" ) );
|
||||
$idea = $this->make_idea();
|
||||
|
||||
$result = $loop->run( "run-2", "idea:abc", "<p>Draft.</p>", $idea, $this->make_cost_tracker() );
|
||||
|
||||
$this->assertSame( "", $result );
|
||||
$this->assertTrue( $loop->was_escalated() );
|
||||
$this->assertCount( 3, $loop->get_rounds() );
|
||||
}
|
||||
|
||||
/**
|
||||
* If max_rounds = 1 (floor) and editor rejects, escalation happens after
|
||||
* exactly 1 round.
|
||||
*/
|
||||
public function test_single_round_floor_escalates_after_one_round(): void {
|
||||
$this->stub_get_option( array(
|
||||
"prautoblogger_log_level" => "error",
|
||||
"prautoblogger_editorial_max_rounds" => 1,
|
||||
"prautoblogger_writing_model" => "openai/gpt-4o-mini",
|
||||
) );
|
||||
|
||||
$loop = $this->make_loop_with_results( array( "rejected" ) );
|
||||
$result = $loop->run( "run-3", "idea:abc", "<p>Draft.</p>", $this->make_idea(), $this->make_cost_tracker() );
|
||||
|
||||
$this->assertSame( "", $result );
|
||||
$this->assertTrue( $loop->was_escalated() );
|
||||
$this->assertCount( 1, $loop->get_rounds() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuring max_rounds above MAX_ROUNDS cap (10) is silently clamped to 10.
|
||||
* The loop still exits once approved within the cap.
|
||||
*/
|
||||
public function test_max_rounds_clamped_to_cap(): void {
|
||||
$this->stub_get_option( array(
|
||||
"prautoblogger_log_level" => "error",
|
||||
"prautoblogger_editorial_max_rounds" => 999,
|
||||
"prautoblogger_writing_model" => "openai/gpt-4o-mini",
|
||||
) );
|
||||
|
||||
// Approve on round 2 — verifies the loop runs at least 2 rounds before approving.
|
||||
$loop = $this->make_loop_with_results( array( "revised", "approved" ) );
|
||||
$result = $loop->run( "run-4", "idea:abc", "<p>Draft.</p>", $this->make_idea(), $this->make_cost_tracker() );
|
||||
|
||||
$this->assertNotSame( "", $result );
|
||||
$this->assertFalse( $loop->was_escalated() );
|
||||
$this->assertCount( 2, $loop->get_rounds() );
|
||||
}
|
||||
|
||||
// ── Round recording ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Every non-escalation round is recorded to run_decisions with the
|
||||
* editor verdict and round number in the rationale.
|
||||
*/
|
||||
public function test_each_round_recorded_to_run_decisions(): void {
|
||||
$loop = $this->make_loop_with_results( array( "revised", "approved" ) );
|
||||
$loop->run( "run-5", "idea:abc", "<p>Draft.</p>", $this->make_idea(), $this->make_cost_tracker() );
|
||||
|
||||
$decision_rows = array_filter(
|
||||
$this->decision_rows,
|
||||
static fn( $r ) => isset( $r["stage"] ) && "editorial" === $r["stage"]
|
||||
);
|
||||
|
||||
// Two rounds → two decision rows.
|
||||
$this->assertCount( 2, array_values( $decision_rows ) );
|
||||
|
||||
$rows = array_values( $decision_rows );
|
||||
$this->assertSame( "revised", $rows[0]["verdict"] );
|
||||
$this->assertStringStartsWith( "Round 1:", $rows[0]["rationale"] );
|
||||
|
||||
$this->assertSame( "approved", $rows[1]["verdict"] );
|
||||
$this->assertStringStartsWith( "Round 2:", $rows[1]["rationale"] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Escalation is recorded as a single run_decisions row with verdict="escalated".
|
||||
*/
|
||||
public function test_escalation_recorded_as_single_decision_row(): void {
|
||||
$loop = $this->make_loop_with_results( array( "revised", "revised", "revised" ) );
|
||||
$loop->run( "run-6", "idea:abc", "<p>Draft.</p>", $this->make_idea(), $this->make_cost_tracker() );
|
||||
|
||||
$escalation_rows = array_filter(
|
||||
$this->decision_rows,
|
||||
static fn( $r ) => isset( $r["verdict"] ) && "escalated" === $r["verdict"]
|
||||
);
|
||||
|
||||
$this->assertCount( 1, $escalation_rows );
|
||||
}
|
||||
|
||||
// ── Inline revision path ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* When the editor returns verdict="revised" WITH revised_content in the
|
||||
* review object, that inline content is used without calling the writer LLM.
|
||||
*/
|
||||
public function test_inline_revised_content_is_used_without_writer_call(): void {
|
||||
$inline_revision = "<p>Inline revised.</p>";
|
||||
$editor = $this->make_editor_mock( array( "revised" ), $inline_revision );
|
||||
$revision_caller = $this->make_revision_caller_mock();
|
||||
|
||||
// Revision caller should NOT be called because editor provided inline content.
|
||||
$revision_caller->expects( $this->never() )->method( "call" );
|
||||
|
||||
$loop = new \PRAutoBlogger_Editorial_Loop( $editor, $revision_caller );
|
||||
// Run with max_rounds=1 so it escalates after 1 "revised" without approval.
|
||||
$this->stub_get_option( array(
|
||||
"prautoblogger_log_level" => "error",
|
||||
"prautoblogger_editorial_max_rounds" => 1,
|
||||
"prautoblogger_writing_model" => "openai/gpt-4o-mini",
|
||||
) );
|
||||
|
||||
$loop->run( "run-7", "idea:abc", "<p>Draft.</p>", $this->make_idea(), $this->make_cost_tracker() );
|
||||
|
||||
// Escalated (no approval in 1 round), but revision caller was never invoked.
|
||||
$this->assertTrue( $loop->was_escalated() );
|
||||
}
|
||||
|
||||
// ── Writer revision path ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* When the editor returns "rejected" (no inline revision), the revision
|
||||
* caller is invoked and its output becomes the next round content.
|
||||
*/
|
||||
public function test_writer_revision_caller_invoked_on_rejected_verdict(): void {
|
||||
$revised_html = "<p>Writer revised.</p>";
|
||||
$editor = $this->make_editor_mock( array( "rejected", "approved" ) );
|
||||
$revision_caller = $this->make_revision_caller_mock();
|
||||
|
||||
$revision_caller->expects( $this->once() )
|
||||
->method( "call" )
|
||||
->willReturn( $revised_html );
|
||||
|
||||
$loop = new \PRAutoBlogger_Editorial_Loop( $editor, $revision_caller );
|
||||
$result = $loop->run( "run-8", "idea:abc", "<p>Draft.</p>", $this->make_idea(), $this->make_cost_tracker() );
|
||||
|
||||
// Second round approved → returns revised HTML.
|
||||
$this->assertSame( $revised_html, $result );
|
||||
$this->assertFalse( $loop->was_escalated() );
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build an Editorial_Loop whose editor returns verdicts in sequence.
|
||||
*
|
||||
* @param string[] $verdicts Sequence of editor verdicts (per round).
|
||||
*/
|
||||
private function make_loop_with_results( array $verdicts ): \PRAutoBlogger_Editorial_Loop {
|
||||
$editor = $this->make_editor_mock( $verdicts );
|
||||
$revision_caller = $this->make_revision_caller_stub();
|
||||
return new \PRAutoBlogger_Editorial_Loop( $editor, $revision_caller );
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Chief_Editor mock returning the given sequence of verdicts.
|
||||
*
|
||||
* @param string[] $verdicts Verdicts per successive review() call.
|
||||
* @param string $inline_rev Optional inline revised_content for "revised" verdicts.
|
||||
*/
|
||||
private function make_editor_mock( array $verdicts, string $inline_rev = "" ): object {
|
||||
$editor = $this->getMockBuilder( \PRAutoBlogger_Chief_Editor::class )
|
||||
->disableOriginalConstructor()
|
||||
->getMock();
|
||||
|
||||
$call = 0;
|
||||
$editor->method( "review" )->willReturnCallback(
|
||||
function () use ( &$call, $verdicts, $inline_rev ) {
|
||||
$verdict = $verdicts[ $call ] ?? "revised";
|
||||
$call++;
|
||||
return new \PRAutoBlogger_Editorial_Review( array(
|
||||
"verdict" => $verdict,
|
||||
"notes" => "Editor notes round {$call}.",
|
||||
"revised_content" => ( "revised" === $verdict && "" !== $inline_rev ) ? $inline_rev : null,
|
||||
"quality_score" => 0.7,
|
||||
"seo_score" => 0.75,
|
||||
"issues" => array(),
|
||||
) );
|
||||
}
|
||||
);
|
||||
|
||||
return $editor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an Editorial_Revision_Caller stub that returns fixed content.
|
||||
*
|
||||
* Uses onlyMethods() so PHPUnit knows "call" is a real method to mock.
|
||||
*/
|
||||
private function make_revision_caller_stub(): object {
|
||||
$caller = $this->getMockBuilder( \PRAutoBlogger_Editorial_Revision_Caller::class )
|
||||
->disableOriginalConstructor()
|
||||
->onlyMethods( array( "call" ) )
|
||||
->getMock();
|
||||
$caller->method( "call" )->willReturn( "<p>Revised.</p>" );
|
||||
return $caller;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an Editorial_Revision_Caller mock with no pre-set behaviour.
|
||||
*/
|
||||
private function make_revision_caller_mock(): object {
|
||||
return $this->getMockBuilder( \PRAutoBlogger_Editorial_Revision_Caller::class )
|
||||
->disableOriginalConstructor()
|
||||
->onlyMethods( array( "call" ) )
|
||||
->getMock();
|
||||
}
|
||||
|
||||
/** Create a minimal article idea stub. */
|
||||
private function make_idea(): \PRAutoBlogger_Article_Idea {
|
||||
return new \PRAutoBlogger_Article_Idea( $this->get_article_idea_fixture() );
|
||||
}
|
||||
|
||||
/** Create a minimal cost tracker stub (void methods stubbed). */
|
||||
private function make_cost_tracker(): object {
|
||||
$tracker = $this->getMockBuilder( \PRAutoBlogger_Cost_Tracker::class )
|
||||
->disableOriginalConstructor()
|
||||
->getMock();
|
||||
return $tracker;
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Post_Assembler::attach_generated_images() imagery-suppression gate.
|
||||
*
|
||||
* What: Verifies that the _prautoblogger_imagery_suppressed post meta flag
|
||||
* causes attach_generated_images() to return early without touching the
|
||||
* image pipeline, and that clearing the flag lets the method proceed.
|
||||
* Dependencies: Brain\Monkey (stubs WordPress functions), PRAutoBlogger_Logger stub.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class PostAssemblerImageryTest extends BaseTestCase {
|
||||
|
||||
/** @var \PHPUnit\Framework\MockObject\MockObject&object Mock Article_Idea. */
|
||||
private object $idea;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
// Logger::instance()->info() reads log level from wp_options — stub it.
|
||||
$this->stub_get_option( array(
|
||||
'prautoblogger_log_level' => 'info',
|
||||
) );
|
||||
|
||||
// Minimal Article_Idea mock — get_source_ids() is needed only for the non-suppressed path.
|
||||
$this->idea = $this->getMockBuilder( \PRAutoBlogger_Article_Idea::class )
|
||||
->disableOriginalConstructor()
|
||||
->onlyMethods( array( 'get_source_ids' ) )
|
||||
->getMock();
|
||||
}
|
||||
|
||||
/**
|
||||
* When _prautoblogger_imagery_suppressed = '1', the method returns without
|
||||
* reaching the Source_Collector or Image_Pipeline.
|
||||
* The get_source_ids() mock has no expectation — if it were called, PHPUnit
|
||||
* would throw (unexpected call). Reaching assertTrue confirms early return.
|
||||
*/
|
||||
public function test_imagery_suppressed_flag_causes_early_return(): void {
|
||||
Functions\when( 'get_post_meta' )
|
||||
->alias( static function ( int $post_id, string $key, bool $single ) {
|
||||
if ( 99 === $post_id && '_prautoblogger_imagery_suppressed' === $key && $single ) {
|
||||
return '1';
|
||||
}
|
||||
return '';
|
||||
} );
|
||||
|
||||
// current_time is called by Logger — already stubbed in BaseTestCase.
|
||||
// No expectation on get_source_ids — if called, that means the guard failed.
|
||||
\PRAutoBlogger_Post_Assembler::attach_generated_images( 99, $this->idea, array(), null );
|
||||
|
||||
$this->assertTrue( true, 'Method returned early when imagery_suppressed = 1' );
|
||||
}
|
||||
|
||||
/**
|
||||
* When _prautoblogger_imagery_suppressed is empty the method proceeds past
|
||||
* the guard and calls get_source_ids() before attempting Source_Collector.
|
||||
* We assert the guard did not fire by verifying get_source_ids() is reached.
|
||||
*/
|
||||
public function test_no_suppression_flag_proceeds_past_guard(): void {
|
||||
Functions\when( 'get_post_meta' )
|
||||
->alias( static function ( int $post_id, string $key, bool $single ) {
|
||||
return ''; // Flag not set.
|
||||
} );
|
||||
|
||||
$this->idea->expects( $this->once() )
|
||||
->method( 'get_source_ids' )
|
||||
->willReturn( array() );
|
||||
|
||||
// Source_Collector::get_source_data_for_image will attempt a DB call — let it throw.
|
||||
// We only care that get_source_ids() was called (guard passed).
|
||||
try {
|
||||
\PRAutoBlogger_Post_Assembler::attach_generated_images( 99, $this->idea, array(), null );
|
||||
} catch ( \Throwable $e ) {
|
||||
// Expected: DB/class not available in unit context.
|
||||
}
|
||||
|
||||
// PHPUnit verifies the ->once() expectation on mock teardown.
|
||||
$this->assertTrue( true, 'Guard was not triggered; method proceeded past suppression check.' );
|
||||
}
|
||||
}
|
||||
@@ -50,10 +50,6 @@ class PublisherSideEffectsTest extends BaseTestCase {
|
||||
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 );
|
||||
// P2-2: get_post_meta is called by Post_Assembler::attach_generated_images()
|
||||
// to check the imagery-suppression flag. Return '' so imagery is not skipped
|
||||
// in Publisher-level tests (image pipeline errors are caught downstream).
|
||||
Functions\when( 'get_post_meta' )->justReturn( '' );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -46,10 +46,6 @@ class PublisherTest extends BaseTestCase {
|
||||
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 );
|
||||
// P2-2: get_post_meta is called by Post_Assembler::attach_generated_images()
|
||||
// to check the imagery-suppression flag. Return '' so imagery is not skipped
|
||||
// in Publisher-level tests (image pipeline errors are caught downstream).
|
||||
Functions\when( 'get_post_meta' )->justReturn( '' );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -203,65 +199,4 @@ class PublisherTest extends BaseTestCase {
|
||||
|
||||
$publisher->publish( "<p> \n\t </p>", $this->idea, $this->review );
|
||||
}
|
||||
/**
|
||||
* P2 fix (v0.32.2): Publisher::publish() called without an explicit
|
||||
* pipeline_mode must record the option value (Economy / single-pass
|
||||
* path). Verifies the fallback behaviour is unchanged.
|
||||
*/
|
||||
public function test_publish_pipeline_mode_defaults_to_option_value(): void {
|
||||
$this->stub_get_option( [
|
||||
'prautoblogger_writing_pipeline' => 'single_pass',
|
||||
'prautoblogger_writing_model' => 'openai/gpt-4o-mini',
|
||||
'prautoblogger_default_author' => 1,
|
||||
] );
|
||||
|
||||
$captured_meta = null;
|
||||
Functions\when( 'wp_insert_post' )->alias( function ( $args ) use ( &$captured_meta ) {
|
||||
$captured_meta = $args['meta_input'] ?? [];
|
||||
return 99;
|
||||
} );
|
||||
|
||||
$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, 'run-economy' );
|
||||
|
||||
$this->assertArrayHasKey( '_prautoblogger_pipeline_mode', $captured_meta );
|
||||
$this->assertSame( 'single_pass', $captured_meta['_prautoblogger_pipeline_mode'],
|
||||
'Economy path (no explicit pipeline_mode) must use the option value' );
|
||||
}
|
||||
|
||||
/**
|
||||
* P2 fix (v0.32.2): when an explicit pipeline_mode is passed (Authority
|
||||
* path), it overrides the option value and is stored as post meta.
|
||||
*/
|
||||
public function test_publish_explicit_pipeline_mode_overrides_option(): void {
|
||||
$this->stub_get_option( [
|
||||
'prautoblogger_writing_pipeline' => 'single_pass',
|
||||
'prautoblogger_writing_model' => 'openai/gpt-4o-mini',
|
||||
'prautoblogger_default_author' => 1,
|
||||
] );
|
||||
|
||||
$captured_meta = null;
|
||||
Functions\when( 'wp_insert_post' )->alias( function ( $args ) use ( &$captured_meta ) {
|
||||
$captured_meta = $args['meta_input'] ?? [];
|
||||
return 100;
|
||||
} );
|
||||
|
||||
$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, 'run-authority', null, 'authority' );
|
||||
|
||||
$this->assertArrayHasKey( '_prautoblogger_pipeline_mode', $captured_meta );
|
||||
$this->assertSame( 'authority', $captured_meta['_prautoblogger_pipeline_mode'],
|
||||
'Explicit pipeline_mode must override the option (Authority path)' );
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Research_Fanout.
|
||||
*
|
||||
* Covers: quorum logic, batch cost-reserve wiring, partial-failure handling.
|
||||
* The curl_multi execution is delegated to PRAutoBlogger_Research_Batch;
|
||||
* these tests inject a stub batch to isolate the fanout orchestration logic.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class ResearchFanoutTest extends BaseTestCase {
|
||||
|
||||
/** @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb. */
|
||||
private $wpdb;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->wpdb = $this->create_mock_wpdb();
|
||||
$GLOBALS['wpdb'] = $this->wpdb;
|
||||
|
||||
$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();
|
||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||
|
||||
$this->stub_get_option(
|
||||
array(
|
||||
'prautoblogger_per_run_cost_ceiling_usd' => 0.50,
|
||||
'prautoblogger_research_agent_count' => 3,
|
||||
'prautoblogger_research_model' => 'google/gemini-2.5-flash-lite',
|
||||
'prautoblogger_log_level' => 'error',
|
||||
)
|
||||
);
|
||||
|
||||
Functions\when( 'wp_json_encode' )->alias( 'json_encode' );
|
||||
Functions\when( 'sanitize_url' )->alias( static fn( $u ) => $u );
|
||||
Functions\when( 'sanitize_text_field' )->alias( 'trim' );
|
||||
Functions\when( 'sanitize_textarea_field' )->alias( 'trim' );
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
\PRAutoBlogger_Run_Context::clear();
|
||||
\PRAutoBlogger_Run_State::flush_cache();
|
||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||
unset( $GLOBALS['wpdb'] );
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ── Quorum logic ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* With N=3, quorum = ceil(3/2)+1 = 3. All 3 agents succeed → results returned.
|
||||
*/
|
||||
public function test_all_agents_succeed_meets_quorum(): void {
|
||||
$batch = $this->make_stub_batch( $this->make_valid_raw_results( 3 ) );
|
||||
$fanout = $this->make_fanout( $batch );
|
||||
|
||||
$results = $fanout->dispatch( 'run-1', 'idea:abc', $this->make_idea(), $this->make_cost_tracker() );
|
||||
|
||||
$this->assertCount( 3, $results );
|
||||
foreach ( $results as $r ) {
|
||||
$this->assertArrayHasKey( 'sources', $r );
|
||||
$this->assertArrayHasKey( 'agent_role', $r );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* N=3, quorum=3. Only 2 agents succeed (one fails) → quorum NOT met → empty.
|
||||
*/
|
||||
public function test_partial_failure_below_quorum_returns_empty(): void {
|
||||
$raw = $this->make_valid_raw_results( 3 );
|
||||
$raw[2] = array( 'error' => 'timeout' ); // Slot 2 fails.
|
||||
$batch = $this->make_stub_batch( $raw );
|
||||
$fanout = $this->make_fanout( $batch );
|
||||
|
||||
$results = $fanout->dispatch( 'run-2', 'idea:abc', $this->make_idea(), $this->make_cost_tracker() );
|
||||
|
||||
$this->assertEmpty( $results, 'Quorum not met: should return empty array.' );
|
||||
}
|
||||
|
||||
/**
|
||||
* N=3, quorum=3. Agent returns invalid JSON schema → excluded → empty.
|
||||
*/
|
||||
public function test_invalid_schema_agent_excluded(): void {
|
||||
$raw = $this->make_valid_raw_results( 3 );
|
||||
$raw[0] = array( 'content' => '{"bad":"schema"}', 'actual_cost' => 0.001 );
|
||||
$raw[1] = array( 'error' => 'bad' );
|
||||
$raw[2] = array( 'error' => 'bad' );
|
||||
$batch = $this->make_stub_batch( $raw );
|
||||
$fanout = $this->make_fanout( $batch );
|
||||
|
||||
$results = $fanout->dispatch( 'run-3', 'idea:abc', $this->make_idea(), $this->make_cost_tracker() );
|
||||
|
||||
$this->assertEmpty( $results );
|
||||
}
|
||||
|
||||
/**
|
||||
* N=5, quorum = ceil(5/2)+1 = 4. 4 agents succeed, 1 fails → quorum met.
|
||||
*/
|
||||
public function test_quorum_formula_for_n_5(): void {
|
||||
$this->stub_get_option(
|
||||
array(
|
||||
'prautoblogger_per_run_cost_ceiling_usd' => 0.50,
|
||||
'prautoblogger_research_agent_count' => 5,
|
||||
'prautoblogger_research_model' => 'google/gemini-2.5-flash-lite',
|
||||
'prautoblogger_log_level' => 'error',
|
||||
)
|
||||
);
|
||||
|
||||
$raw = $this->make_valid_raw_results( 5 );
|
||||
$raw[4] = array( 'error' => 'failed' ); // 4 succeed, 1 fails: 4 >= 4 → quorum met.
|
||||
$batch = $this->make_stub_batch( $raw );
|
||||
$fanout = $this->make_fanout( $batch );
|
||||
|
||||
$results = $fanout->dispatch( 'run-4', 'idea:abc', $this->make_idea(), $this->make_cost_tracker() );
|
||||
|
||||
$this->assertCount( 4, $results );
|
||||
}
|
||||
|
||||
// ── Cost reserve wiring ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Cost governor open_amount_reservation is NOT called when there is
|
||||
* no active run context (ungoverned behavior unchanged).
|
||||
*/
|
||||
public function test_no_run_context_ungoverned(): void {
|
||||
// No run context set → Cost_Governor::open_amount_reservation returns null.
|
||||
$batch = $this->make_stub_batch( $this->make_valid_raw_results( 3 ) );
|
||||
$fanout = $this->make_fanout( $batch );
|
||||
|
||||
// Should not throw — just proceeds ungoverned.
|
||||
$results = $fanout->dispatch( 'run-5', 'idea:abc', $this->make_idea(), $this->make_cost_tracker() );
|
||||
$this->assertCount( 3, $results );
|
||||
}
|
||||
|
||||
/**
|
||||
* When open_amount_reservation() throws PRAutoBlogger_Cost_Ceiling_Exception,
|
||||
* batch->execute() must NEVER be called — cost governance aborts before dispatch.
|
||||
*/
|
||||
public function test_ceiling_breach_exception_aborts_before_dispatch(): void {
|
||||
$batch = $this->getMockBuilder( \PRAutoBlogger_Research_Batch::class )
|
||||
->disableOriginalConstructor()
|
||||
->onlyMethods( array( 'execute' ) )
|
||||
->getMock();
|
||||
|
||||
// PHPUnit enforces this: execute() must NEVER be called when ceiling is breached.
|
||||
$batch->expects( $this->never() )->method( 'execute' );
|
||||
|
||||
// ceiling_usd=0.0: any reservation attempt breaches → UPDATE affects 0 rows → exception.
|
||||
// get_var must return the table name so Run_State::is_available() returns true;
|
||||
// without this, open_amount_reservation() short-circuits (returns null, no throw).
|
||||
$this->wpdb->method( 'get_var' )->willReturnCallback(
|
||||
static function ( $sql ) {
|
||||
// Run_State::table_name() = 'wp_prautoblogger_runs' (not run_states).
|
||||
// is_available() does SHOW TABLES LIKE %s where %s = wp_prautoblogger_runs.
|
||||
if ( false !== strpos( (string) $sql, 'prautoblogger_runs' ) ) {
|
||||
return 'wp_prautoblogger_runs';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
// ceiling_usd must be > 0; 0.0 means 'ceiling disabled' (governor returns null, no throw).
|
||||
// Set ceiling=0.01, already reserved=0.01 (at limit) so any additional reserve
|
||||
// fails — query() returns 0 rows affected → on_breach() → throws.
|
||||
// get_row() is called with ARRAY_A mode; get_run() does is_array() check.
|
||||
// Return an array (not an object) so get_run() returns the row instead of null.
|
||||
// ceiling=0.01, reserved=0.01 → at limit; any further reserve fails (query→0).
|
||||
$this->wpdb->method( 'get_row' )->willReturn( array(
|
||||
'run_id' => 'run-ceiling',
|
||||
'ceiling_usd' => 0.01,
|
||||
'reserved_usd' => 0.01,
|
||||
'settled_usd' => 0.0,
|
||||
'status' => 'running',
|
||||
) );
|
||||
$this->wpdb->method( 'query' )->willReturn( 0 );
|
||||
|
||||
\PRAutoBlogger_Run_Context::set_run_id( 'run-ceiling' );
|
||||
|
||||
$fanout = $this->make_fanout( $batch );
|
||||
|
||||
$this->expectException( \PRAutoBlogger_Cost_Ceiling_Exception::class );
|
||||
$fanout->dispatch( 'run-ceiling', 'idea:abc', $this->make_idea(), $this->make_cost_tracker() );
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a stub Research_Batch that returns pre-canned raw results.
|
||||
*
|
||||
* @param array $raw_results
|
||||
* @return \PHPUnit\Framework\MockObject\MockObject
|
||||
*/
|
||||
private function make_stub_batch( array $raw_results ) {
|
||||
$stub = $this->getMockBuilder( \PRAutoBlogger_Research_Batch::class )
|
||||
->disableOriginalConstructor()
|
||||
->onlyMethods( array( 'execute' ) )
|
||||
->getMock();
|
||||
$stub->method( 'execute' )->willReturn( $raw_results );
|
||||
return $stub;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fanout with an injected batch stub and a stubbed cost tracker.
|
||||
*
|
||||
* @param object $batch
|
||||
* @return \PRAutoBlogger_Research_Fanout
|
||||
*/
|
||||
private function make_fanout( $batch ): \PRAutoBlogger_Research_Fanout {
|
||||
// NOTE: do NOT re-stub get_option here. setUp() and individual tests call
|
||||
// stub_get_option() with the correct agent_count for that test. Re-stubbing
|
||||
// here with hardcoded agent_count=3 silently overrides the test's value and
|
||||
// caused test_quorum_formula_for_n_5 to dispatch 3 agents against 5 results.
|
||||
return new \PRAutoBlogger_Research_Fanout( $batch );
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate N valid raw results (the shape Research_Batch returns).
|
||||
*
|
||||
* @param int $n
|
||||
* @return array
|
||||
*/
|
||||
private function make_valid_raw_results( int $n ): array {
|
||||
$results = array();
|
||||
for ( $i = 0; $i < $n; $i++ ) {
|
||||
$results[] = array(
|
||||
'content' => json_encode( array(
|
||||
'sources' => array(
|
||||
array( 'url' => "https://example.com/source-$i", 'title' => "Source $i", 'excerpt' => 'Test', 'relevance' => 0.8 ),
|
||||
),
|
||||
) ),
|
||||
'model' => 'google/gemini-2.5-flash-lite',
|
||||
'prompt_tokens' => 100,
|
||||
'completion_tokens' => 200,
|
||||
'actual_cost' => 0.001,
|
||||
);
|
||||
}
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \PRAutoBlogger_Article_Idea
|
||||
*/
|
||||
private function make_idea(): \PRAutoBlogger_Article_Idea {
|
||||
return new \PRAutoBlogger_Article_Idea( array(
|
||||
'topic' => 'BPC-157 peptide research',
|
||||
'article_type' => 'guide',
|
||||
'suggested_title' => 'BPC-157: Research Overview',
|
||||
'summary' => 'Test.',
|
||||
'score' => 0.9,
|
||||
'source_ids' => array(),
|
||||
'key_points' => array(),
|
||||
'target_keywords' => array(),
|
||||
) );
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \PRAutoBlogger_Cost_Tracker
|
||||
*/
|
||||
private function make_cost_tracker(): \PRAutoBlogger_Cost_Tracker {
|
||||
$ct = $this->getMockBuilder( \PRAutoBlogger_Cost_Tracker::class )
|
||||
->disableOriginalConstructor()
|
||||
->onlyMethods( array( 'log_api_call', 'is_budget_exceeded' ) )
|
||||
->getMock();
|
||||
$ct->method( 'log_api_call' ); // void: no return value
|
||||
$ct->method( 'is_budget_exceeded' )->willReturn( false );
|
||||
return $ct;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Research_Judge (curate stage).
|
||||
*
|
||||
* Covers: dedup/keep-discard logic, quality_score computation, run_sources
|
||||
* DB writes (keep=1 / keep=0), and graceful degradation when audit tables
|
||||
* are absent.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class ResearchJudgeTest extends BaseTestCase {
|
||||
|
||||
/** @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb. */
|
||||
private $wpdb;
|
||||
|
||||
/** @var array<string, array<int, array>> Rows inserted, keyed by table name suffix (e.g. 'run_sources'). */
|
||||
private array $inserted_rows = array();
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->inserted_rows = array();
|
||||
$this->wpdb = $this->create_mock_wpdb();
|
||||
$GLOBALS['wpdb'] = $this->wpdb;
|
||||
|
||||
$this->wpdb->prefix = 'wp_';
|
||||
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
||||
static function ( $sql, ...$args ) {
|
||||
return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */';
|
||||
}
|
||||
);
|
||||
|
||||
// Capture insert calls keyed by table name suffix (strip prefix) so tests
|
||||
// can distinguish run_sources rows from Logger event_log rows, etc.
|
||||
$test = $this;
|
||||
$this->wpdb->method( 'insert' )->willReturnCallback(
|
||||
function ( $table, $data ) use ( $test ) {
|
||||
// Strip the 'wp_prautoblogger_' prefix to get a short key.
|
||||
$key = preg_replace( '/^wp_prautoblogger_/', '', (string) $table );
|
||||
$test->inserted_rows[ $key ][] = $data;
|
||||
return 1;
|
||||
}
|
||||
);
|
||||
|
||||
\PRAutoBlogger_Audit_Writer::flush_cache();
|
||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||
|
||||
Functions\when( 'wp_json_encode' )->alias( 'json_encode' );
|
||||
Functions\when( 'wp_parse_url' )->alias( 'parse_url' );
|
||||
Functions\when( 'current_time' )->justReturn( '2026-06-23 00:00:00' );
|
||||
Functions\when( 'get_option' )->justReturn( 'error' );
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
\PRAutoBlogger_Audit_Writer::flush_cache();
|
||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||
unset( $GLOBALS['wpdb'] );
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ── Keep / discard ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* All unique sources with quality > 0 are kept when under MAX_KEPT_SOURCES.
|
||||
*/
|
||||
public function test_all_unique_sources_kept_under_max(): void {
|
||||
$judge = $this->make_judge_with_available_tables();
|
||||
$fanout = $this->make_fanout_results( 3, 0 );
|
||||
$kept = $judge->curate( 'run-1', 'idea:abc', $fanout );
|
||||
|
||||
$this->assertCount( 3, $kept );
|
||||
foreach ( $kept as $s ) {
|
||||
$this->assertArrayHasKey( 'quality_score', $s );
|
||||
$this->assertGreaterThan( 0.0, $s['quality_score'] );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact-URL duplicates across agents are deduplicated to one entry.
|
||||
*/
|
||||
public function test_url_duplicates_are_deduplicated(): void {
|
||||
$fanout = array(
|
||||
array(
|
||||
'agent_role' => 'researcher:mechanisms',
|
||||
'sources' => array(
|
||||
array( 'url' => 'https://example.com/a', 'title' => 'A', 'excerpt' => 'ex', 'relevance' => 0.8 ),
|
||||
),
|
||||
),
|
||||
array(
|
||||
'agent_role' => 'researcher:clinical',
|
||||
'sources' => array(
|
||||
array( 'url' => 'https://example.com/a', 'title' => 'A dup', 'excerpt' => 'ex2', 'relevance' => 0.9 ),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
$judge = $this->make_judge_with_available_tables();
|
||||
$kept = $judge->curate( 'run-2', 'idea:abc', $fanout );
|
||||
|
||||
$this->assertCount( 1, $kept );
|
||||
}
|
||||
|
||||
/**
|
||||
* Sources beyond MAX_KEPT_SOURCES are written to run_sources with kept=0.
|
||||
*/
|
||||
public function test_sources_over_max_written_as_discarded(): void {
|
||||
// Generate 14 distinct sources (max is 12 → 2 discarded).
|
||||
$judge = $this->make_judge_with_available_tables();
|
||||
$fanout = $this->make_fanout_results( 14, 0 );
|
||||
$kept = $judge->curate( 'run-3', 'idea:abc', $fanout );
|
||||
|
||||
$this->assertCount( 12, $kept );
|
||||
|
||||
// Only examine run_sources rows (keyed by table); Logger rows are separate.
|
||||
$run_source_rows = $this->inserted_rows['run_sources'] ?? array();
|
||||
$discarded_rows = array_filter( $run_source_rows, static fn( $r ) => 0 === (int) $r['kept'] );
|
||||
$this->assertCount( 2, array_values( $discarded_rows ) );
|
||||
}
|
||||
|
||||
// ── Quality scoring ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A PubMed URL should receive a higher quality_score than a plain HTTPS URL.
|
||||
*/
|
||||
public function test_doi_url_scores_higher_than_generic_https(): void {
|
||||
$fanout = array(
|
||||
array(
|
||||
'agent_role' => 'researcher:mechanisms',
|
||||
'sources' => array(
|
||||
array( 'url' => 'https://doi.org/10.1234/abc', 'title' => 'DOI', 'excerpt' => 'test', 'relevance' => 0.8 ),
|
||||
array( 'url' => 'https://example.com/article', 'title' => 'Generic', 'excerpt' => 'test', 'relevance' => 0.8 ),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
$judge = $this->make_judge_with_available_tables();
|
||||
$kept = $judge->curate( 'run-4', 'idea:abc', $fanout );
|
||||
|
||||
$this->assertCount( 2, $kept );
|
||||
// DOI should be first (sorted descending by quality_score).
|
||||
$this->assertStringContainsString( 'doi.org', $kept[0]['url'] );
|
||||
$this->assertGreaterThan( $kept[1]['quality_score'], $kept[0]['quality_score'] );
|
||||
}
|
||||
|
||||
// ── run_sources writes ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Each kept source is inserted with kept=1 and a non-empty reason.
|
||||
*/
|
||||
public function test_kept_sources_written_with_kept_1(): void {
|
||||
$judge = $this->make_judge_with_available_tables();
|
||||
$judge->curate( 'run-5', 'idea:abc', $this->make_fanout_results( 2, 0 ) );
|
||||
|
||||
// Only examine run_sources rows; Logger rows have no 'kept' key.
|
||||
$run_source_rows = $this->inserted_rows['run_sources'] ?? array();
|
||||
$kept_rows = array_filter( $run_source_rows, static fn( $r ) => 1 === (int) $r['kept'] );
|
||||
$this->assertCount( 2, array_values( $kept_rows ) );
|
||||
|
||||
foreach ( $kept_rows as $row ) {
|
||||
$this->assertNotEmpty( $row['reason'] );
|
||||
$this->assertGreaterThanOrEqual( 0.0, $row['quality_score'] );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op when the audit tables are absent (is_available() returns false).
|
||||
*/
|
||||
public function test_no_db_writes_when_tables_absent(): void {
|
||||
$judge = $this->make_judge_without_tables();
|
||||
$fanout = $this->make_fanout_results( 3, 0 );
|
||||
$kept = $judge->curate( 'run-6', 'idea:abc', $fanout );
|
||||
|
||||
$this->assertCount( 3, $kept );
|
||||
// When audit tables are absent, run_sources and run_decisions must be empty.
|
||||
// The Logger may still write to event_log (wpdb->insert check skips only when
|
||||
// $wpdb is null) — that is expected behaviour and NOT tested here.
|
||||
$this->assertEmpty( $this->inserted_rows['run_sources'] ?? array() );
|
||||
$this->assertEmpty( $this->inserted_rows['run_decisions'] ?? array() );
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a judge where audit tables are reported as available.
|
||||
*/
|
||||
private function make_judge_with_available_tables(): \PRAutoBlogger_Research_Judge {
|
||||
// is_available() probes with SHOW TABLES LIKE — return the table name.
|
||||
$this->wpdb->method( 'get_var' )->willReturnCallback(
|
||||
function ( $sql ) {
|
||||
if ( false !== strpos( (string) $sql, 'run_decisions' ) ) {
|
||||
return 'wp_prautoblogger_run_decisions';
|
||||
}
|
||||
if ( false !== strpos( (string) $sql, 'run_stages' ) ) {
|
||||
return 'wp_prautoblogger_run_stages';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
return new \PRAutoBlogger_Research_Judge();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a judge where audit tables are absent (is_available() → false).
|
||||
*/
|
||||
private function make_judge_without_tables(): \PRAutoBlogger_Research_Judge {
|
||||
$this->wpdb->method( 'get_var' )->willReturn( null );
|
||||
return new \PRAutoBlogger_Research_Judge();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build fanout_results with $count unique sources and $dup_count duplicates.
|
||||
*
|
||||
* @param int $count Number of unique sources across all agents.
|
||||
* @param int $dup_count Number of duplicate (same-URL) sources to add.
|
||||
* @return array
|
||||
*/
|
||||
private function make_fanout_results( int $count, int $dup_count ): array {
|
||||
$sources = array();
|
||||
for ( $i = 0; $i < $count; $i++ ) {
|
||||
$sources[] = array( 'url' => "https://example.com/src-$i", 'title' => "Source $i", 'excerpt' => "Excerpt $i", 'relevance' => 0.7 );
|
||||
}
|
||||
for ( $i = 0; $i < $dup_count; $i++ ) {
|
||||
$sources[] = array( 'url' => "https://example.com/src-0", 'title' => 'Dup', 'excerpt' => 'dup', 'relevance' => 0.5 ); // same URL as first.
|
||||
}
|
||||
return array(
|
||||
array(
|
||||
'agent_role' => 'researcher:mechanisms',
|
||||
'sources' => $sources,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Seo_Stage.
|
||||
*
|
||||
* Covers: _prab_* post-meta writes per the ratified JSON-LD contract,
|
||||
* citation_score computation (average quality_score of kept sources),
|
||||
* score stored as post-meta, threshold option read, and run_stages
|
||||
* start→done lifecycle calls recorded via Audit_Writer + Run_Stage_State.
|
||||
*
|
||||
* No LLM calls — the SEO stage is entirely deterministic.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class SeoStageTest extends BaseTestCase {
|
||||
|
||||
/** @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb. */
|
||||
private $wpdb;
|
||||
|
||||
/** @var array<int, array> Rows inserted via Audit_Writer. */
|
||||
private array $decision_rows = array();
|
||||
|
||||
/** @var array<int, array> Rows inserted via Run_Stage_State (run_stages). */
|
||||
private array $stage_rows = array();
|
||||
|
||||
/** @var array<string, mixed> post-meta updates captured by update_post_meta stub. */
|
||||
private array $meta = array();
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->decision_rows = array();
|
||||
$this->stage_rows = array();
|
||||
$this->meta = array();
|
||||
|
||||
$this->wpdb = $this->create_mock_wpdb();
|
||||
$GLOBALS['wpdb'] = $this->wpdb;
|
||||
$this->wpdb->prefix = 'wp_';
|
||||
|
||||
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
||||
static function ( $sql, ...$args ) {
|
||||
return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */';
|
||||
}
|
||||
);
|
||||
|
||||
// Capture all inserts: route by table suffix.
|
||||
$test = $this;
|
||||
$this->wpdb->method( 'insert' )->willReturnCallback(
|
||||
function ( $table, $data ) use ( $test ) {
|
||||
if ( false !== strpos( $table, 'run_decisions' ) ) {
|
||||
$test->decision_rows[] = $data;
|
||||
} elseif ( false !== strpos( $table, 'run_stages' ) ) {
|
||||
$test->stage_rows[] = $data;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
);
|
||||
|
||||
// SHOW TABLES: simulate both audit + stage tables available.
|
||||
$this->wpdb->method( 'get_var' )->willReturnCallback(
|
||||
static function ( $sql ) {
|
||||
if ( false !== strpos( $sql, 'SHOW TABLES' ) ) {
|
||||
return 'wp_prautoblogger_run_decisions';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
// upsert for run_stages done() — query() is used by Run_Stage_Writes.
|
||||
$this->wpdb->method( 'query' )->willReturn( 1 );
|
||||
$this->wpdb->method( 'get_row' )->willReturn( null ); // No existing stage row.
|
||||
|
||||
\PRAutoBlogger_Audit_Writer::flush_cache();
|
||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||
|
||||
// Stub WordPress functions used by Seo_Stage.
|
||||
$test = $this;
|
||||
Functions\when( 'update_post_meta' )->alias(
|
||||
function ( $post_id, $key, $value ) use ( $test ) {
|
||||
$test->meta[ $key ] = $value;
|
||||
return true;
|
||||
}
|
||||
);
|
||||
Functions\when( 'gmdate' )->alias( 'gmdate' );
|
||||
Functions\when( 'current_time' )->justReturn( '2026-06-24 00:00:00' );
|
||||
Functions\when( 'wp_json_encode' )->alias( 'json_encode' );
|
||||
Functions\when( '__' )->alias( static fn( $s ) => $s );
|
||||
|
||||
$this->stub_get_option( array(
|
||||
'prautoblogger_log_level' => 'error',
|
||||
'prautoblogger_citation_score_threshold' => 0.0,
|
||||
) );
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
\PRAutoBlogger_Audit_Writer::flush_cache();
|
||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||
unset( $GLOBALS['wpdb'] );
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ── Post-meta writes ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* _prab_schema_version must be set to integer 1.
|
||||
* Presence is the opt-in trigger for prcore JSON-LD emission.
|
||||
*/
|
||||
public function test_writes_prab_schema_version_1(): void {
|
||||
$stage = new \PRAutoBlogger_Seo_Stage();
|
||||
$stage->run( 'run-1', 'idea:abc', 42, $this->make_sources( 2 ), array() );
|
||||
|
||||
$this->assertArrayHasKey( '_prab_schema_version', $this->meta );
|
||||
$this->assertSame( 1, $this->meta['_prab_schema_version'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* _prab_citations must be a JSON-encoded array matching the kept sources.
|
||||
*/
|
||||
public function test_writes_citations_from_kept_sources(): void {
|
||||
$sources = array(
|
||||
array( 'url' => 'https://example.com/a', 'title' => 'Source A', 'quality_score' => 0.8 ),
|
||||
array( 'url' => 'https://example.com/b', 'title' => 'Source B', 'doi' => '10.1234/test', 'quality_score' => 0.9 ),
|
||||
);
|
||||
|
||||
$stage = new \PRAutoBlogger_Seo_Stage();
|
||||
$stage->run( 'run-2', 'idea:abc', 42, $sources, array() );
|
||||
|
||||
$this->assertArrayHasKey( '_prab_citations', $this->meta );
|
||||
$decoded = json_decode( $this->meta['_prab_citations'], true );
|
||||
$this->assertIsArray( $decoded );
|
||||
$this->assertCount( 2, $decoded );
|
||||
$this->assertSame( 'https://example.com/a', $decoded[0]['url'] );
|
||||
$this->assertSame( 'Source A', $decoded[0]['title'] );
|
||||
// DOI present only when supplied.
|
||||
$this->assertArrayNotHasKey( 'doi', $decoded[0] );
|
||||
$this->assertSame( '10.1234/test', $decoded[1]['doi'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* _prab_review_mode must be 'editorial-system' for the automated SEO stage.
|
||||
* 'human' mode is set only in P2b.4 on human-queue approval.
|
||||
*/
|
||||
public function test_writes_review_mode_editorial_system(): void {
|
||||
$stage = new \PRAutoBlogger_Seo_Stage();
|
||||
$stage->run( 'run-3', 'idea:abc', 42, $this->make_sources( 1 ), array() );
|
||||
|
||||
$this->assertArrayHasKey( '_prab_review_mode', $this->meta );
|
||||
$this->assertSame( 'editorial-system', $this->meta['_prab_review_mode'] );
|
||||
}
|
||||
|
||||
// ── citation_score computation ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Given 3 sources with quality_scores [0.8, 0.6, 1.0], the score must
|
||||
* equal (0.8 + 0.6 + 1.0) / 3 = 0.8.
|
||||
*/
|
||||
public function test_citation_score_computed_correctly(): void {
|
||||
$sources = array(
|
||||
array( 'url' => 'https://a.com', 'title' => 'A', 'quality_score' => 0.8 ),
|
||||
array( 'url' => 'https://b.com', 'title' => 'B', 'quality_score' => 0.6 ),
|
||||
array( 'url' => 'https://c.com', 'title' => 'C', 'quality_score' => 1.0 ),
|
||||
);
|
||||
|
||||
$stage = new \PRAutoBlogger_Seo_Stage();
|
||||
$score = $stage->run( 'run-4', 'idea:abc', 42, $sources, array() );
|
||||
|
||||
$this->assertEqualsWithDelta( 0.8, $score, 0.0001 );
|
||||
}
|
||||
|
||||
/**
|
||||
* The computed citation_score must also be stored as '_prab_citation_score'
|
||||
* post-meta (as a string representation of the float).
|
||||
*/
|
||||
public function test_citation_score_stored_as_post_meta(): void {
|
||||
$sources = array(
|
||||
array( 'url' => 'https://a.com', 'title' => 'A', 'quality_score' => 0.8 ),
|
||||
array( 'url' => 'https://b.com', 'title' => 'B', 'quality_score' => 0.6 ),
|
||||
array( 'url' => 'https://c.com', 'title' => 'C', 'quality_score' => 1.0 ),
|
||||
);
|
||||
|
||||
$stage = new \PRAutoBlogger_Seo_Stage();
|
||||
$score = $stage->run( 'run-5', 'idea:abc', 42, $sources, array() );
|
||||
|
||||
$this->assertArrayHasKey( '_prab_citation_score', $this->meta );
|
||||
$this->assertEqualsWithDelta( $score, (float) $this->meta['_prab_citation_score'], 0.0001 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-empty $peptide_ids must be JSON-encoded as a plain integer array in _prab_about_peptides.
|
||||
* Covers the populated path of write_post_meta (array_values + wp_json_encode).
|
||||
*/
|
||||
public function test_peptide_ids_encoded_as_json_int_array(): void {
|
||||
$stage = new \PRAutoBlogger_Seo_Stage();
|
||||
$stage->run( 'run-5b', 'idea:abc', 42, $this->make_sources( 1 ), array( 0 => 10, 1 => 20, 2 => 30 ) );
|
||||
|
||||
$this->assertArrayHasKey( '_prab_about_peptides', $this->meta );
|
||||
$decoded = json_decode( $this->meta['_prab_about_peptides'], true );
|
||||
$this->assertIsArray( $decoded );
|
||||
$this->assertCount( 3, $decoded );
|
||||
// Values must be the original integer IDs.
|
||||
$this->assertSame( 10, $decoded[0] );
|
||||
$this->assertSame( 20, $decoded[1] );
|
||||
$this->assertSame( 30, $decoded[2] );
|
||||
// Array keys must be sequential (array_values re-indexes).
|
||||
$this->assertSame( array( 0, 1, 2 ), array_keys( $decoded ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* When kept_sources is empty the citation_score must be 0.0 (never divide-by-zero).
|
||||
*/
|
||||
public function test_empty_sources_score_is_zero(): void {
|
||||
$stage = new \PRAutoBlogger_Seo_Stage();
|
||||
$score = $stage->run( 'run-6', 'idea:abc', 42, array(), array() );
|
||||
|
||||
$this->assertSame( 0.0, $score );
|
||||
$this->assertArrayHasKey( '_prab_citation_score', $this->meta );
|
||||
$this->assertEqualsWithDelta( 0.0, (float) $this->meta['_prab_citation_score'], 0.0001 );
|
||||
}
|
||||
|
||||
// ── Threshold option ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The stage must read 'prautoblogger_citation_score_threshold' via
|
||||
* get_option with a default of 0.0 (intentionally uncalibrated).
|
||||
* We verify this by checking that the option key appears in a stub
|
||||
* map where it's set to a non-default value and confirming it is used.
|
||||
*/
|
||||
public function test_threshold_option_read_with_default(): void {
|
||||
// Re-stub get_option so we can detect the threshold key being read.
|
||||
$threshold_read = false;
|
||||
Functions\when( 'get_option' )->alias(
|
||||
function ( $name, $default = false ) use ( &$threshold_read ) {
|
||||
if ( 'prautoblogger_citation_score_threshold' === $name ) {
|
||||
$threshold_read = true;
|
||||
return 0.0;
|
||||
}
|
||||
if ( 'prautoblogger_log_level' === $name ) {
|
||||
return 'error';
|
||||
}
|
||||
return $default;
|
||||
}
|
||||
);
|
||||
|
||||
$stage = new \PRAutoBlogger_Seo_Stage();
|
||||
$stage->run( 'run-7', 'idea:abc', 42, $this->make_sources( 1 ), array() );
|
||||
|
||||
$this->assertTrue( $threshold_read, 'get_option(prautoblogger_citation_score_threshold) was never called' );
|
||||
}
|
||||
|
||||
// ── Stage lifecycle ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Run_Stage_State::start() and ::done() must both be called with
|
||||
* stage='seo' for the pipeline audit trail to be correct.
|
||||
*
|
||||
* Run_Stage_Writes uses $wpdb->query() (INSERT ON DUPLICATE KEY) for
|
||||
* both start() and done(). We assert that after run() completes:
|
||||
* (a) a run_decisions insert fired (Audit_Writer::record_decision), and
|
||||
* (b) a decision row carries stage='seo' and verdict='scored'.
|
||||
*
|
||||
* The query() path for run_stages is already exercised by the shared
|
||||
* setUp() mock (which stubs query() to return 1) — we confirm it by
|
||||
* verifying the full run() returns successfully with the correct score.
|
||||
*/
|
||||
public function test_run_stages_start_and_done_called(): void {
|
||||
$sources = $this->make_sources( 2 );
|
||||
$stage = new \PRAutoBlogger_Seo_Stage();
|
||||
$score = $stage->run( 'run-8', 'idea:abc', 42, $sources, array() );
|
||||
|
||||
// run() completes without error => start() + done() both fired.
|
||||
$this->assertIsFloat( $score );
|
||||
|
||||
// A run_decisions row with stage='seo' must have been inserted.
|
||||
$seo_decisions = array_filter(
|
||||
$this->decision_rows,
|
||||
static fn( $r ) => isset( $r['stage'] ) && 'seo' === $r['stage']
|
||||
);
|
||||
$this->assertCount( 1, array_values( $seo_decisions ), 'Audit_Writer::record_decision() for stage=seo was not called' );
|
||||
|
||||
$row = array_values( $seo_decisions )[0];
|
||||
$this->assertSame( 'scored', $row['verdict'] );
|
||||
$this->assertNotNull( $row['citation_score'] );
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build N simple source fixtures with sequential quality_scores.
|
||||
*
|
||||
* @param int $n Number of sources.
|
||||
* @return array<int, array{url: string, title: string, quality_score: float}>
|
||||
*/
|
||||
private function make_sources( int $n ): array {
|
||||
$sources = array();
|
||||
for ( $i = 0; $i < $n; $i++ ) {
|
||||
$sources[] = array(
|
||||
'url' => "https://example.com/source-{$i}",
|
||||
'title' => "Source {$i}",
|
||||
'quality_score' => round( 0.5 + ( $i * 0.1 ), 2 ),
|
||||
);
|
||||
}
|
||||
return $sources;
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Tier_Router.
|
||||
*
|
||||
* Covers: master flag OFF always returns 'economy', unclassified category
|
||||
* defaults to authority, explicit 'economy' demotion, explicit 'authority'
|
||||
* entry treated as default authority.
|
||||
*
|
||||
* No LLM calls — the router is a pure option-read + conditional.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class TierRouterTest extends BaseTestCase {
|
||||
|
||||
/** @var \PRAutoBlogger_Article_Idea Shared idea fixture. */
|
||||
private \PRAutoBlogger_Article_Idea $idea;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->idea = new \PRAutoBlogger_Article_Idea( array(
|
||||
'topic' => 'BPC-157 overview',
|
||||
'article_type' => 'guide',
|
||||
'suggested_title' => 'BPC-157 Guide',
|
||||
'summary' => 'Comprehensive guide.',
|
||||
'score' => 0.9,
|
||||
) );
|
||||
}
|
||||
|
||||
// ── Master flag OFF ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* When the master flag is OFF (default false), resolve() must always
|
||||
* return 'economy' regardless of category map contents.
|
||||
*
|
||||
* This is the critical proof that P2b.4 deployment = zero behavior change.
|
||||
*/
|
||||
public function test_master_flag_off_always_returns_economy(): void {
|
||||
$this->stub_get_option( array(
|
||||
'prautoblogger_authority_pipeline_enabled' => false,
|
||||
'prautoblogger_category_tiers' => array( 'guide' => 'authority' ),
|
||||
) );
|
||||
|
||||
$router = new \PRAutoBlogger_Tier_Router();
|
||||
$this->assertSame( 'economy', $router->resolve( $this->idea ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* String '0' is also a falsy master flag — must return 'economy'.
|
||||
*/
|
||||
public function test_master_flag_string_zero_returns_economy(): void {
|
||||
$this->stub_get_option( array(
|
||||
'prautoblogger_authority_pipeline_enabled' => '0',
|
||||
) );
|
||||
|
||||
$router = new \PRAutoBlogger_Tier_Router();
|
||||
$this->assertSame( 'economy', $router->resolve( $this->idea ) );
|
||||
}
|
||||
|
||||
// ── Master flag ON — category routing ────────────────────────────────
|
||||
|
||||
/**
|
||||
* When flag is ON and the idea's category is NOT in the tier map,
|
||||
* the default Authority tier must apply.
|
||||
*/
|
||||
public function test_unclassified_category_defaults_authority(): void {
|
||||
$this->stub_get_option( array(
|
||||
'prautoblogger_authority_pipeline_enabled' => '1',
|
||||
'prautoblogger_category_tiers' => array( 'news' => 'economy' ),
|
||||
) );
|
||||
|
||||
$router = new \PRAutoBlogger_Tier_Router();
|
||||
// 'guide' is not in the tier map → default authority.
|
||||
$this->assertSame( 'authority', $router->resolve( $this->idea ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* When flag is ON and the idea's category is explicitly set to 'economy'
|
||||
* in the tier map, resolve() must return 'economy'.
|
||||
*/
|
||||
public function test_explicitly_demoted_category_returns_economy(): void {
|
||||
$this->stub_get_option( array(
|
||||
'prautoblogger_authority_pipeline_enabled' => '1',
|
||||
'prautoblogger_category_tiers' => array( 'guide' => 'economy' ),
|
||||
) );
|
||||
|
||||
$router = new \PRAutoBlogger_Tier_Router();
|
||||
$this->assertSame( 'economy', $router->resolve( $this->idea ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* When flag is ON and the idea's category is explicitly set to 'authority'
|
||||
* in the tier map (redundant but valid), resolve() must return 'authority'.
|
||||
*/
|
||||
public function test_authority_category_returns_authority(): void {
|
||||
$this->stub_get_option( array(
|
||||
'prautoblogger_authority_pipeline_enabled' => '1',
|
||||
'prautoblogger_category_tiers' => array( 'guide' => 'authority' ),
|
||||
) );
|
||||
|
||||
$router = new \PRAutoBlogger_Tier_Router();
|
||||
$this->assertSame( 'authority', $router->resolve( $this->idea ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* When flag is ON and the tier map is empty, all categories default to 'authority'.
|
||||
*/
|
||||
public function test_empty_tier_map_defaults_authority(): void {
|
||||
$this->stub_get_option( array(
|
||||
'prautoblogger_authority_pipeline_enabled' => '1',
|
||||
'prautoblogger_category_tiers' => array(),
|
||||
) );
|
||||
|
||||
$router = new \PRAutoBlogger_Tier_Router();
|
||||
$this->assertSame( 'authority', $router->resolve( $this->idea ) );
|
||||
}
|
||||
}
|
||||
@@ -69,10 +69,6 @@ foreach ( $options as $option ) {
|
||||
$wpdb->query(
|
||||
"DELETE FROM {$wpdb->postmeta} WHERE meta_key LIKE '\\_prautoblogger\\_%'"
|
||||
);
|
||||
// P2b.3 (v0.30.0): SEO stage writes _prab_* keys (different prefix — covered separately).
|
||||
$wpdb->query(
|
||||
"DELETE FROM {$wpdb->postmeta} WHERE meta_key LIKE '\\_prab\\_%'"
|
||||
);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user