Files
prautoblogger/includes/core/class-audit-writer.php
peptiderepo ed8ec9425f feat(runs): state machine, idempotent resume, reaper extension, retention
Pipeline v2 Phase 1, commit 4/4. Run_Stage_State persists per-run
per-stage state keyed run_id + stage + agent_role + item_key (item_key
scopes articles because one run_id spans a whole batch; agent_role is
the Phase-2 quorum dimension, schema-only). Done stages snapshot output
and are reused on re-entry — writer stages via
Content_Generator::set_run_item, the review via its stored verdict, a
published item skipped outright — never re-run, never re-charged. Post
creation is keyed by _prautoblogger_run_id + new _prautoblogger_idea_hash
(check-before-insert in Publisher) so retries cannot duplicate posts.
Run-level research/analysis checkpoints + done/failed lifecycle marks
ride Pipeline_Runner/Executor (incl. orphan-abort + budget-abort paths).

Run_Reaper rides the EXISTING #19 daily cron: stages stuck running >2x
expected stage wall-clock and runs silent >2x expected run wall-clock ->
failed (filterable), open stages reaped, queryable as 'incomplete'.
Retention: request_json + stage output payloads NULLed after R days, R
from the new Audit Payload Retention setting (default constant 14,
0 = keep forever, no literal at point of use). Audit_Writer records
editorial verdicts (run_decisions) and per-article kept sources
(run_sources) for new runs.

Under-300: status-transient/summary helpers extracted verbatim to
Pipeline_Status (Pipeline_Runner 271; removed the worker's duplicate).

Agent-Session: pipeline-v2-phase1-engineer-20260612
2026-06-11 16:22:59 +07:00

133 lines
4.9 KiB
PHP

<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Writes run-scoped audit rows: sources considered + stage decisions.
*
* What: Thin, self-healing insert layer over the v0.18.0 audit child
* tables. `record_decision()` persists a stage verdict (Phase 1:
* the chief editor's review verdict; Phase 2 adds curate/editorial/
* seo decisions and citation_score). `record_idea_sources()`
* consolidates which collected sources fed a generated article —
* the run_sources replacement for the historical source_ids_json /
* `_prautoblogger_research_sources` scatter — for NEW runs only (no
* backfill). Every method no-ops when the tables are missing.
* Who triggers it: Article_Worker (decision after review, sources after
* publish), Phase-2 agents.
* Dependencies: WordPress $wpdb, Run_State (availability probe pattern).
*
* @see core/class-article-worker.php — Phase-1 call sites.
* @see ARCHITECTURE.md #21 — Audit substrate design.
*/
class PRAutoBlogger_Audit_Writer {
/** @var bool|null Per-request "audit tables exist" probe result. */
private static ?bool $tables_ok = null;
/**
* Whether the audit child tables exist. Cached per request; never throws.
*
* @return bool
*/
public static function is_available(): bool {
if ( null !== self::$tables_ok ) {
return self::$tables_ok;
}
global $wpdb;
if ( null === $wpdb ) {
return false; // Not cached: $wpdb may appear later in boot.
}
$table = $wpdb->prefix . 'prautoblogger_run_decisions';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
self::$tables_ok = ( $found === $table );
return self::$tables_ok;
}
/**
* Persist a stage decision for a run.
*
* @param string $run_id Run UUID.
* @param string $stage Deciding stage (see Stage_Display_Map).
* @param string $verdict e.g. 'approved', 'revised', 'rejected'.
* @param string $rationale Why (editor notes, governor message, …).
* @param float|null $citation_score Phase-2 citation score (null in Phase 1).
* @return void
*/
public static function record_decision( string $run_id, string $stage, string $verdict, string $rationale = '', ?float $citation_score = null ): void {
if ( '' === $run_id || ! self::is_available() ) {
return;
}
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->insert(
$wpdb->prefix . 'prautoblogger_run_decisions',
array(
'run_id' => $run_id,
'stage' => $stage,
'verdict' => $verdict,
'rationale' => $rationale,
'citation_score' => $citation_score,
'created_at' => current_time( 'mysql' ),
)
);
}
/**
* Record the collected sources that fed a generated article (kept = 1;
* Phase-2 curation adds discard rows with reasons + quality scores).
*
* Resolves the idea's source_data ids to permalinks in one query.
*
* @param string $run_id Run UUID.
* @param PRAutoBlogger_Article_Idea $idea The generated idea.
* @return void
*/
public static function record_idea_sources( string $run_id, PRAutoBlogger_Article_Idea $idea ): void {
if ( '' === $run_id || ! self::is_available() ) {
return;
}
$source_ids = array_filter( array_map( 'intval', $idea->get_source_ids() ) );
if ( empty( $source_ids ) ) {
return;
}
global $wpdb;
$source_table = $wpdb->prefix . 'prautoblogger_source_data';
$placeholders = implode( ',', array_fill( 0, count( $source_ids ), '%d' ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared
$rows = $wpdb->get_results(
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $placeholders is a fixed %d list.
$wpdb->prepare( "SELECT id, source_type, permalink FROM {$source_table} WHERE id IN ({$placeholders})", $source_ids ),
ARRAY_A
);
if ( ! is_array( $rows ) ) {
return;
}
foreach ( $rows as $row ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->insert(
$wpdb->prefix . 'prautoblogger_run_sources',
array(
'run_id' => $run_id,
'agent_role' => 'analyst',
'source_url' => (string) ( $row['permalink'] ?? '' ),
'doi' => null,
'kept' => 1,
'reason' => sprintf( 'Cited by analyzer for "%s" (%s #%d)', $idea->get_topic(), (string) $row['source_type'], (int) $row['id'] ),
'quality_score' => null,
'created_at' => current_time( 'mysql' ),
)
);
}
}
/** Reset per-request caches (tests). */
public static function flush_cache(): void {
self::$tables_ok = null;
}
}