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:
@@ -64,6 +64,7 @@ class PRAutoBlogger_Chief_Editor {
|
||||
'temperature' => 0.3,
|
||||
'max_tokens' => 5000,
|
||||
'response_format' => array( 'type' => 'json_object' ),
|
||||
'stage' => 'review',
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ class PRAutoBlogger_Content_Analyzer {
|
||||
'temperature' => 0.5,
|
||||
'max_tokens' => 8000,
|
||||
'response_format' => array( 'type' => 'json_object' ),
|
||||
'stage' => 'analysis',
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -197,6 +197,12 @@ class PRAutoBlogger_Content_Generator {
|
||||
PRAutoBlogger_Run_Stage_State::start( $this->run_id, $stage, '', $this->item_key );
|
||||
}
|
||||
|
||||
// v0.18.1: stage metadata for the provider's empty-completion
|
||||
// guard — warning logs and failed-attempt audit rows carry the
|
||||
// real stage + registry key (never sent upstream).
|
||||
$options['stage'] = $stage;
|
||||
$options['prompt_key'] = $prompt_key;
|
||||
|
||||
$ctx = PRAutoBlogger_Opik_Trace_Context::current();
|
||||
|
||||
$span_id = $ctx->start_span(
|
||||
@@ -223,6 +229,21 @@ class PRAutoBlogger_Content_Generator {
|
||||
$options
|
||||
);
|
||||
|
||||
// v0.18.1 writer-path guard (belt over the provider seam, which
|
||||
// already throws for OpenRouter): no empty article may ever leave
|
||||
// a writing stage — it would flow to the editor, be rejected as
|
||||
// "draft text is missing", and land as an empty draft post.
|
||||
if ( '' === trim( (string) $response['content'] ) ) {
|
||||
throw new \RuntimeException(
|
||||
sprintf(
|
||||
/* translators: 1: writing stage name, 2: model identifier. */
|
||||
__( 'Writing stage "%1$s" produced empty content (model %2$s) — failing the stage instead of passing an empty article downstream.', 'prautoblogger' ),
|
||||
$stage,
|
||||
(string) $response['model']
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$ctx->end_span(
|
||||
$span_id,
|
||||
array(
|
||||
|
||||
@@ -200,6 +200,7 @@ PROMPT;
|
||||
array(
|
||||
'temperature' => 0.7,
|
||||
'max_tokens' => self::REWRITER_MAX_TOKENS,
|
||||
'stage' => 'image_prompt_rewrite',
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -82,6 +82,23 @@ class PRAutoBlogger_Publisher {
|
||||
?string $run_id = null,
|
||||
?PRAutoBlogger_Cost_Tracker $cost_tracker = null
|
||||
): int {
|
||||
// v0.18.1 belt-and-braces: never create a post whose content is
|
||||
// empty once tags and whitespace are stripped — an empty "draft
|
||||
// for human review" (2026-06-11 incident, post 921) reviews
|
||||
// nothing and hides the underlying generation failure. The
|
||||
// provider/writer guards make this unreachable on OpenRouter
|
||||
// paths; this protects every other path into post creation.
|
||||
if ( '' === trim( wp_strip_all_tags( $content ) ) ) {
|
||||
throw new \RuntimeException(
|
||||
sprintf(
|
||||
/* translators: 1: intended post status, 2: article title. */
|
||||
__( 'Refusing to create %1$s post "%2$s": generated content is empty.', 'prautoblogger' ),
|
||||
$post_status,
|
||||
$idea->get_suggested_title()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// v0.18.0 idempotency: post creation is keyed by run + idea
|
||||
// (check-before-insert) so a retried/resumed run cannot create a
|
||||
// duplicate post. One run_id spans all N articles of a batch, so
|
||||
|
||||
Reference in New Issue
Block a user