feat: M3 edit + single-step re-run — input versioning, human-modified audit, staged re-run (v0.20.0) (#161)
QA: REJECT@6ed639d (CTO rebase header P1) -> APPROVE@6727e07 (qa-reviews/prautoblogger/2026-06-12-6727e07.md) CPO guardrails 1-5 verified hard-AC. CEO conditional greenlight 2026-06-12 in chat (QA-APPROVE condition met). Agent-Session: cto-m3-merge-20260612
This commit is contained in:
@@ -127,7 +127,7 @@ class PRAutoBlogger_Board_Data_Provider {
|
||||
);
|
||||
}
|
||||
|
||||
return $cards;
|
||||
return $this->flag_human_modified( $cards );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -183,6 +183,48 @@ class PRAutoBlogger_Board_Data_Provider {
|
||||
);
|
||||
}
|
||||
|
||||
return $this->flag_human_modified( $cards );
|
||||
}
|
||||
|
||||
/**
|
||||
* Set 'human_modified' on each card in ONE batched query (v0.20.0 —
|
||||
* CPO product AC: human-modified runs are visually distinct at the
|
||||
* run-list level). No per-card queries; self-healing on a missing
|
||||
* table/column (flag simply stays false).
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $cards Column cards.
|
||||
* @return array<int, array<string, mixed>> Cards with the flag set.
|
||||
*/
|
||||
private function flag_human_modified( array $cards ): array {
|
||||
$run_ids = array();
|
||||
foreach ( $cards as $i => $card ) {
|
||||
$cards[ $i ]['human_modified'] = false;
|
||||
if ( ! empty( $card['run_id'] ) ) {
|
||||
$run_ids[] = (string) $card['run_id'];
|
||||
}
|
||||
}
|
||||
if ( empty( $run_ids ) || ! PRAutoBlogger_Run_Stage_State::is_available() ) {
|
||||
return $cards;
|
||||
}
|
||||
global $wpdb;
|
||||
$table = PRAutoBlogger_Run_Stage_State::table_name();
|
||||
$placeholders = implode( ',', array_fill( 0, count( $run_ids ), '%s' ) );
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- $placeholders is a fixed %s list.
|
||||
$flagged = $wpdb->get_col(
|
||||
$wpdb->prepare(
|
||||
"SELECT DISTINCT run_id FROM {$table} WHERE human_modified = 1 AND run_id IN ({$placeholders})",
|
||||
$run_ids
|
||||
)
|
||||
);
|
||||
if ( ! is_array( $flagged ) || empty( $flagged ) ) {
|
||||
return $cards;
|
||||
}
|
||||
$flagged = array_flip( array_map( 'strval', $flagged ) );
|
||||
foreach ( $cards as $i => $card ) {
|
||||
if ( isset( $flagged[ (string) ( $card['run_id'] ?? '' ) ] ) ) {
|
||||
$cards[ $i ]['human_modified'] = true;
|
||||
}
|
||||
}
|
||||
return $cards;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +152,7 @@ class PRAutoBlogger_Board_Page {
|
||||
'edit' => __( 'Edit', 'prautoblogger' ),
|
||||
'viewLog' => __( 'View Log', 'prautoblogger' ),
|
||||
'pollError' => __( 'Board update failed — retrying.', 'prautoblogger' ),
|
||||
'humanModified' => __( 'Human-modified', 'prautoblogger' ),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
299
includes/admin/class-dossier-actions.php
Normal file
299
includes/admin/class-dossier-actions.php
Normal file
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* AJAX endpoints for the dossier's edit + re-run actions (v0.20.0, M3).
|
||||
*
|
||||
* What: Four manage_options + nonce gated endpoints. The mutation
|
||||
* endpoints follow the chained-cron contract strictly: save_input
|
||||
* only INSERTs an immutable fork version; the two rerun endpoints
|
||||
* only validate + queue (Rerun_Job_Support::queue) — execution and
|
||||
* ALL run/stage state mutations happen in the cron handler after
|
||||
* re-validation under the generation lock. Run identity (run_id /
|
||||
* item_key) is always derived server-side from the post's meta —
|
||||
* never trusted from the client; the client only names the stage
|
||||
* row it acted on, which is validated against an existing row.
|
||||
* stage_status is the read-only poll the dossier JS uses to show
|
||||
* queued -> pickup -> result.
|
||||
* Who triggers it: assets/js/dossier-edit.js via admin-ajax.php;
|
||||
* registered in class-prautoblogger.php.
|
||||
* Dependencies: Rerun_Eligibility, Stage_Input_Store, Stage_Replay
|
||||
* (decode), Rerun_Job_Support/Executor, Run_Stage_State.
|
||||
*
|
||||
* @see assets/js/dossier-edit.js — The frontend caller.
|
||||
* @see core/class-rerun-executor.php — The cron handlers jobs land in.
|
||||
* @see ARCHITECTURE.md #24 — Edit + re-run design.
|
||||
*/
|
||||
class PRAutoBlogger_Dossier_Actions {
|
||||
|
||||
/** Nonce action shared by all four endpoints. */
|
||||
public const NONCE_ACTION = 'prautoblogger_dossier_actions';
|
||||
|
||||
/**
|
||||
* AJAX: save an edited stage input as a new immutable fork version.
|
||||
*
|
||||
* The fork body is the stage's base request (latest fork, else the
|
||||
* recorded request_json) with ONLY the message contents substituted —
|
||||
* roles, message count, model and call options are not editable, so
|
||||
* a fork is always a well-formed replayable chat body. Prompt text
|
||||
* is stored raw (it is LLM input, never rendered unescaped).
|
||||
*
|
||||
* Side effects: one stage_inputs INSERT.
|
||||
*
|
||||
* @return void Sends JSON.
|
||||
*/
|
||||
public function on_save_input(): void {
|
||||
$request = $this->authorize_and_resolve();
|
||||
|
||||
$check = PRAutoBlogger_Rerun_Eligibility::check( $request['run_id'], $request['post_id'] );
|
||||
if ( ! PRAutoBlogger_Rerun_Eligibility::is_editable_stage( $request['stage'] ) || ! $check['ok'] ) {
|
||||
$reason = $check['ok'] ? __( 'This stage cannot be replayed with an edited input.', 'prautoblogger' ) : $check['reason'];
|
||||
wp_send_json_error( array( 'message' => $reason ), 400 );
|
||||
return;
|
||||
}
|
||||
|
||||
$base_body = $this->resolve_base_body( $request );
|
||||
if ( null === $base_body ) {
|
||||
wp_send_json_error( array( 'message' => __( 'No persisted input exists for this stage.', 'prautoblogger' ) ), 400 );
|
||||
return;
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- raw prompt text for the LLM; structure-validated below, stored raw, escaped at render.
|
||||
$raw = isset( $_POST['messages'] ) ? wp_unslash( (string) $_POST['messages'] ) : '';
|
||||
$messages = json_decode( $raw, true );
|
||||
$merged = $this->merge_messages( $base_body, is_array( $messages ) ? $messages : null );
|
||||
if ( null === $merged ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Edited messages must match the original structure (same count and roles, non-empty contents).', 'prautoblogger' ) ), 400 );
|
||||
return;
|
||||
}
|
||||
|
||||
$user = wp_get_current_user();
|
||||
$version = PRAutoBlogger_Stage_Input_Store::save_fork(
|
||||
$request['run_id'],
|
||||
$request['stage'],
|
||||
$request['agent_role'],
|
||||
$request['item_key'],
|
||||
(string) wp_json_encode( $merged ),
|
||||
(string) $user->user_login
|
||||
);
|
||||
if ( null === $version ) {
|
||||
wp_send_json_error( array( 'message' => __( 'The edited input could not be saved.', 'prautoblogger' ) ), 500 );
|
||||
return;
|
||||
}
|
||||
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'version' => $version,
|
||||
'message' => sprintf(
|
||||
/* translators: %d: fork version number. */
|
||||
__( 'Saved as version %d. The original input is preserved unchanged; re-running this stage will use your edited version and mark all downstream stages stale.', 'prautoblogger' ),
|
||||
$version
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: queue a single-stage replay of the latest edited fork.
|
||||
*
|
||||
* Validates, then ONLY schedules — chained-cron semantics. The UI
|
||||
* copy never implies synchronous execution.
|
||||
*
|
||||
* @return void Sends JSON.
|
||||
*/
|
||||
public function on_rerun_stage(): void {
|
||||
$request = $this->authorize_and_resolve();
|
||||
|
||||
$check = PRAutoBlogger_Rerun_Eligibility::check_replay(
|
||||
$request['run_id'],
|
||||
$request['post_id'],
|
||||
$request['stage'],
|
||||
$request['agent_role'],
|
||||
$request['item_key']
|
||||
);
|
||||
if ( ! $check['ok'] ) {
|
||||
wp_send_json_error( array( 'message' => $check['reason'] ), 400 );
|
||||
return;
|
||||
}
|
||||
|
||||
PRAutoBlogger_Rerun_Job_Support::queue(
|
||||
PRAutoBlogger_Rerun_Executor::REPLAY_ACTION,
|
||||
array( $request['run_id'], $request['post_id'], $request['stage'], $request['agent_role'], $request['item_key'] ),
|
||||
sprintf(
|
||||
/* translators: %s: stage display label. */
|
||||
__( 'Queued: re-running %s with the edited input…', 'prautoblogger' ),
|
||||
PRAutoBlogger_Stage_Display_Map::label( $request['stage'] )
|
||||
)
|
||||
);
|
||||
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'queued' => true,
|
||||
'message' => __( 'Re-run queued. The pipeline will pick it up shortly — progress appears here and on the board.', 'prautoblogger' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: queue re-run-from-here (rebuild this stage + downstream).
|
||||
*
|
||||
* @return void Sends JSON.
|
||||
*/
|
||||
public function on_rerun_from(): void {
|
||||
$request = $this->authorize_and_resolve();
|
||||
|
||||
$check = PRAutoBlogger_Rerun_Eligibility::check_rebuild(
|
||||
$request['run_id'],
|
||||
$request['post_id'],
|
||||
$request['stage'],
|
||||
$request['item_key']
|
||||
);
|
||||
if ( ! $check['ok'] ) {
|
||||
wp_send_json_error( array( 'message' => $check['reason'] ), 400 );
|
||||
return;
|
||||
}
|
||||
|
||||
PRAutoBlogger_Rerun_Job_Support::queue(
|
||||
PRAutoBlogger_Rerun_Executor::REBUILD_ACTION,
|
||||
array( $request['run_id'], $request['post_id'], $request['stage'], $request['item_key'] ),
|
||||
sprintf(
|
||||
/* translators: %s: stage display label. */
|
||||
__( 'Queued: re-running the pipeline from %s…', 'prautoblogger' ),
|
||||
PRAutoBlogger_Stage_Display_Map::label( $request['stage'] )
|
||||
)
|
||||
);
|
||||
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'queued' => true,
|
||||
'message' => __( 'Re-run from here queued. Stages will rebuild in sequence — progress appears here and on the board.', 'prautoblogger' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: read-only stage-status poll for the dossier.
|
||||
*
|
||||
* @return void Sends JSON: run status, per-stage state, live message.
|
||||
*/
|
||||
public function on_stage_status(): void {
|
||||
$request = $this->authorize_and_resolve( false );
|
||||
|
||||
$stages = array();
|
||||
foreach ( PRAutoBlogger_Run_Stage_State::stages_for_item( $request['run_id'], $request['item_key'] ) as $row ) {
|
||||
$stages[] = array(
|
||||
'stage' => (string) ( $row['stage'] ?? '' ),
|
||||
'agent_role' => (string) ( $row['agent_role'] ?? '' ),
|
||||
'status' => (string) ( $row['status'] ?? '' ),
|
||||
'stale' => ! empty( $row['stale'] ),
|
||||
'human_modified' => ! empty( $row['human_modified'] ),
|
||||
'attempt' => (int) ( $row['attempt'] ?? 1 ),
|
||||
'finished_at' => (string) ( $row['finished_at'] ?? '' ),
|
||||
);
|
||||
}
|
||||
|
||||
$live = get_transient( PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT );
|
||||
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'run_status' => (string) ( PRAutoBlogger_Run_State::get_status( $request['run_id'] ) ?? '' ),
|
||||
'stages' => $stages,
|
||||
'live' => is_array( $live ) ? $live : null,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared gate: nonce + capability, then derive run identity from the
|
||||
* POSTed post_id server-side. Sends a JSON error (and exits) on any
|
||||
* failure.
|
||||
*
|
||||
* @param bool $require_stage Whether the request must name a stage row.
|
||||
* @return array{post_id: int, run_id: string, item_key: string, stage: string, agent_role: string}
|
||||
*/
|
||||
private function authorize_and_resolve( bool $require_stage = true ): array {
|
||||
check_ajax_referer( self::NONCE_ACTION, 'nonce' );
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
|
||||
}
|
||||
|
||||
$post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0;
|
||||
$run_id = $post_id > 0 ? (string) get_post_meta( $post_id, '_prautoblogger_run_id', true ) : '';
|
||||
if ( 0 === $post_id || '' === $run_id ) {
|
||||
wp_send_json_error( array( 'message' => __( 'No generation run is recorded for this article.', 'prautoblogger' ) ), 400 );
|
||||
}
|
||||
$idea_hash = (string) get_post_meta( $post_id, '_prautoblogger_idea_hash', true );
|
||||
$item_key = '' !== $idea_hash ? 'idea:' . $idea_hash : '';
|
||||
|
||||
$stage = isset( $_POST['stage'] ) ? sanitize_key( (string) wp_unslash( $_POST['stage'] ) ) : '';
|
||||
$agent_role = isset( $_POST['agent_role'] ) ? sanitize_key( (string) wp_unslash( $_POST['agent_role'] ) ) : '';
|
||||
if ( $require_stage && '' === $stage ) {
|
||||
wp_send_json_error( array( 'message' => __( 'No stage specified.', 'prautoblogger' ) ), 400 );
|
||||
}
|
||||
|
||||
return array(
|
||||
'post_id' => $post_id,
|
||||
'run_id' => $run_id,
|
||||
'item_key' => $item_key,
|
||||
'stage' => $stage,
|
||||
'agent_role' => $agent_role,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The base body an edit forks from: latest saved fork, else the
|
||||
* stage's recorded request_json.
|
||||
*
|
||||
* @param array<string, mixed> $request Resolved request context.
|
||||
* @return array<string, mixed>|null Decoded body, or null when absent.
|
||||
*/
|
||||
private function resolve_base_body( array $request ): ?array {
|
||||
$fork = PRAutoBlogger_Stage_Input_Store::latest_fork( $request['run_id'], $request['stage'], $request['agent_role'], $request['item_key'] );
|
||||
if ( null !== $fork && ! empty( $fork['request_json'] ) ) {
|
||||
return PRAutoBlogger_Stage_Replay::decode_body( (string) $fork['request_json'] );
|
||||
}
|
||||
$log_rows = ( new PRAutoBlogger_Dossier_Gen_Log_Query() )->get_by_run( $request['run_id'] );
|
||||
$rows = $log_rows[ $request['stage'] ] ?? array();
|
||||
foreach ( array_reverse( $rows ) as $row ) {
|
||||
if ( ! empty( $row['request_json'] ) ) {
|
||||
$body = PRAutoBlogger_Stage_Replay::decode_body( (string) $row['request_json'] );
|
||||
if ( null !== $body ) {
|
||||
return $body;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute edited message contents into the base body. Structure
|
||||
* is locked: same message count, same roles, all contents non-empty
|
||||
* strings — so every fork stays a replayable chat body.
|
||||
*
|
||||
* @param array<string, mixed> $base Decoded base body.
|
||||
* @param array<int, mixed>|null $messages Edited messages payload.
|
||||
* @return array<string, mixed>|null Merged body, or null when invalid.
|
||||
*/
|
||||
private function merge_messages( array $base, ?array $messages ): ?array {
|
||||
if ( null === $messages || count( $messages ) !== count( $base['messages'] ) ) {
|
||||
return null;
|
||||
}
|
||||
$merged = $base;
|
||||
foreach ( array_values( $messages ) as $i => $message ) {
|
||||
if ( ! is_array( $message ) || ! isset( $message['role'], $message['content'] )
|
||||
|| ! is_string( $message['role'] ) || ! is_string( $message['content'] ) ) {
|
||||
return null;
|
||||
}
|
||||
if ( ( $base['messages'][ $i ]['role'] ?? null ) !== $message['role'] ) {
|
||||
return null;
|
||||
}
|
||||
if ( '' === trim( $message['content'] ) ) {
|
||||
return null;
|
||||
}
|
||||
$merged['messages'][ $i ]['content'] = $message['content'];
|
||||
}
|
||||
return $merged;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,15 @@ declare(strict_types=1);
|
||||
* Legacy posts (no run row) return ['has_run' => false] -- never notices.
|
||||
* Amortized research rows (pv=null, role='') render gracefully.
|
||||
*
|
||||
* v0.20.0 (M3): stage rows are filtered to THIS post's item (item_key
|
||||
* from the idea-hash meta; multi-article runs no longer collide) and
|
||||
* gen_log rows exclude entries linked to OTHER posts; per-stage edit/
|
||||
* re-run affordance data + run spend ride the view model
|
||||
* (Dossier_Rerun_Panel_Data); log-only stages (image_a/image_b/
|
||||
* llm_research/... -- generation_log rows with no run_stages row) and
|
||||
* the post's pipeline attachments are exposed so the image sections
|
||||
* render from their REAL data sources (QA M2 F3).
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Dossier_Page::render_page().
|
||||
* Dependencies: PRAutoBlogger_Dossier_Gen_Log_Query, PRAutoBlogger_Stage_Display_Map,
|
||||
* PRAutoBlogger_Run_Stage_State.
|
||||
@@ -55,6 +64,16 @@ class PRAutoBlogger_Dossier_Data_Assembler {
|
||||
'stages' => array(),
|
||||
'decisions' => array(),
|
||||
'gen_log_index' => array(),
|
||||
'item_key' => '',
|
||||
'eligibility' => array(
|
||||
'ok' => false,
|
||||
'reason' => '',
|
||||
),
|
||||
'spend' => PRAutoBlogger_Dossier_Rerun_Panel_Data::spend( null ),
|
||||
'human_modified_any' => false,
|
||||
'stale_any' => false,
|
||||
'log_only_stages' => array(),
|
||||
'images' => array(),
|
||||
);
|
||||
|
||||
if ( $post_id <= 0 ) {
|
||||
@@ -82,11 +101,40 @@ class PRAutoBlogger_Dossier_Data_Assembler {
|
||||
return $base;
|
||||
}
|
||||
|
||||
$idea_hash = (string) get_post_meta( $post_id, '_prautoblogger_idea_hash', true );
|
||||
$item_key = '' !== $idea_hash ? 'idea:' . $idea_hash : '';
|
||||
$base['item_key'] = $item_key;
|
||||
|
||||
$base['has_run'] = true;
|
||||
$base['run'] = $run;
|
||||
$base['stages'] = $this->get_stage_rows( $run_id );
|
||||
$base['stages'] = $this->get_stage_rows( $run_id, $item_key );
|
||||
$base['decisions'] = $this->get_decision_rows( $run_id );
|
||||
$base['gen_log_index'] = $this->log_query->get_by_run( $run_id );
|
||||
$base['gen_log_index'] = $this->filter_log_for_post( $this->log_query->get_by_run( $run_id ), $post_id );
|
||||
$base['eligibility'] = PRAutoBlogger_Rerun_Eligibility::check( $run_id, $post_id );
|
||||
$base['spend'] = PRAutoBlogger_Dossier_Rerun_Panel_Data::spend( $run );
|
||||
|
||||
foreach ( $base['stages'] as $role_key => $stage_row ) {
|
||||
$stage_name = (string) ( $stage_row['stage'] ?? '' );
|
||||
$rerun = PRAutoBlogger_Dossier_Rerun_Panel_Data::for_stage(
|
||||
$stage_row,
|
||||
$base['gen_log_index'][ $stage_name ] ?? array(),
|
||||
$run_id,
|
||||
$base['eligibility']
|
||||
);
|
||||
$base['stages'][ $role_key ]['rerun'] = $rerun;
|
||||
$base['human_modified_any'] = $base['human_modified_any'] || $rerun['human_modified'];
|
||||
$base['stale_any'] = $base['stale_any'] || $rerun['stale'];
|
||||
}
|
||||
|
||||
// QA M2 F3: stages that exist only in generation_log (image_a/
|
||||
// image_b, llm_research, image_prompt_rewrite, ...) render from
|
||||
// their real source instead of silently disappearing.
|
||||
$run_stage_names = array();
|
||||
foreach ( $base['stages'] as $stage_row ) {
|
||||
$run_stage_names[ (string) ( $stage_row['stage'] ?? '' ) ] = true;
|
||||
}
|
||||
$base['log_only_stages'] = array_values( array_diff( array_keys( $base['gen_log_index'] ), array_keys( $run_stage_names ) ) );
|
||||
$base['images'] = PRAutoBlogger_Dossier_Image_Data::for_post( $post_id );
|
||||
|
||||
return $base;
|
||||
}
|
||||
@@ -112,12 +160,14 @@ class PRAutoBlogger_Dossier_Data_Assembler {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all run_stages rows for a run, indexed by stage name.
|
||||
* Fetch run_stages rows for a run, indexed by stage name — scoped to
|
||||
* one item (+ run-level rows) when an item key is known.
|
||||
*
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @param string $item_key Item key ('' = no scoping, M2 behavior).
|
||||
* @return array<string, array<string, mixed>> Stage rows keyed by stage name.
|
||||
*/
|
||||
private function get_stage_rows( string $run_id ): array {
|
||||
private function get_stage_rows( string $run_id, string $item_key = '' ): array {
|
||||
if ( ! PRAutoBlogger_Run_Stage_State::is_available() ) {
|
||||
return array();
|
||||
}
|
||||
@@ -126,11 +176,22 @@ class PRAutoBlogger_Dossier_Data_Assembler {
|
||||
return array();
|
||||
}
|
||||
$table = PRAutoBlogger_Run_Stage_State::table_name();
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare( "SELECT * FROM {$table} WHERE run_id = %s ORDER BY id ASC", $run_id ),
|
||||
ARRAY_A
|
||||
);
|
||||
// v0.20.0: scope to THIS post's item + run-level rows. When the
|
||||
// idea-hash meta is absent (pre-v0.18 posts) fall back to the
|
||||
// unfiltered M2 behavior (single-item runs are unaffected).
|
||||
if ( '' !== $item_key ) {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare( "SELECT * FROM {$table} WHERE run_id = %s AND item_key IN (%s, '') ORDER BY id ASC", $run_id, $item_key ),
|
||||
ARRAY_A
|
||||
);
|
||||
} else {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare( "SELECT * FROM {$table} WHERE run_id = %s ORDER BY id ASC", $run_id ),
|
||||
ARRAY_A
|
||||
);
|
||||
}
|
||||
|
||||
$index = array();
|
||||
foreach ( ( $rows ?? array() ) as $row ) {
|
||||
@@ -145,6 +206,29 @@ class PRAutoBlogger_Dossier_Data_Assembler {
|
||||
return $index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop gen_log rows linked to OTHER posts (multi-article runs share
|
||||
* one run_id). Rows with no post linkage (NULL/0 — run-level work
|
||||
* and pre-link entries) are kept.
|
||||
*
|
||||
* @param array<string, array<int, array<string, mixed>>> $log_index Rows keyed by stage.
|
||||
* @param int $post_id This dossier's post.
|
||||
* @return array<string, array<int, array<string, mixed>>>
|
||||
*/
|
||||
private function filter_log_for_post( array $log_index, int $post_id ): array {
|
||||
$filtered = array();
|
||||
foreach ( $log_index as $stage => $rows ) {
|
||||
foreach ( $rows as $row ) {
|
||||
$row_post = (int) ( $row['post_id'] ?? 0 );
|
||||
if ( 0 !== $row_post && $row_post !== $post_id ) {
|
||||
continue;
|
||||
}
|
||||
$filtered[ $stage ][] = $row;
|
||||
}
|
||||
}
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the text output from a run_stages meta_json blob.
|
||||
* Returns null when meta_json is NULL (pruned) or absent.
|
||||
|
||||
82
includes/admin/class-dossier-image-data.php
Normal file
82
includes/admin/class-dossier-image-data.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Resolves a post's pipeline image attachments for the dossier (M3/F3).
|
||||
*
|
||||
* What: The image sections' REAL data source — the attachments the
|
||||
* image pipeline produced (featured + composed og/square variants,
|
||||
* discovered via the `_prautoblogger_image_role` attachment meta
|
||||
* and the post thumbnail), paired in the template with the
|
||||
* image_a/image_b generation_log rows. Replaces the M2 sections
|
||||
* that keyed off run_stages rows the image pipeline never writes
|
||||
* (QA M2 F3 — wiring the dossier to real data was the smaller,
|
||||
* safer change vs. teaching the live image pipeline to write
|
||||
* run_stages; the pipeline restructure is Phase 2b).
|
||||
* Who triggers it: PRAutoBlogger_Dossier_Data_Assembler::assemble().
|
||||
* Dependencies: get_children(), attachment meta, wp_get_attachment_image_url().
|
||||
*
|
||||
* @see admin/class-dossier-data-assembler.php — Caller.
|
||||
* @see templates/admin/dossier-log-stage-section.php — Renders the pairs.
|
||||
* @see ARCHITECTURE.md #24 — M3 design (F3 note).
|
||||
*/
|
||||
class PRAutoBlogger_Dossier_Image_Data {
|
||||
|
||||
/**
|
||||
* The post's pipeline attachments (featured/og/square) via the
|
||||
* `_prautoblogger_image_role` attachment meta — the image sections'
|
||||
* real data source (QA M2 F3).
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @return array<int, array{id: int, role: string, url: string, is_featured: bool}>
|
||||
*/
|
||||
public static function for_post( int $post_id ): array {
|
||||
$featured_id = (int) get_post_thumbnail_id( $post_id );
|
||||
$children = get_children(
|
||||
array(
|
||||
'post_parent' => $post_id,
|
||||
'post_type' => 'attachment',
|
||||
'numberposts' => 12,
|
||||
)
|
||||
);
|
||||
|
||||
$images = array();
|
||||
$seen = array();
|
||||
foreach ( (array) $children as $attachment ) {
|
||||
$att_id = (int) ( $attachment->ID ?? 0 );
|
||||
if ( $att_id <= 0 ) {
|
||||
continue;
|
||||
}
|
||||
$role = (string) get_post_meta( $att_id, '_prautoblogger_image_role', true );
|
||||
if ( '' === $role && $att_id !== $featured_id ) {
|
||||
continue; // Not a pipeline attachment.
|
||||
}
|
||||
$url = (string) wp_get_attachment_image_url( $att_id, 'medium' );
|
||||
if ( '' === $url ) {
|
||||
continue;
|
||||
}
|
||||
$seen[ $att_id ] = true;
|
||||
$images[] = array(
|
||||
'id' => $att_id,
|
||||
'role' => '' !== $role ? $role : 'featured',
|
||||
'url' => $url,
|
||||
'is_featured' => $att_id === $featured_id,
|
||||
);
|
||||
}
|
||||
// Featured image attached elsewhere (not a child) still renders.
|
||||
if ( $featured_id > 0 && ! isset( $seen[ $featured_id ] ) ) {
|
||||
$url = (string) wp_get_attachment_image_url( $featured_id, 'medium' );
|
||||
if ( '' !== $url ) {
|
||||
$images[] = array(
|
||||
'id' => $featured_id,
|
||||
'role' => 'featured',
|
||||
'url' => $url,
|
||||
'is_featured' => true,
|
||||
);
|
||||
}
|
||||
}
|
||||
return $images;
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,42 @@ class PRAutoBlogger_Dossier_Page {
|
||||
PRAUTOBLOGGER_VERSION,
|
||||
true
|
||||
);
|
||||
|
||||
// M3: edit + re-run layer (separate files; dossier.css is not grown).
|
||||
wp_enqueue_style(
|
||||
'prautoblogger-dossier-edit',
|
||||
PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/dossier-edit.css',
|
||||
array( 'prautoblogger-dossier' ),
|
||||
PRAUTOBLOGGER_VERSION
|
||||
);
|
||||
wp_enqueue_script(
|
||||
'prautoblogger-dossier-edit',
|
||||
PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/dossier-edit.js',
|
||||
array(),
|
||||
PRAUTOBLOGGER_VERSION,
|
||||
true
|
||||
);
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only context resolution for script config.
|
||||
$post_id = isset( $_GET['post_id'] ) ? absint( $_GET['post_id'] ) : 0;
|
||||
wp_localize_script(
|
||||
'prautoblogger-dossier-edit',
|
||||
'prabDossierEdit',
|
||||
array(
|
||||
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||
'nonce' => wp_create_nonce( PRAutoBlogger_Dossier_Actions::NONCE_ACTION ),
|
||||
'postId' => $post_id,
|
||||
// Settings read at point of use -- same poll setting as the board.
|
||||
'pollInterval' => max( 3, (int) get_option( 'prautoblogger_board_poll_interval', 5 ) ),
|
||||
'strings' => array(
|
||||
'saving' => __( 'Saving…', 'prautoblogger' ),
|
||||
'error' => __( 'Request failed — try again.', 'prautoblogger' ),
|
||||
'queued' => __( 'Queued…', 'prautoblogger' ),
|
||||
'rerunFromLabel' => __( 'Re-run from here', 'prautoblogger' ),
|
||||
'confirmReplay' => __( 'Queue a re-run of this stage with your edited input? This makes a new LLM call billed against this run\'s ceiling, and all downstream stages will be marked stale (nothing re-runs automatically).', 'prautoblogger' ),
|
||||
'confirmRerunFrom' => __( 'Queue a re-run of this stage AND every stage after it? Each re-run stage makes new LLM calls billed against this run\'s ceiling. The article draft will be refreshed; published posts are never touched.', 'prautoblogger' ),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
181
includes/admin/class-dossier-rerun-panel-data.php
Normal file
181
includes/admin/class-dossier-rerun-panel-data.php
Normal file
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Builds the edit + re-run view-model layer for the dossier (v0.20.0, M3).
|
||||
*
|
||||
* What: Per-stage affordance data (editable? why not? latest fork,
|
||||
* prefill messages from the fork or the stage's recorded
|
||||
* request_json) plus the run-level spend strip (settled + reserved
|
||||
* vs ceiling, warn fraction — guardrail 4's visible cost
|
||||
* implication) and the overall eligibility verdict. Pure
|
||||
* assembly: reads the rows the Dossier_Data_Assembler already
|
||||
* fetched plus the stage_inputs store; performs NO mutations.
|
||||
* Disabled affordances always carry an operator-readable reason —
|
||||
* the UI never hides why an edit is unavailable.
|
||||
* Who triggers it: PRAutoBlogger_Dossier_Data_Assembler::assemble().
|
||||
* Dependencies: Rerun_Eligibility (policy), Stage_Input_Store (forks),
|
||||
* Stage_Replay (body decode).
|
||||
*
|
||||
* @see admin/class-dossier-data-assembler.php — Caller.
|
||||
* @see templates/admin/dossier-edit-panel.php — Renders this data.
|
||||
* @see ARCHITECTURE.md #24 — Edit + re-run design.
|
||||
*/
|
||||
class PRAutoBlogger_Dossier_Rerun_Panel_Data {
|
||||
|
||||
/** Default warn threshold as a fraction of the per-run ceiling. */
|
||||
private const DEFAULT_WARN_FRACTION = 0.8;
|
||||
|
||||
/**
|
||||
* Run-level spend strip data (guardrail 4 visibility).
|
||||
*
|
||||
* @param array<string, mixed>|null $run Run ledger row, or null.
|
||||
* @return array{settled: float, reserved: float, ceiling: float, fraction: float, warn: bool}
|
||||
*/
|
||||
public static function spend( ?array $run ): array {
|
||||
$settled = null !== $run ? (float) ( $run['settled_usd'] ?? 0 ) : 0.0;
|
||||
$reserved = null !== $run ? (float) ( $run['reserved_usd'] ?? 0 ) : 0.0;
|
||||
$ceiling = null !== $run ? (float) ( $run['ceiling_usd'] ?? 0 ) : 0.0;
|
||||
$fraction = $ceiling > 0 ? ( $settled + $reserved ) / $ceiling : 0.0;
|
||||
|
||||
/**
|
||||
* Filters the spend fraction at which re-run confirms warn about
|
||||
* approaching the per-run ceiling.
|
||||
*
|
||||
* @param float $warn_fraction Fraction of ceiling (default 0.8).
|
||||
*/
|
||||
$warn_fraction = (float) apply_filters( 'prautoblogger_rerun_ceiling_warn_fraction', self::DEFAULT_WARN_FRACTION );
|
||||
|
||||
return array(
|
||||
'settled' => $settled,
|
||||
'reserved' => $reserved,
|
||||
'ceiling' => $ceiling,
|
||||
'fraction' => $fraction,
|
||||
'warn' => $ceiling > 0 && $fraction >= $warn_fraction,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit + re-run affordance data for one stage row.
|
||||
*
|
||||
* @param array<string, mixed> $stage_row run_stages row.
|
||||
* @param array<int, array<string, mixed>> $log_rows generation_log rows for the stage.
|
||||
* @param string $run_id Run UUID.
|
||||
* @param array{ok: bool, reason: string} $eligibility Overall run eligibility.
|
||||
* @return array<string, mixed> Affordance view model (see keys below).
|
||||
*/
|
||||
public static function for_stage( array $stage_row, array $log_rows, string $run_id, array $eligibility ): array {
|
||||
$stage = (string) ( $stage_row['stage'] ?? '' );
|
||||
$role = (string) ( $stage_row['agent_role'] ?? '' );
|
||||
$item_key = (string) ( $stage_row['item_key'] ?? '' );
|
||||
|
||||
$data = array(
|
||||
'stale' => ! empty( $stage_row['stale'] ),
|
||||
'human_modified' => ! empty( $stage_row['human_modified'] ),
|
||||
'attempt' => (int) ( $stage_row['attempt'] ?? 1 ),
|
||||
'editable' => false,
|
||||
'edit_reason' => '',
|
||||
'fork' => null,
|
||||
'prefill' => null,
|
||||
'rerun_from' => $eligibility['ok'] && PRAutoBlogger_Rerun_Eligibility::is_chain_stage( $stage ),
|
||||
);
|
||||
|
||||
if ( ! PRAutoBlogger_Rerun_Eligibility::is_editable_stage( $stage ) ) {
|
||||
$data['edit_reason'] = self::policy_reason( $stage );
|
||||
return $data;
|
||||
}
|
||||
if ( ! $eligibility['ok'] ) {
|
||||
$data['edit_reason'] = $eligibility['reason'];
|
||||
return $data;
|
||||
}
|
||||
|
||||
$fork = PRAutoBlogger_Stage_Input_Store::latest_fork( $run_id, $stage, $role, $item_key );
|
||||
if ( null !== $fork ) {
|
||||
$data['fork'] = array(
|
||||
'version' => (int) $fork['version'],
|
||||
'author' => (string) $fork['author'],
|
||||
'created_at' => (string) $fork['created_at'],
|
||||
'has_body' => ! empty( $fork['request_json'] ),
|
||||
);
|
||||
}
|
||||
|
||||
$base_json = ( null !== $fork && ! empty( $fork['request_json'] ) )
|
||||
? (string) $fork['request_json']
|
||||
: self::latest_request_json( $log_rows );
|
||||
|
||||
if ( null === $base_json ) {
|
||||
$days = (int) get_option( 'prautoblogger_request_json_retention_days', PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS );
|
||||
$data['edit_reason'] = sprintf(
|
||||
/* translators: %d: retention days. */
|
||||
__( 'No persisted input for this stage — request bodies are recorded from v0.20.0 onward and retained for %d days.', 'prautoblogger' ),
|
||||
$days
|
||||
);
|
||||
return $data;
|
||||
}
|
||||
|
||||
$body = PRAutoBlogger_Stage_Replay::decode_body( $base_json );
|
||||
if ( null === $body ) {
|
||||
$data['edit_reason'] = __( 'The persisted input for this stage is not a replayable chat request.', 'prautoblogger' );
|
||||
return $data;
|
||||
}
|
||||
|
||||
$data['editable'] = true;
|
||||
$data['prefill'] = array(
|
||||
'model' => (string) $body['model'],
|
||||
'messages' => $body['messages'],
|
||||
);
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a non-writer stage has no edit affordance (always shown —
|
||||
* the UI never silently hides the option).
|
||||
*
|
||||
* @param string $stage Stage name.
|
||||
* @return string Operator-readable reason.
|
||||
*/
|
||||
private static function policy_reason( string $stage ): string {
|
||||
if ( 'review' === $stage ) {
|
||||
return __( 'The review input is derived entirely from the article content — edit a writing stage, then use "Re-run from here" on this one.', 'prautoblogger' );
|
||||
}
|
||||
if ( 'publish' === $stage ) {
|
||||
return __( 'Publish is not an LLM stage — use "Re-run from here" to refresh the post from current outputs.', 'prautoblogger' );
|
||||
}
|
||||
if ( in_array( $stage, array( 'analysis', 'research', 'llm_research' ), true ) ) {
|
||||
return __( 'Run-level stage: its output fed idea selection for the whole run and cannot be re-flowed into a single article.', 'prautoblogger' );
|
||||
}
|
||||
if ( 0 === strpos( $stage, 'image' ) ) {
|
||||
return __( 'Image stages are not replayable yet (Phase 2b restructure).', 'prautoblogger' );
|
||||
}
|
||||
return __( 'This stage cannot be replayed with an edited input.', 'prautoblogger' );
|
||||
}
|
||||
|
||||
/**
|
||||
* The most recent recorded request body among a stage's log rows
|
||||
* (success rows preferred, then latest id).
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $log_rows generation_log rows.
|
||||
* @return string|null
|
||||
*/
|
||||
private static function latest_request_json( array $log_rows ): ?string {
|
||||
$best = null;
|
||||
foreach ( $log_rows as $row ) {
|
||||
if ( empty( $row['request_json'] ) ) {
|
||||
continue;
|
||||
}
|
||||
if ( null === $best ) {
|
||||
$best = $row;
|
||||
continue;
|
||||
}
|
||||
$row_success = ( 'success' === ( $row['response_status'] ?? '' ) );
|
||||
$best_success = ( 'success' === ( $best['response_status'] ?? '' ) );
|
||||
if ( ( $row_success && ! $best_success )
|
||||
|| ( $row_success === $best_success && (int) ( $row['id'] ?? 0 ) > (int) ( $best['id'] ?? 0 ) ) ) {
|
||||
$best = $row;
|
||||
}
|
||||
}
|
||||
return null !== $best ? (string) $best['request_json'] : null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user