feat(phase2b): P2b.3 — SEO stage (_prab_ meta + citation_score) (#180, v0.30.0)
Additive: seo stage writes _prab_* meta per the ratified prcore JSON-LD contract (emitter consumes); computes citation_score + configurable threshold (gate is P2b.4). Deterministic, no LLM/cost, live path untouched. Uninstall purges _prab_ meta. QA APPROVE 55b94d0; CI green (VPS-verified). Agent-Session: prab-phase2b-3-merge
This commit is contained in:
@@ -228,6 +228,7 @@ prautoblogger/
|
|||||||
│ │ ├── 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-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-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-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)
|
||||||
│ │ ├── class-audit-writer.php # run_sources / run_decisions insert layer
|
│ │ ├── class-audit-writer.php # run_sources / run_decisions insert layer
|
||||||
│ │ ├── class-pipeline-status.php # Status-transient + summary helpers (extracted from runner/worker)
|
│ │ ├── class-pipeline-status.php # Status-transient + summary helpers (extracted from runner/worker)
|
||||||
│ │ ├── class-logger.php # Structured logging singleton (error/warning/info/debug)
|
│ │ ├── class-logger.php # Structured logging singleton (error/warning/info/debug)
|
||||||
@@ -248,6 +249,7 @@ prautoblogger/
|
|||||||
│ │ ├── interface-research-fanout.php # P2b.1: contract for parallel research fan-out (v0.28.0)
|
│ │ ├── 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-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-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)
|
||||||
│ │ ├── class-reddit-provider.php # Reddit data collection orchestrator (RSS primary)
|
│ │ ├── class-reddit-provider.php # Reddit data collection orchestrator (RSS primary)
|
||||||
│ │ ├── interface-image-provider.php # Contract for any image generation provider (incl. batch)
|
│ │ ├── interface-image-provider.php # Contract for any image generation provider (incl. batch)
|
||||||
│ │ ├── class-open-router-image-provider.php # OpenRouter image gen (single + batch dispatch)
|
│ │ ├── class-open-router-image-provider.php # OpenRouter image gen (single + batch dispatch)
|
||||||
@@ -693,6 +695,7 @@ All prefixed with `prautoblogger_`:
|
|||||||
| `prautoblogger_board_poll_interval` | Mission Brief board poll interval in seconds (default 5, min 3). Localized into board.js. |
|
| `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_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_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). |
|
||||||
|
|
||||||
### Post Meta
|
### Post Meta
|
||||||
|
|
||||||
@@ -714,6 +717,12 @@ 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_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_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) |
|
| `_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. |
|
||||||
|
|
||||||
Composed-variant **attachment** meta (v0.17.0): every pipeline attachment gets
|
Composed-variant **attachment** meta (v0.17.0): every pipeline attachment gets
|
||||||
`_prautoblogger_image_role` (`featured`/`og`/`square`); OG/square variants also
|
`_prautoblogger_image_role` (`featured`/`og`/`square`); OG/square variants also
|
||||||
|
|||||||
20
CHANGELOG.md
20
CHANGELOG.md
@@ -5,6 +5,26 @@ All notable changes to PRAutoBlogger will be documented in this file.
|
|||||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project uses [Semantic Versioning](https://semver.org/).
|
and this project uses [Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
|
## [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
|
## [0.29.0] - 2026-06-24
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
20
CONTEXT.md
20
CONTEXT.md
@@ -190,3 +190,23 @@ into the live Economy path until P2b.4).
|
|||||||
| **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. |
|
| **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. |
|
| **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. |
|
| **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). |
|
||||||
|
|||||||
@@ -790,3 +790,41 @@ The Authority-tier editorial pipeline uses a bounded editor<->writer loop
|
|||||||
| `PRAutoBlogger_Editorial_Revision_Caller` | Writer LLM call, stage lifecycle for 'writer' role |
|
| `PRAutoBlogger_Editorial_Revision_Caller` | Writer LLM call, stage lifecycle for 'writer' role |
|
||||||
| `PRAutoBlogger_Content_Prompts` | Static `build_revision_system()` + `build_revision_user()` |
|
| `PRAutoBlogger_Content_Prompts` | Static `build_revision_system()` + `build_revision_user()` |
|
||||||
| `PRAutoBlogger_Editorial_Round` | Immutable value object (one round snapshot) |
|
| `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 |
|
||||||
|
|||||||
174
includes/core/class-seo-stage.php
Normal file
174
includes/core/class-seo-stage.php
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
<?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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
35
includes/providers/interface-seo-stage.php
Normal file
35
includes/providers/interface-seo-stage.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?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,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"redefinable-internals": [
|
"redefinable-internals": [
|
||||||
"time"
|
"time",
|
||||||
|
"gmdate"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
* Plugin Name: PRAutoBlogger
|
* Plugin Name: PRAutoBlogger
|
||||||
* Plugin URI: https://peptiderepo.com/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.
|
* 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.29.0
|
* Version: 0.30.0
|
||||||
* Requires at least: 6.0
|
* Requires at least: 6.0
|
||||||
* Requires PHP: 7.4
|
* Requires PHP: 7.4
|
||||||
* Author: PeptideRepo
|
* Author: PeptideRepo
|
||||||
@@ -35,8 +35,8 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||||||
| Defined here so every file in the plugin can reference paths, versions,
|
| Defined here so every file in the plugin can reference paths, versions,
|
||||||
| and limits without magic strings.
|
| and limits without magic strings.
|
||||||
*/
|
*/
|
||||||
define( 'PRAUTOBLOGGER_VERSION', '0.29.0' );
|
define( 'PRAUTOBLOGGER_VERSION', '0.30.0' );
|
||||||
define( 'PRAUTOBLOGGER_DB_VERSION', '1.4.0' ); // No schema change in v0.29.0 (Editorial_Loop is additive code only).
|
define( 'PRAUTOBLOGGER_DB_VERSION', '1.4.0' ); // No schema change in v0.30.0 (Seo_Stage is additive code only).
|
||||||
define( 'PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD', 0.50 );
|
define( 'PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD', 0.50 );
|
||||||
define( 'PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS', 14 );
|
define( 'PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS', 14 );
|
||||||
// Default max cards shown per board column and per ideas-browser page.
|
// Default max cards shown per board column and per ideas-browser page.
|
||||||
|
|||||||
309
tests/unit/Core/SeoStageTest.php
Normal file
309
tests/unit/Core/SeoStageTest.php
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,10 @@ foreach ( $options as $option ) {
|
|||||||
$wpdb->query(
|
$wpdb->query(
|
||||||
"DELETE FROM {$wpdb->postmeta} WHERE meta_key LIKE '\\_prautoblogger\\_%'"
|
"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