diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1463348..6cb3787 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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-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) │ │ ├── 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) @@ -248,6 +249,7 @@ prautoblogger/ │ │ ├── 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) │ │ ├── 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) @@ -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_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). | ### 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_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. | Composed-variant **attachment** meta (v0.17.0): every pipeline attachment gets `_prautoblogger_image_role` (`featured`/`og`/`square`); OG/square variants also diff --git a/CHANGELOG.md b/CHANGELOG.md index 77193c0..62eea8f 100644 --- a/CHANGELOG.md +++ b/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/), 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 ### Added diff --git a/CONTEXT.md b/CONTEXT.md index be2f046..6ba5172 100644 --- a/CONTEXT.md +++ b/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. | | **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). | diff --git a/CONVENTIONS.md b/CONVENTIONS.md index f29413f..bd98c92 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -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_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 | diff --git a/includes/core/class-seo-stage.php b/includes/core/class-seo-stage.php new file mode 100644 index 0000000..2b51640 --- /dev/null +++ b/includes/core/class-seo-stage.php @@ -0,0 +1,174 @@ + $kept_sources Kept research sources from the curate stage. + * @param array $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 $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 $kept_sources Kept sources. + * @param array $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 ); + } +} diff --git a/includes/providers/interface-seo-stage.php b/includes/providers/interface-seo-stage.php new file mode 100644 index 0000000..154c3f3 --- /dev/null +++ b/includes/providers/interface-seo-stage.php @@ -0,0 +1,35 @@ + $kept_sources Kept research sources from the curate stage. + * @param array $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; +} diff --git a/patchwork.json b/patchwork.json index da70e72..85b8e21 100644 --- a/patchwork.json +++ b/patchwork.json @@ -1,5 +1,6 @@ { "redefinable-internals": [ - "time" + "time", + "gmdate" ] } diff --git a/prautoblogger.php b/prautoblogger.php index db12800..eacbae2 100644 --- a/prautoblogger.php +++ b/prautoblogger.php @@ -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.29.0 + * Version: 0.30.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.29.0' ); -define( 'PRAUTOBLOGGER_DB_VERSION', '1.4.0' ); // No schema change in v0.29.0 (Editorial_Loop is additive code only). +define( 'PRAUTOBLOGGER_VERSION', '0.30.0' ); +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_REQUEST_JSON_RETENTION_DAYS', 14 ); // Default max cards shown per board column and per ideas-browser page. diff --git a/tests/unit/Core/SeoStageTest.php b/tests/unit/Core/SeoStageTest.php new file mode 100644 index 0000000..7b23b5b --- /dev/null +++ b/tests/unit/Core/SeoStageTest.php @@ -0,0 +1,309 @@ + Rows inserted via Audit_Writer. */ + private array $decision_rows = array(); + + /** @var array Rows inserted via Run_Stage_State (run_stages). */ + private array $stage_rows = array(); + + /** @var array 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 + */ + 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; + } +} diff --git a/uninstall.php b/uninstall.php index ddfeed1..0d894dd 100644 --- a/uninstall.php +++ b/uninstall.php @@ -69,6 +69,10 @@ 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\\_%'" +); /* |--------------------------------------------------------------------------