feat(prompts): versioned prompt registry, v1 seed, pinning + stamping

Pipeline v2 Phase 1, commit 2/4. Prompt_Registry (read) +
Prompt_Registry_Writer (immutable create/activate/seed) over the new
prompts table; canonical v1 bodies extracted verbatim into
Prompt_Defaults(+Editorial) — single source for BOTH the seed migration
(Migrate_Prompt_Seed_V0180, flag-gated, self-healing) and the in-code
fallback, so a missing table renders byte-identical prompts and never
fatals. Call sites swapped: Content_Prompts, Analysis_Prompts,
Chief_Editor, LLM_Research_Provider. Includes the image-composer seam
keys image.rewriter_system + image.style_template (composer PR consumes,
must not add its own prompt storage).

Pinning: Cost_Tracker::set_run_id() registers the process run context,
ensures the runs ledger row, and pins active versions once per run;
log_api_call/log_image_generation stamp prompt_version (run pins) +
agent_role (stage map) on every generation_log row. Single-pass keeps
stage 'draft' and stamps content.single_pass explicitly.

Under-300 refactors: Content_Generator's four duplicated span/log blocks
consolidated into execute_stage() (301 -> 224 lines, behavior
identical); get_avg_tokens_for_stages body moved to Cost_Reporter with a
delegating wrapper kept for API compatibility.

Tests: byte-identical fallback renders (single_pass/outline/polish/
editor.system), token filling, key coverage incl. stage-map integrity.

Agent-Session: pipeline-v2-phase1-engineer-20260612
This commit is contained in:
peptiderepo
2026-06-11 04:12:32 +00:00
parent 809e7176ad
commit 1b04809092
21 changed files with 1712 additions and 386 deletions

235
includes/core/class-content-generator.php Executable file → Normal file
View File

