fix: empty-content guard + finish_reason=length handling + reasoning budget (v0.18.1)

Reasoning models at high effort could spend the entire max_tokens budget
on thinking and return zero visible content with finish_reason=length;
the empty article flowed writer -> editor (reject) -> empty draft post,
with the cost booked as success (2026-06-11 incident: prod run acf24029,
draft post 921; diagnosis: convo pipeline-v2-phase1 seq 12).

- Empty-completion guard at the provider seam (new
  OpenRouter_Completion_Guard): empty/whitespace content is a failure;
  one retry with reasoning disabled (per-call override) when
  finish_reason=length or reasoning was active, then the stage fails.
- finish_reason=length always warning-logged (model, stage, tokens).
- Failed/retried attempts booked to generation_log with
  response_status=error + real token burn; run_decisions verdict rows
  (retry_reasoning_disabled / failed_empty_completion) for v0.18.0 runs.
- Writer-path guard in Content_Generator::execute_stage and
  belt-and-braces empty check in Publisher::create_post.
- Reasoning budget sanity: reasoning.max_tokens cap + completion
  headroom via new prautoblogger_reasoning_max_tokens setting (default
  constant 2048, 0 = legacy uncapped effort mode); cap replaces effort
  when active; governor reservations use the effective ceiling.
- Body assembly moved to Request_Builder::build_body (300-line cap).
- Tests: guard verdict matrix, builder budget shaping, writer/publisher
  guards, full provider retry path.

Agent-Session: prab-empty-draft-guard-20260611
This commit is contained in:
peptiderepo
2026-06-11 13:15:58 +00:00
parent f5658f1c97
commit 2bdc763dc0
19 changed files with 1196 additions and 34 deletions

View File

@@ -36,6 +36,10 @@ class PublisherTest extends BaseTestCase {
Functions\when( 'sanitize_text_field' )->returnArg();
Functions\when( 'wp_kses_post' )->returnArg();
// v0.18.1 empty-content guard strips tags before the emptiness check.
Functions\when( 'wp_strip_all_tags' )->alias( function ( $text ) {
return trim( strip_tags( (string) $text ) );
} );
Functions\when( 'is_wp_error' )->justReturn( false );
Functions\when( 'apply_filters' )->returnArg( 2 );
Functions\when( 'absint' )->alias( function ( $val ) { return abs( (int) $val ); } );
@@ -295,4 +299,35 @@ class PublisherTest extends BaseTestCase {
$publisher = new \PRAutoBlogger_Publisher();
$publisher->publish( '<p>Content</p>', $this->idea, $this->review );
}
/**
* v0.18.1 belt-and-braces: save_as_draft() with empty content throws
* and never reaches wp_insert_post — no empty draft post can be
* created (the 2026-06-11 post-921 incident shape).
*/
public function test_save_as_draft_refuses_empty_content(): void {
Functions\expect( 'wp_insert_post' )->never();
$publisher = new \PRAutoBlogger_Publisher();
$this->expectException( \RuntimeException::class );
$this->expectExceptionMessage( 'generated content is empty' );
$publisher->save_as_draft( '', $this->idea, $this->review, 'run_empty_draft' );
}
/**
* v0.18.1 belt-and-braces: publish() refuses content that is empty
* once tags and whitespace are stripped (an empty HTML shell).
*/
public function test_publish_refuses_whitespace_only_html_content(): void {
Functions\expect( 'wp_insert_post' )->never();
$publisher = new \PRAutoBlogger_Publisher();
$this->expectException( \RuntimeException::class );
$this->expectExceptionMessage( 'generated content is empty' );
$publisher->publish( "<p> \n\t </p>", $this->idea, $this->review );
}
}