feat(schema): v1.2.0 substrate schema + stage display map (v0.18.0)

Pipeline v2 Phase 1, commit 1/4. Five new dbDelta-idempotent tables
(prompts, run_sources, run_decisions, runs, run_stages), additive
nullable agent_role + prompt_version columns on generation_log,
PRAUTOBLOGGER_DB_VERSION 1.1.0 -> 1.2.0 (existing on_check_db_version
auto-migration re-runs the installers on mismatch), Stage_Display_Map
covering historical + image + v2 stage vocabulary with humanizing
fallback, uninstall coverage for all new tables + full cron hook list,
ARCHITECTURE edit->polish canonicalization + schema docs + decision #22.

No behavior change: nothing reads the new tables yet.

Agent-Session: pipeline-v2-phase1-engineer-20260612
This commit is contained in:
peptiderepo
2026-06-11 03:57:36 +00:00
parent 6de8cf2031
commit 809e7176ad
9 changed files with 627 additions and 9 deletions

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

@@ -36,9 +36,16 @@ class PRAutoBlogger_Activator {
update_option( 'prautoblogger_db_version', PRAUTOBLOGGER_DB_VERSION, false );
}
/** Delegate to `PRAutoBlogger_Schema_Installer::install()` — see that class. */
/**
* Delegate to the schema installers — see those classes.
*
* `Schema_Installer` owns the v1.1.0 tables; `Pipeline_Schema_Installer`
* owns the v0.18.0 / db-1.2.0 Pipeline v2 substrate tables. Both are
* dbDelta-idempotent and safe to re-run on any version mismatch.
*/
private static function create_tables(): void {
PRAutoBlogger_Schema_Installer::install();
PRAutoBlogger_Pipeline_Schema_Installer::install();
}
/**

View File

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

View File

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

View File

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