@@ -6,6 +6,10 @@ declare(strict_types=1);
*
* Supports: single_pass (one LLM call) or multi_step (outline → draft → polish).
* Opik instrumentation: spans per LLM call when feature-flag enabled.
* v0.18.0: prompt copy renders from the versioned prompt registry (with
* byte-identical in-code fallback); every log row carries the run-pinned
* prompt_version. The four stage methods share one execute_stage() helper —
* span lifecycle, LLM dispatch, and cost logging are identical across stages.
*
* Triggered by: PRAutoBlogger_Article_Worker.
* Dependencies: LLM_Provider_Interface, Cost_Tracker, Content_Prompts.
@@ -26,7 +30,8 @@ class PRAutoBlogger_Content_Generator {
/**
* Generate a blog post from an article idea.
*
* @param PRAutoBlogger_Article_Idea $idea The scored idea to generate content for.
* @param PRAutoBlogger_Article_Idea $idea The scored idea to generate content for.
* @param bool $eval_mode True to suppress publish/image side effects.
* @return string Generated HTML content.
*/
public function generate( PRAutoBlogger_Article_Idea $idea, bool $eval_mode = false ): string {
@@ -52,101 +57,47 @@ class PRAutoBlogger_Content_Generator {
}
/**
* Single-pass: one LLM call produces complete article.
* Single-pass: one LLM call produces complete article (Economy tier).
*
* The log row's stage stays 'draft' (historical contract: a draft row
* without an outline row marks a single-pass run) but the prompt key
* is passed explicitly — the stage map would resolve 'draft' to
* 'content.draft'.
*
* @param PRAutoBlogger_Content_Request $request Generation request.
* @return string Generated HTML content.
*/
private function generate_single_pass( PRAutoBlogger_Content_Request $request ): string {
$model = get_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
$ctx = PRAutoBlogger_Opik_Trace_Context::current();
$span_id = $ctx->start_span(
array(
'name' => 'single_pass_generation',
'type' => 'llm',
'model' => $model,
'provider' => 'openrouter',
)
);
$response = $this->llm->send_chat_completion(
array(
array(
'role' => 'system',
'content' => PRAutoBlogger_Content_Prompts::build_system( $request ),
),
array(
'role' => 'user',
'content' => PRAutoBlogger_Content_Prompts::build_single_pass( $request ),
),
),
return $this->execute_stage(
'draft',
'single_pass_generation',
PRAutoBlogger_Content_Prompts::build_single_pass( $request ),
$request,
$model,
array(
'temperature' => 0.7,
'max_tokens' => 4000,
)
),
'content.single_pass'
);
$ctx->end_span(
$span_id,
array(
'usage' => array(
'prompt_tokens' => $response['prompt_tokens'],
'completion_tokens' => $response['completion_tokens'],
'total_tokens' => $response['prompt_tokens'] + $response['completion_tokens'],
),
)
);
$this->cost_tracker->log_api_call(
null,
'draft',
$this->llm->get_provider_name(),
$response['model'],
$response['prompt_tokens'],
$response['completion_tokens']
);
return $response['content'];
}
/**
* Multi-step: outline → draft → polish.
*
* @param PRAutoBlogger_Content_Request $request Generation request.
* @return string Generated HTML content.
*/
private function generate_multi_step( PRAutoBlogger_Content_Request $request ): string {
$model = get_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
$outline = $this->stage_outline( $request, $model );
$draft = $this->stage_draft( $request, $outline, $model );
$polished = $this->stage_polish( $request, $draft, $model );
return $polished;
}
/**
* Stage 1: Generate article outline.
*/
private function stage_outline( PRAutoBlogger_Content_Request $request, string $model ): string {
$ctx = PRAutoBlogger_Opik_Trace_Context::current();
$span_id = $ctx->start_span(
array(
'name' => 'outline_generation',
'type' => 'llm',
'model' => $model,
'provider' => 'openrouter',
)
);
$response = $this->llm->send_chat_completion(
array(
array(
'role' => 'system',
'content' => PRAutoBlogger_Content_Prompts::build_system( $request ),
),
array(
'role' => 'user',
'content' => PRAutoBlogger_Content_Prompts::build_outline( $request ),
),
),
$outline = $this->execute_stage(
'outline',
'outline_generation',
PRAutoBlogger_Content_Prompts::build_outline( $request ),
$request,
$model,
array(
'temperature' => 0.5,
@@ -154,55 +105,11 @@ class PRAutoBlogger_Content_Generator {
)
);
$ctx->end_span(
$span_id,
array(
'usage' => array(
'prompt_tokens' => $response['prompt_tokens'],
'completion_tokens' => $response['completion_tokens'],
'total_tokens' => $response['prompt_tokens'] + $response['completion_tokens'],
),
)
);
$this->cost_tracker->log_api_call(
null,
'outline',
$this->llm->get_provider_name(),
$response['model'],
$response['prompt_tokens'],
$response['completion_tokens']
);
return $response['content'];
}
/**
* Stage 2: Write full draft from outline.
*/
private function stage_draft( PRAutoBlogger_Content_Request $request, string $outline, string $model ): string {
$ctx = PRAutoBlogger_Opik_Trace_Context::current();
$span_id = $ctx->start_span(
array(
'name' => 'draft_generation',
'type' => 'llm',
'model' => $model,
'provider' => 'openrouter',
)
);
$response = $this->llm->send_chat_completion(
array(
array(
'role' => 'system',
'content' => PRAutoBlogger_Content_Prompts::build_system( $request ),
),
array(
'role' => 'user',
'content' => PRAutoBlogger_Content_Prompts::build_draft( $request, $outline ),
),
),
$draft = $this->execute_stage(
'draft',
'draft_generation',
PRAutoBlogger_Content_Prompts::build_draft( $request, $outline ),
$request,
$model,
array(
'temperature' => 0.7,
@@ -210,38 +117,53 @@ class PRAutoBlogger_Content_Generator {
)
);
$ctx->end_span(
$span_id,
return $this->execute_stage(
'polish',
'polish_generation',
PRAutoBlogger_Content_Prompts::build_polish( $draft ),
$request,
$model,
array(
'usage' => array(
'prompt_tokens' => $response['prompt_tokens'],
'completion_tokens' => $response['completion_tokens'],
'total_tokens' => $response['prompt_tokens'] + $response['completion_tokens'],
),
'temperature' => 0.4,
'max_tokens' => 4000,
)
);
$this->cost_tracker->log_api_call(
null,
'draft',
$this->llm->get_provider_name(),
$response['model'],
$response['prompt_tokens'],
$response['completion_tokens']
);
return $response['content'];
}
/**
* Stage 3: Polish and refine draft.
* Run one writing stage: Opik span → LLM call → span close → cost log.
*
* Consolidates the per-stage boilerplate that was previously duplicated
* across the four stage methods (v0.18.0). The system prompt is rebuilt
* per stage exactly as before.
*
* Side effects: one OpenRouter API call, one Opik span (when tracing),
* one generation_log row.
*
* @param string $stage Log stage value ('outline'|'draft'|'polish').
* @param string $span_name Opik span name.
* @param string $user_prompt Rendered user prompt for the stage.
* @param PRAutoBlogger_Content_Request $request Generation request (system prompt input).
* @param string $model Model identifier.
* @param array<string, mixed> $options LLM options (temperature, max_tokens).
* @param string|null $prompt_key Explicit registry key for prompt_version
* stamping (null = derive from stage).
* @return string Stage output content.
*/
private function stage_polish( PRAutoBlogger_Content_Request $request, string $draft, string $model ): string {
private function execute_stage(
string $stage,
string $span_name,
string $user_prompt,
PRAutoBlogger_Content_Request $request,
string $model,
array $options,
?string $prompt_key = null
): string {
$ctx = PRAutoBlogger_Opik_Trace_Context::current();
$span_id = $ctx->start_span(
array(
'name' => 'polish_generation',
'name' => $span_name,
'type' => 'llm',
'model' => $model,
'provider' => 'openrouter',
@@ -256,14 +178,11 @@ class PRAutoBlogger_Content_Generator {
),
array(
'role' => 'user',
'content' => PRAutoBlogger_Content_Prompts::build_polish( $draft ),
'content' => $user_prompt,
),
),
$model,
array(
'temperature' => 0.4,
'max_tokens' => 4000,
)
$options
);
$ctx->end_span(
@@ -279,11 +198,15 @@ class PRAutoBlogger_Content_Generator {
$this->cost_tracker->log_api_call(
null,
'polish',
$stage,
$this->llm->get_provider_name(),
$response['model'],
$response['prompt_tokens'],
$response['completion_tokens']
$response['completion_tokens'],
'success',
'',
null,
$prompt_key
);
return $response['content'];