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:
@@ -31,6 +31,7 @@ class PRAutoBlogger_Activator {
|
||||
self::set_default_options();
|
||||
self::schedule_cron();
|
||||
self::backpopulate_run_id_meta_v081();
|
||||
PRAutoBlogger_Migrate_Prompt_Seed_V0180::run();
|
||||
|
||||
// Store the DB version so we can run migrations on future updates.
|
||||
update_option( 'prautoblogger_db_version', PRAUTOBLOGGER_DB_VERSION, false );
|
||||
|
||||
59
includes/class-migrate-prompt-seed-v0180.php
Normal file
59
includes/class-migrate-prompt-seed-v0180.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* One-shot v0.18.0 migration — seed the versioned prompt registry.
|
||||
*
|
||||
* What: Writes the canonical hardcoded prompt texts (content, analysis,
|
||||
* editor, research, illustration rewriter, image Style Template)
|
||||
* into `wp_prautoblogger_prompts` as immutable version 1 and
|
||||
* activates them. Idempotent twice over: gated by the
|
||||
* `prautoblogger_migrated_prompt_seed_v0180` flag AND seed_v1()
|
||||
* itself skips any key that already has rows. The flag is only set
|
||||
* when the table was actually reachable, so a failed seed
|
||||
* self-heals on the next activation / version-mismatch pass.
|
||||
* Standalone class per the Migrate_Remove_Cloudflare_V0100
|
||||
* precedent (keeps Activator under the 300-line cap).
|
||||
* Who triggers it: PRAutoBlogger_Activator::activate() — on activation and
|
||||
* on every db-version mismatch.
|
||||
* Dependencies: PRAutoBlogger_Prompt_Registry(_Writer), get_option.
|
||||
*
|
||||
* @see class-activator.php — Sole caller.
|
||||
* @see core/class-prompt-registry-writer.php — seed_v1() implementation.
|
||||
* @see includes/class-migrate-remove-cloudflare-v0100.php — Pattern precedent.
|
||||
*/
|
||||
class PRAutoBlogger_Migrate_Prompt_Seed_V0180 {
|
||||
|
||||
/** Option flag marking the seed as complete. */
|
||||
private const FLAG = 'prautoblogger_migrated_prompt_seed_v0180';
|
||||
|
||||
/**
|
||||
* Run the seed once.
|
||||
*
|
||||
* Side effects: up to 12 INSERTs into the prompts table, one INFO log
|
||||
* line, one option write.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function run(): void {
|
||||
if ( get_option( self::FLAG ) ) {
|
||||
return;
|
||||
}
|
||||
if ( ! PRAutoBlogger_Prompt_Registry::is_available() ) {
|
||||
return; // Table missing — retry on the next migration pass.
|
||||
}
|
||||
|
||||
$seeded = PRAutoBlogger_Prompt_Registry_Writer::seed_v1( 'seed:v0.18.0' );
|
||||
|
||||
if ( class_exists( 'PRAutoBlogger_Logger', false ) ) {
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf( 'Prompt registry seeded: %d key(s) written as v1.', $seeded ),
|
||||
'activator'
|
||||
);
|
||||
}
|
||||
|
||||
update_option( self::FLAG, '1' );
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,8 @@ declare(strict_types=1);
|
||||
* What: Builds system and user prompts for source-data analysis, including
|
||||
* past-performance context for self-improvement feedback loops.
|
||||
* Who calls it: PRAutoBlogger_Content_Analyzer::analyze_recent_data().
|
||||
* Dependencies: WordPress $wpdb (for performance lookup), no external APIs.
|
||||
* Dependencies: PRAutoBlogger_Prompt_Registry (versioned prompt bodies),
|
||||
* WordPress $wpdb (for performance lookup), no external APIs.
|
||||
*
|
||||
* @see core/class-content-analyzer.php — Orchestrates analysis and calls these builders.
|
||||
* @see ARCHITECTURE.md — Data flow step 2.
|
||||
@@ -25,54 +26,26 @@ class PRAutoBlogger_Analysis_Prompts {
|
||||
* @return string System prompt text.
|
||||
*/
|
||||
public static function build_system_prompt( string $niche, string $performance_context, int $target_count = 6 ): string {
|
||||
$prompt = 'You are a content strategist analyzing social media discussions to find article ideas for a blog';
|
||||
if ( '' !== $niche ) {
|
||||
$prompt .= " in the {$niche} niche";
|
||||
}
|
||||
$prompt .= ".\n\n";
|
||||
|
||||
$prompt .= "IMPORTANT: You must identify exactly {$target_count} DISTINCT article ideas. ";
|
||||
$prompt .= 'Each idea must cover a substantially different topic — no two ideas should overlap ';
|
||||
$prompt .= "in their main subject. Aim for diversity across these categories:\n";
|
||||
$prompt .= "1. QUESTIONS: Recurring questions people ask (\"How do I...\", \"What is...\", \"Is it safe to...\")\n";
|
||||
$prompt .= "2. COMPLAINTS: Pain points, frustrations, or problems people report\n";
|
||||
$prompt .= "3. COMPARISONS: Product/method comparisons people discuss (\"X vs Y\", \"Which is better\")\n";
|
||||
$prompt .= "4. NEWS: Recent developments, rule changes, or trends people are discussing\n";
|
||||
$prompt .= "5. GUIDES: How-to topics, best practices, or educational content people need\n\n";
|
||||
|
||||
$prompt .= "Spread your {$target_count} ideas across multiple categories. ";
|
||||
$prompt .= 'Be specific — "peptide dosing for BPC-157" is better than "peptide information". ';
|
||||
$prompt .= "Each idea should be narrow enough to be a single focused blog post.\n\n";
|
||||
|
||||
$prompt .= "For each idea, provide:\n";
|
||||
$prompt .= "- type: 'question', 'complaint', 'comparison', 'news', or 'guide'\n";
|
||||
$prompt .= "- topic: A clear, specific, narrow topic description\n";
|
||||
$prompt .= "- summary: 1-2 sentence summary of why this topic matters\n";
|
||||
$prompt .= "- frequency: How many of the provided posts relate to this topic\n";
|
||||
$prompt .= "- relevance_score: 0.0 to 1.0 indicating how relevant and article-worthy this is\n";
|
||||
$prompt .= "- suggested_title: A compelling, unique blog post title\n";
|
||||
$prompt .= "- key_points: Array of 3-5 key points the article should cover\n";
|
||||
$prompt .= "- target_keywords: Array of SEO keywords for this topic\n\n";
|
||||
|
||||
if ( '' !== $performance_context ) {
|
||||
$prompt .= $performance_context . "\n\n";
|
||||
}
|
||||
// Blocks carry their own separators when non-empty so the rendered
|
||||
// output is byte-identical to the historical concatenation.
|
||||
$performance_block = '' !== $performance_context ? $performance_context . "\n\n" : '';
|
||||
|
||||
$recent_context = self::get_recent_articles_context();
|
||||
if ( '' !== $recent_context ) {
|
||||
$prompt .= $recent_context . "\n\n";
|
||||
}
|
||||
$recent_block = '' !== $recent_context ? $recent_context . "\n\n" : '';
|
||||
|
||||
// Append user-defined analysis instructions if configured.
|
||||
$instructions = trim( (string) get_option( 'prautoblogger_analysis_instructions', '' ) );
|
||||
if ( '' !== $instructions ) {
|
||||
$prompt .= "Additional instructions:\n" . $instructions . "\n\n";
|
||||
}
|
||||
$instructions = trim( (string) get_option( 'prautoblogger_analysis_instructions', '' ) );
|
||||
$instructions_block = '' !== $instructions ? "Additional instructions:\n" . $instructions . "\n\n" : '';
|
||||
|
||||
$prompt .= "Respond with valid JSON containing exactly {$target_count} patterns:\n";
|
||||
$prompt .= '{"patterns": [{"type": "...", "topic": "...", "summary": "...", "frequency": N, "relevance_score": 0.X, "suggested_title": "...", "key_points": [...], "target_keywords": [...]}]}';
|
||||
|
||||
return $prompt;
|
||||
return PRAutoBlogger_Prompt_Registry::render(
|
||||
'analysis.system',
|
||||
array(
|
||||
'niche_clause' => '' !== $niche ? " in the {$niche} niche" : '',
|
||||
'target_count' => (string) $target_count,
|
||||
'performance_block' => $performance_block,
|
||||
'recent_articles_block' => $recent_block,
|
||||
'instructions_block' => $instructions_block,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,9 +56,13 @@ class PRAutoBlogger_Analysis_Prompts {
|
||||
* @return string User prompt text.
|
||||
*/
|
||||
public static function build_user_prompt( string $summary, int $target_count = 6 ): string {
|
||||
return 'Here are the recent social media posts and comments to analyze. '
|
||||
. "Find {$target_count} distinct, diverse article ideas from this data:\n\n"
|
||||
. $summary;
|
||||
return PRAutoBlogger_Prompt_Registry::render(
|
||||
'analysis.user',
|
||||
array(
|
||||
'target_count' => (string) $target_count,
|
||||
'summary' => $summary,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
73
includes/core/class-chief-editor.php
Executable file → Normal file
73
includes/core/class-chief-editor.php
Executable file → Normal file
@@ -9,7 +9,7 @@ declare(strict_types=1);
|
||||
* Opik instrumentation: spans per review LLM call.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Article_Worker.
|
||||
* Dependencies: LLM_Provider_Interface, Cost_Tracker.
|
||||
* Dependencies: LLM_Provider_Interface, Cost_Tracker, Prompt_Registry.
|
||||
*/
|
||||
class PRAutoBlogger_Chief_Editor {
|
||||
|
||||
@@ -92,54 +92,47 @@ class PRAutoBlogger_Chief_Editor {
|
||||
|
||||
/**
|
||||
* Build system prompt for editorial review.
|
||||
*
|
||||
* v0.18.0: renders from the versioned registry ('editor.system') with a
|
||||
* byte-identical in-code fallback; the admin's extra editor
|
||||
* instructions stay a setting and are injected as a token value.
|
||||
*
|
||||
* @param string $niche Niche description from settings.
|
||||
* @return string System prompt text.
|
||||
*/
|
||||
private function build_system_prompt( string $niche ): string {
|
||||
$prompt = 'You are a senior blog editor';
|
||||
if ( '' !== $niche ) {
|
||||
$prompt .= " specializing in {$niche} content";
|
||||
}
|
||||
$prompt .= ". Review article drafts before publication.\n\n";
|
||||
$instructions = trim( (string) get_option( 'prautoblogger_editor_instructions', '' ) );
|
||||
$instructions_block = '' !== $instructions ? "\nAdditional instructions:\n" . $instructions . "\n" : '';
|
||||
|
||||
$prompt .= "Evaluate on: QUALITY, ACCURACY, SEO, COMPLETENESS, READABILITY.\n";
|
||||
$prompt .= "Respond with JSON: {\n";
|
||||
$prompt .= ' "verdict": "approved" | "revised" | "rejected",' . "\n";
|
||||
$prompt .= ' "quality_score": 0.0-1.0,' . "\n";
|
||||
$prompt .= ' "seo_score": 0.0-1.0,' . "\n";
|
||||
$prompt .= ' "issues": ["issue1", "issue2"],' . "\n";
|
||||
$prompt .= ' "notes": "Editorial notes",' . "\n";
|
||||
$prompt .= ' "revised_content": "Full revised HTML if revised, null otherwise"' . "\n";
|
||||
$prompt .= "}\n\n";
|
||||
|
||||
$prompt .= "Rules:\n";
|
||||
$prompt .= "- APPROVE if quality_score >= 0.7 and seo_score >= 0.6\n";
|
||||
$prompt .= "- REVISE if fixable issues exist — provide full revised HTML\n";
|
||||
$prompt .= "- REJECT if fundamentally flawed\n";
|
||||
$prompt .= "- Preserve formatting, links, lists when revising\n";
|
||||
|
||||
$instructions = trim( (string) get_option( 'prautoblogger_editor_instructions', '' ) );
|
||||
if ( '' !== $instructions ) {
|
||||
$prompt .= "\nAdditional instructions:\n" . $instructions . "\n";
|
||||
}
|
||||
|
||||
return $prompt;
|
||||
return PRAutoBlogger_Prompt_Registry::render(
|
||||
'editor.system',
|
||||
array(
|
||||
'niche_clause' => '' !== $niche ? " specializing in {$niche} content" : '',
|
||||
'instructions_block' => $instructions_block,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build user prompt for editorial review.
|
||||
*
|
||||
* v0.18.0: renders from the versioned registry ('editor.review').
|
||||
*
|
||||
* @param string $content The generated HTML content.
|
||||
* @param PRAutoBlogger_Article_Idea $idea The original article idea.
|
||||
* @return string User prompt text.
|
||||
*/
|
||||
private function build_review_prompt( string $content, PRAutoBlogger_Article_Idea $idea ): string {
|
||||
return sprintf(
|
||||
"Review this article draft:\n\n" .
|
||||
"BRIEF: Title: %s | Topic: %s | Type: %s\n" .
|
||||
"KEY POINTS: %s\n" .
|
||||
"TARGET KEYWORDS: %s\n\n" .
|
||||
"CONTENT:\n%s",
|
||||
$idea->get_suggested_title(),
|
||||
$idea->get_topic(),
|
||||
$idea->get_article_type(),
|
||||
implode( ', ', $idea->get_key_points() ),
|
||||
implode( ', ', $idea->get_target_keywords() ),
|
||||
$content
|
||||
return PRAutoBlogger_Prompt_Registry::render(
|
||||
'editor.review',
|
||||
array(
|
||||
'title' => $idea->get_suggested_title(),
|
||||
'topic' => $idea->get_topic(),
|
||||
'article_type' => $idea->get_article_type(),
|
||||
'key_points' => implode( ', ', $idea->get_key_points() ),
|
||||
'keywords' => implode( ', ', $idea->get_target_keywords() ),
|
||||
'content' => $content,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
235
includes/core/class-content-generator.php
Executable file → Normal file
235
includes/core/class-content-generator.php
Executable file → Normal 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'];
|
||||
|
||||
@@ -11,7 +11,8 @@ declare(strict_types=1);
|
||||
* independently of the generation orchestration.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Content_Generator (called for every stage).
|
||||
* Dependencies: WordPress query functions (get_posts, get_the_title, get_permalink).
|
||||
* Dependencies: PRAutoBlogger_Prompt_Registry (versioned prompt bodies),
|
||||
* WordPress query functions (get_posts, get_the_title, get_permalink).
|
||||
*
|
||||
* @see core/class-content-generator.php — Consumer of these prompts.
|
||||
* @see models/class-content-request.php — Data bag passed to every builder.
|
||||
@@ -24,18 +25,22 @@ class PRAutoBlogger_Content_Prompts {
|
||||
* Includes niche, tone, mandatory style guide, internal link reference,
|
||||
* peptide database links, and the "never fabricate URLs" rule.
|
||||
*
|
||||
* v0.18.0: the prompt copy renders from the versioned registry
|
||||
* ('content.system', falls back to the identical in-code default);
|
||||
* the style guide and linking rules are data injection and stay here.
|
||||
*
|
||||
* @param PRAutoBlogger_Content_Request $request Content request with settings.
|
||||
* @return string Complete system prompt.
|
||||
*/
|
||||
public static function build_system( PRAutoBlogger_Content_Request $request ): string {
|
||||
$niche = $request->get_niche_description();
|
||||
$prompt = 'You are an expert blog writer';
|
||||
if ( '' !== $niche ) {
|
||||
$prompt .= " specializing in {$niche}";
|
||||
}
|
||||
$prompt .= '. Write well-researched, engaging, SEO-friendly content. ';
|
||||
$prompt .= "Use a {$request->get_tone()} tone. ";
|
||||
$prompt .= 'Output HTML content only — no markdown, no code fences, no commentary.';
|
||||
$prompt = PRAutoBlogger_Prompt_Registry::render(
|
||||
'content.system',
|
||||
array(
|
||||
'niche_clause' => '' !== $niche ? " specializing in {$niche}" : '',
|
||||
'tone' => $request->get_tone(),
|
||||
)
|
||||
);
|
||||
|
||||
// Append user-defined writing instructions as a mandatory style guide.
|
||||
$instructions = trim( $request->get_writing_instructions() );
|
||||
@@ -60,24 +65,17 @@ class PRAutoBlogger_Content_Prompts {
|
||||
public static function build_single_pass( PRAutoBlogger_Content_Request $request ): string {
|
||||
$idea = $request->get_idea();
|
||||
|
||||
return sprintf(
|
||||
"Write a complete blog post in HTML format.\n\n" .
|
||||
"Title: %s\nTopic: %s\nType: %s\n\nKey points:\n- %s\n\n" .
|
||||
"Keywords: %s\n\n" .
|
||||
"Requirements:\n" .
|
||||
"- %d-%d words\n" .
|
||||
"- Proper HTML (h2, h3, p, ul/li)\n" .
|
||||
"- Engaging intro, strong conclusion with CTA\n" .
|
||||
"- Do NOT include the title or <html>/<body> tags\n" .
|
||||
"- Output HTML only, no markdown or commentary\n" .
|
||||
'- Follow EVERY formatting and structural requirement from your system prompt style guide',
|
||||
$idea->get_suggested_title(),
|
||||
$idea->get_topic(),
|
||||
$idea->get_article_type(),
|
||||
implode( "\n- ", $idea->get_key_points() ),
|
||||
implode( ', ', $idea->get_target_keywords() ),
|
||||
$request->get_min_word_count(),
|
||||
$request->get_max_word_count()
|
||||
return PRAutoBlogger_Prompt_Registry::render(
|
||||
'content.single_pass',
|
||||
array(
|
||||
'title' => $idea->get_suggested_title(),
|
||||
'topic' => $idea->get_topic(),
|
||||
'article_type' => $idea->get_article_type(),
|
||||
'key_points' => implode( "\n- ", $idea->get_key_points() ),
|
||||
'keywords' => implode( ', ', $idea->get_target_keywords() ),
|
||||
'min_words' => (string) $request->get_min_word_count(),
|
||||
'max_words' => (string) $request->get_max_word_count(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,21 +88,17 @@ class PRAutoBlogger_Content_Prompts {
|
||||
public static function build_outline( PRAutoBlogger_Content_Request $request ): string {
|
||||
$idea = $request->get_idea();
|
||||
|
||||
return sprintf(
|
||||
"Create a detailed outline for a blog post titled: \"%s\"\n\n" .
|
||||
"Topic: %s\nArticle type: %s\n\nKey points to cover:\n%s\n\n" .
|
||||
"Target keywords: %s\n\n" .
|
||||
'The outline should have 4-6 main sections with bullet points under each. ' .
|
||||
'Include an introduction hook and a conclusion with a call to action. ' .
|
||||
"Word count target: %d-%d words.\n\n" .
|
||||
'Plan the structure to satisfy EVERY requirement in your system prompt style guide.',
|
||||
$idea->get_suggested_title(),
|
||||
$idea->get_topic(),
|
||||
$idea->get_article_type(),
|
||||
implode( "\n- ", $idea->get_key_points() ),
|
||||
implode( ', ', $idea->get_target_keywords() ),
|
||||
$request->get_min_word_count(),
|
||||
$request->get_max_word_count()
|
||||
return PRAutoBlogger_Prompt_Registry::render(
|
||||
'content.outline',
|
||||
array(
|
||||
'title' => $idea->get_suggested_title(),
|
||||
'topic' => $idea->get_topic(),
|
||||
'article_type' => $idea->get_article_type(),
|
||||
'key_points' => implode( "\n- ", $idea->get_key_points() ),
|
||||
'keywords' => implode( ', ', $idea->get_target_keywords() ),
|
||||
'min_words' => (string) $request->get_min_word_count(),
|
||||
'max_words' => (string) $request->get_max_word_count(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -116,23 +110,15 @@ class PRAutoBlogger_Content_Prompts {
|
||||
* @return string Draft prompt.
|
||||
*/
|
||||
public static function build_draft( PRAutoBlogger_Content_Request $request, string $outline ): string {
|
||||
return sprintf(
|
||||
"Using this outline, write the full blog post in HTML format.\n\n" .
|
||||
"OUTLINE:\n%s\n\n" .
|
||||
"Requirements:\n" .
|
||||
"- Write in a %s tone\n" .
|
||||
"- Target %d-%d words\n" .
|
||||
"- Use proper HTML headings (h2, h3), paragraphs, and lists\n" .
|
||||
"- Include an engaging introduction and strong conclusion\n" .
|
||||
"- Naturally incorporate these keywords: %s\n" .
|
||||
"- Do NOT include the title in the HTML (it will be set separately)\n" .
|
||||
"- Do NOT wrap in <html>, <head>, or <body> tags — just the article content\n" .
|
||||
'- Follow EVERY formatting and structural requirement from your system prompt style guide',
|
||||
$outline,
|
||||
$request->get_tone(),
|
||||
$request->get_min_word_count(),
|
||||
$request->get_max_word_count(),
|
||||
implode( ', ', $request->get_idea()->get_target_keywords() )
|
||||
return PRAutoBlogger_Prompt_Registry::render(
|
||||
'content.draft',
|
||||
array(
|
||||
'outline' => $outline,
|
||||
'tone' => $request->get_tone(),
|
||||
'min_words' => (string) $request->get_min_word_count(),
|
||||
'max_words' => (string) $request->get_max_word_count(),
|
||||
'keywords' => implode( ', ', $request->get_idea()->get_target_keywords() ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -143,18 +129,10 @@ class PRAutoBlogger_Content_Prompts {
|
||||
* @return string Polish prompt.
|
||||
*/
|
||||
public static function build_polish( string $draft ): string {
|
||||
return "Review and polish this blog post draft. Improve:\n" .
|
||||
"1. Flow and readability\n" .
|
||||
"2. SEO optimization (headings, keyword placement)\n" .
|
||||
"3. Engagement (hooks, transitions, call-to-action)\n" .
|
||||
"4. Accuracy and clarity\n" .
|
||||
"5. Remove any filler or redundant sentences\n\n" .
|
||||
'IMPORTANT: Preserve all bullet points, numbered lists, hyperlinks, and ' .
|
||||
'structural elements from the draft. Do NOT flatten lists into prose or ' .
|
||||
'remove links. Ensure every requirement from your system prompt style ' .
|
||||
"guide is satisfied in the final output.\n\n" .
|
||||
"Return the polished HTML content only. Do not add commentary.\n\n" .
|
||||
"DRAFT:\n" . $draft;
|
||||
return PRAutoBlogger_Prompt_Registry::render(
|
||||
'content.polish',
|
||||
array( 'draft' => $draft )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -140,4 +140,62 @@ class PRAutoBlogger_Cost_Reporter {
|
||||
}
|
||||
return ( $this->get_monthly_spend() / $budget ) * 100.0;
|
||||
}
|
||||
/**
|
||||
* Get average input/output token counts for given stages over a time period.
|
||||
*
|
||||
* Used by the model picker field renderer to calculate estimated costs
|
||||
* per generation based on historical token usage. Maps setting IDs to their
|
||||
* constituting stages (e.g., writing model controls outline + draft + polish).
|
||||
* Moved here from Cost_Tracker in v0.18.0 (read-only reporting query;
|
||||
* Cost_Tracker keeps a delegating wrapper for API compatibility).
|
||||
*
|
||||
* @param array<string> $stages Stage names ('analysis', 'outline', 'draft', 'polish', 'review').
|
||||
* @param int $days Historical window (default 30 days).
|
||||
*
|
||||
* @return array{avg_prompt_tokens: float, avg_completion_tokens: float, sample_size: int}
|
||||
* Returns empty counters if no history. Never throws.
|
||||
*/
|
||||
public function get_avg_tokens_for_stages( array $stages, int $days = 30 ): array {
|
||||
global $wpdb;
|
||||
|
||||
if ( empty( $stages ) ) {
|
||||
return array(
|
||||
'avg_prompt_tokens' => 0.0,
|
||||
'avg_completion_tokens' => 0.0,
|
||||
'sample_size' => 0,
|
||||
);
|
||||
}
|
||||
|
||||
$cutoff_datetime = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) );
|
||||
$stage_list = implode( ',', array_map( array( $wpdb, 'prepare' ), array_fill( 0, count( $stages ), '%s' ), $stages ) );
|
||||
$table_name = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Prepared stage list via array_map + prepare
|
||||
$query = $wpdb->prepare(
|
||||
"SELECT AVG(prompt_tokens) as avg_prompt,
|
||||
AVG(completion_tokens) as avg_completion,
|
||||
COUNT(*) as sample_count
|
||||
FROM $table_name
|
||||
WHERE stage IN ( $stage_list )
|
||||
AND created_at >= %s",
|
||||
$cutoff_datetime
|
||||
);
|
||||
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- phpcs pragma above
|
||||
$result = $wpdb->get_row( $query, ARRAY_A );
|
||||
|
||||
if ( ! $result || 0 === (int) ( $result['sample_count'] ?? 0 ) ) {
|
||||
return array(
|
||||
'avg_prompt_tokens' => 0.0,
|
||||
'avg_completion_tokens' => 0.0,
|
||||
'sample_size' => 0,
|
||||
);
|
||||
}
|
||||
|
||||
return array(
|
||||
'avg_prompt_tokens' => (float) ( $result['avg_prompt'] ?? 0 ),
|
||||
'avg_completion_tokens' => (float) ( $result['avg_completion'] ?? 0 ),
|
||||
'sample_size' => (int) ( $result['sample_count'] ?? 0 ),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,14 @@ class PRAutoBlogger_Cost_Tracker {
|
||||
*/
|
||||
public function set_run_id( string $run_id ): void {
|
||||
$this->run_id = $run_id;
|
||||
// v0.18.0: declaring a run id makes this PHP process part of that
|
||||
// run — record the context (cost-governor guard, prompt-version
|
||||
// stamping for components without a run reference), make sure the
|
||||
// run ledger row exists, and pin the active prompt versions once.
|
||||
// All three are self-healing no-ops on a half-migrated schema.
|
||||
PRAutoBlogger_Run_Context::set_run_id( $run_id );
|
||||
PRAutoBlogger_Run_State::ensure_run( $run_id );
|
||||
PRAutoBlogger_Prompt_Registry::pin_for_run( $run_id );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,14 +71,20 @@ class PRAutoBlogger_Cost_Tracker {
|
||||
*
|
||||
* Side effects: database insert into prab_generation_log.
|
||||
*
|
||||
* @param int|null $post_id WordPress post ID (null during generation, set later).
|
||||
* @param string $stage Pipeline stage: 'analysis', 'outline', 'draft', 'polish', 'review'.
|
||||
* @param string $provider Provider name: 'OpenRouter'.
|
||||
* @param string $model Model identifier used.
|
||||
* @param int $prompt_tokens Input tokens consumed.
|
||||
* @param int $completion_tokens Output tokens generated.
|
||||
* @param string $response_status 'success', 'error', or 'timeout'.
|
||||
* @param string $error_message Error details if status is not 'success'.
|
||||
* @param int|null $post_id WordPress post ID (null during generation, set later).
|
||||
* @param string $stage Pipeline stage (see PRAutoBlogger_Stage_Display_Map).
|
||||
* @param string $provider Provider name: 'OpenRouter'.
|
||||
* @param string $model Model identifier used.
|
||||
* @param int $prompt_tokens Input tokens consumed.
|
||||
* @param int $completion_tokens Output tokens generated.
|
||||
* @param string $response_status 'success', 'error', or 'timeout'.
|
||||
* @param string $error_message Error details if status is not 'success'.
|
||||
* @param string|null $agent_role v0.18.0 — role that made the call; derived
|
||||
* from the stage map when null.
|
||||
* @param string|null $prompt_key v0.18.0 — registry key the call rendered
|
||||
* with (e.g. 'content.single_pass'); derived
|
||||
* from the stage map when null. Resolves the
|
||||
* run-pinned prompt_version stamped on the row.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
@@ -82,7 +96,9 @@ class PRAutoBlogger_Cost_Tracker {
|
||||
int $prompt_tokens,
|
||||
int $completion_tokens,
|
||||
string $response_status = 'success',
|
||||
string $error_message = ''
|
||||
string $error_message = '',
|
||||
?string $agent_role = null,
|
||||
?string $prompt_key = null
|
||||
): void {
|
||||
$llm = new PRAutoBlogger_OpenRouter_Provider();
|
||||
$cost = $llm->estimate_cost( $model, $prompt_tokens, $completion_tokens );
|
||||
@@ -101,6 +117,8 @@ class PRAutoBlogger_Cost_Tracker {
|
||||
'estimated_cost' => $cost,
|
||||
'response_status' => $response_status,
|
||||
'error_message' => $error_message,
|
||||
'agent_role' => $agent_role ?? PRAutoBlogger_Stage_Display_Map::default_agent_role( $stage ),
|
||||
'prompt_version' => $this->resolve_prompt_version( $stage, $prompt_key ),
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
)
|
||||
);
|
||||
@@ -175,11 +193,37 @@ class PRAutoBlogger_Cost_Tracker {
|
||||
'estimated_cost' => $cost_usd,
|
||||
'response_status' => 'success',
|
||||
'error_message' => '',
|
||||
'agent_role' => PRAutoBlogger_Stage_Display_Map::default_agent_role( $stage ),
|
||||
'prompt_version' => $this->resolve_prompt_version( $stage, null ),
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the pinned prompt version to stamp on a log row.
|
||||
*
|
||||
* Uses the explicit registry key when the call site passed one,
|
||||
* otherwise the stage's primary key from the display map. The version
|
||||
* comes from the run's pins; the run is this tracker's run_id or —
|
||||
* for standalone trackers inside a pipeline process (e.g. the image
|
||||
* prompt rewriter's) — the process-level run context. Null when there
|
||||
* is no run, no pins, or no key (historical behavior preserved).
|
||||
*
|
||||
* @param string $stage Stage being logged.
|
||||
* @param string|null $prompt_key Explicit registry key, or null to derive.
|
||||
* @return string|null Pinned version as string, or null.
|
||||
*/
|
||||
private function resolve_prompt_version( string $stage, ?string $prompt_key ): ?string {
|
||||
$key = $prompt_key ?? PRAutoBlogger_Stage_Display_Map::default_prompt_key( $stage );
|
||||
if ( null === $key ) {
|
||||
return null;
|
||||
}
|
||||
$run_id = $this->run_id ?? PRAutoBlogger_Run_Context::current_run_id();
|
||||
$pins = PRAutoBlogger_Prompt_Registry::pins_for_run( $run_id );
|
||||
return isset( $pins[ $key ] ) ? (string) $pins[ $key ] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if adding an estimated cost would exceed the monthly budget.
|
||||
*
|
||||
@@ -214,9 +258,9 @@ class PRAutoBlogger_Cost_Tracker {
|
||||
/**
|
||||
* Get average input/output token counts for given stages over a time period.
|
||||
*
|
||||
* Used by the model picker field renderer to calculate estimated costs
|
||||
* per generation based on historical token usage. Maps setting IDs to their
|
||||
* constituting stages (e.g., writing model controls outline + draft + polish).
|
||||
* Thin delegate to PRAutoBlogger_Cost_Reporter (the read-only reporting
|
||||
* class) — kept here for API compatibility with the model-picker field
|
||||
* and existing tests. See Cost_Reporter for the query.
|
||||
*
|
||||
* @param array<string> $stages Stage names ('analysis', 'outline', 'draft', 'polish', 'review').
|
||||
* @param int $days Historical window (default 30 days).
|
||||
@@ -225,46 +269,6 @@ class PRAutoBlogger_Cost_Tracker {
|
||||
* Returns empty counters if no history. Never throws.
|
||||
*/
|
||||
public function get_avg_tokens_for_stages( array $stages, int $days = 30 ): array {
|
||||
global $wpdb;
|
||||
|
||||
if ( empty( $stages ) ) {
|
||||
return array(
|
||||
'avg_prompt_tokens' => 0.0,
|
||||
'avg_completion_tokens' => 0.0,
|
||||
'sample_size' => 0,
|
||||
);
|
||||
}
|
||||
|
||||
$cutoff_datetime = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) );
|
||||
$stage_list = implode( ',', array_map( array( $wpdb, 'prepare' ), array_fill( 0, count( $stages ), '%s' ), $stages ) );
|
||||
$table_name = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Prepared stage list via array_map + prepare
|
||||
$query = $wpdb->prepare(
|
||||
"SELECT AVG(prompt_tokens) as avg_prompt,
|
||||
AVG(completion_tokens) as avg_completion,
|
||||
COUNT(*) as sample_count
|
||||
FROM $table_name
|
||||
WHERE stage IN ( $stage_list )
|
||||
AND created_at >= %s",
|
||||
$cutoff_datetime
|
||||
);
|
||||
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- phpcs pragma above
|
||||
$result = $wpdb->get_row( $query, ARRAY_A );
|
||||
|
||||
if ( ! $result || 0 === (int) ( $result['sample_count'] ?? 0 ) ) {
|
||||
return array(
|
||||
'avg_prompt_tokens' => 0.0,
|
||||
'avg_completion_tokens' => 0.0,
|
||||
'sample_size' => 0,
|
||||
);
|
||||
}
|
||||
|
||||
return array(
|
||||
'avg_prompt_tokens' => (float) ( $result['avg_prompt'] ?? 0 ),
|
||||
'avg_completion_tokens' => (float) ( $result['avg_completion'] ?? 0 ),
|
||||
'sample_size' => (int) ( $result['sample_count'] ?? 0 ),
|
||||
);
|
||||
return ( new PRAutoBlogger_Cost_Reporter() )->get_avg_tokens_for_stages( $stages, $days );
|
||||
}
|
||||
}
|
||||
|
||||
147
includes/core/class-prompt-defaults-editorial.php
Normal file
147
includes/core/class-prompt-defaults-editorial.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Canonical default bodies for the editor, research, and image registry keys.
|
||||
*
|
||||
* What: Companion to PRAutoBlogger_Prompt_Defaults (split for the 300-line
|
||||
* cap, mirroring the settings-fields/-extended split). Holds the
|
||||
* chief-editor and LLM-research prompt copy extracted verbatim from
|
||||
* their classes, and references the two image keys that form the
|
||||
* image-composer PR seam: `image.rewriter_system` (the illustration
|
||||
* rewriter prompt) and `image.style_template` (the Style Template,
|
||||
* ARCHITECTURE #20). The composer PR CONSUMES these keys — it must
|
||||
* not grow its own prompt storage, and the key names are frozen on
|
||||
* the pipeline-v2-phase1 convo thread.
|
||||
* Who triggers it: PRAutoBlogger_Prompt_Registry (render fallback + seed),
|
||||
* PRAutoBlogger_Activator (seed migration).
|
||||
* Dependencies: PRAutoBlogger_Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT,
|
||||
* PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE (referenced, not duplicated).
|
||||
*
|
||||
* @see core/class-prompt-defaults.php — Content + analysis keys.
|
||||
* @see core/class-prompt-registry.php — Render/seed consumer.
|
||||
* @see core/class-chief-editor.php — Computes the editor token values.
|
||||
* @see providers/class-llm-research-provider.php — research.system consumer.
|
||||
*/
|
||||
class PRAutoBlogger_Prompt_Defaults_Editorial {
|
||||
|
||||
/**
|
||||
* Chief-editor system prompt. instructions_block carries its own
|
||||
* leading newline + trailing newline when non-empty ('' when absent),
|
||||
* matching the historical concatenation byte-for-byte.
|
||||
*/
|
||||
public const EDITOR_SYSTEM = <<<'TPL'
|
||||
You are a senior blog editor{{ niche_clause }}. Review article drafts before publication.
|
||||
|
||||
Evaluate on: QUALITY, ACCURACY, SEO, COMPLETENESS, READABILITY.
|
||||
Respond with JSON: {
|
||||
"verdict": "approved" | "revised" | "rejected",
|
||||
"quality_score": 0.0-1.0,
|
||||
"seo_score": 0.0-1.0,
|
||||
"issues": ["issue1", "issue2"],
|
||||
"notes": "Editorial notes",
|
||||
"revised_content": "Full revised HTML if revised, null otherwise"
|
||||
}
|
||||
|
||||
Rules:
|
||||
- APPROVE if quality_score >= 0.7 and seo_score >= 0.6
|
||||
- REVISE if fixable issues exist — provide full revised HTML
|
||||
- REJECT if fundamentally flawed
|
||||
- Preserve formatting, links, lists when revising
|
||||
{{ instructions_block }}
|
||||
TPL;
|
||||
|
||||
/** Chief-editor review user prompt. */
|
||||
public const EDITOR_REVIEW = <<<'TPL'
|
||||
Review this article draft:
|
||||
|
||||
BRIEF: Title: {{ title }} | Topic: {{ topic }} | Type: {{ article_type }}
|
||||
KEY POINTS: {{ key_points }}
|
||||
TARGET KEYWORDS: {{ keywords }}
|
||||
|
||||
CONTENT:
|
||||
{{ content }}
|
||||
TPL;
|
||||
|
||||
/**
|
||||
* LLM deep-research system prompt (fully static). The user-side
|
||||
* research brief stays the `prautoblogger_research_prompt` SETTING —
|
||||
* it is site configuration, not pipeline copy.
|
||||
*/
|
||||
public const RESEARCH_SYSTEM = <<<'TPL'
|
||||
You are a deep research analyst specializing in emerging trends, scientific developments, and community knowledge in niche health and biohacking domains. Your task is to identify substantive, actionable findings.
|
||||
|
||||
Using your training knowledge, scan across:
|
||||
- Recent scientific literature and research directions
|
||||
- Active community discussions, debates, and evolving consensus
|
||||
- Emerging products, protocols, and methodologies gaining traction
|
||||
- Common questions, misconceptions, and points of confusion
|
||||
- Regulatory changes, safety concerns, and legal shifts
|
||||
- Gaps between what practitioners want to know and what content exists
|
||||
|
||||
Your findings must be:
|
||||
1. SUBSTANTIVE — 2-3 paragraphs of detailed analysis per finding, not a headline
|
||||
2. ACTIONABLE — practical enough to seed a full article
|
||||
3. REALISTIC — grounded in what is actually discussed or researched; do NOT invent citations
|
||||
4. TIMELY — reflect current knowledge, recent shifts, or emerging questions
|
||||
|
||||
Respond with valid JSON only (no preamble, no markdown fences):
|
||||
{"findings": [{"title": "string", "content": "string (2-3 paragraphs)", "relevance_score": 0-100, "category": "question|trend|comparison|guide|misconception|safety"}]}
|
||||
|
||||
Generate 8-12 findings. Prioritize depth over breadth. Avoid generic or surface-level topics.
|
||||
TPL;
|
||||
|
||||
/**
|
||||
* Registry definitions for the editor / research / image keys.
|
||||
*
|
||||
* The image bodies are referenced from their existing single sources
|
||||
* of truth rather than duplicated. Note the image keys seed the
|
||||
* registry for the composer PR; the v0.16.0 option-override chain
|
||||
* (`prautoblogger_image_prompt_instructions`,
|
||||
* `prautoblogger_image_style_template`) is unchanged in Phase 1.
|
||||
*
|
||||
* @return array<string, array{body: string, model_option: ?string, params: array<string, mixed>}>
|
||||
*/
|
||||
public static function defs(): array {
|
||||
return array(
|
||||
'editor.system' => array(
|
||||
'body' => self::EDITOR_SYSTEM,
|
||||
'model_option' => 'prautoblogger_editor_model',
|
||||
'params' => array(
|
||||
'temperature' => 0.3,
|
||||
'max_tokens' => 5000,
|
||||
'response_format' => array( 'type' => 'json_object' ),
|
||||
),
|
||||
),
|
||||
'editor.review' => array(
|
||||
'body' => self::EDITOR_REVIEW,
|
||||
'model_option' => 'prautoblogger_editor_model',
|
||||
'params' => array(),
|
||||
),
|
||||
'research.system' => array(
|
||||
'body' => self::RESEARCH_SYSTEM,
|
||||
'model_option' => 'prautoblogger_research_model',
|
||||
'params' => array(
|
||||
'temperature' => 0.7,
|
||||
'max_tokens' => 8000,
|
||||
'response_format' => array( 'type' => 'json_object' ),
|
||||
),
|
||||
),
|
||||
'image.rewriter_system' => array(
|
||||
'body' => PRAutoBlogger_Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT,
|
||||
'model_option' => 'prautoblogger_analysis_model',
|
||||
'params' => array(
|
||||
'temperature' => 0.7,
|
||||
'max_tokens' => 180,
|
||||
),
|
||||
),
|
||||
'image.style_template' => array(
|
||||
'body' => PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE,
|
||||
'model_option' => 'prautoblogger_image_model',
|
||||
'params' => array(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
210
includes/core/class-prompt-defaults.php
Normal file
210
includes/core/class-prompt-defaults.php
Normal file
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Canonical default bodies for the content + analysis prompt-registry keys.
|
||||
*
|
||||
* What: The v0.16.0 hardcoded prompt texts, extracted verbatim into
|
||||
* `{{ token }}` templates. These constants are the single source of
|
||||
* truth twice over: the v1.2.0 migration seeds them into
|
||||
* `wp_prautoblogger_prompts` as version 1, AND the registry falls
|
||||
* back to them whenever the table is missing/empty — so a
|
||||
* half-migrated site renders byte-identical prompts and never
|
||||
* fatals. Versions in the table are immutable; to change a prompt,
|
||||
* create a new version (see Prompt_Registry).
|
||||
* Who triggers it: PRAutoBlogger_Prompt_Registry (render fallback + seed),
|
||||
* PRAutoBlogger_Activator (seed migration).
|
||||
* Dependencies: none — pure constants.
|
||||
*
|
||||
* @see core/class-prompt-registry.php — Render/seed consumer.
|
||||
* @see core/class-prompt-defaults-editorial.php — Editor/research/image keys.
|
||||
* @see core/class-content-prompts.php — Computes the token values.
|
||||
* @see core/class-analysis-prompts.php — Computes the token values.
|
||||
*/
|
||||
class PRAutoBlogger_Prompt_Defaults {
|
||||
|
||||
/**
|
||||
* System prompt shared across all writing stages. The mandatory style
|
||||
* guide block and the linking rules stay code-side (they are data
|
||||
* injection, not prompt copy) and are appended after this body.
|
||||
*/
|
||||
public const CONTENT_SYSTEM = 'You are an expert blog writer{{ niche_clause }}. Write well-researched, engaging, SEO-friendly content. Use a {{ tone }} tone. Output HTML content only — no markdown, no code fences, no commentary.';
|
||||
|
||||
/** Single-pass writer prompt (Economy tier — logs stage 'draft'). */
|
||||
public const CONTENT_SINGLE_PASS = <<<'TPL'
|
||||
Write a complete blog post in HTML format.
|
||||
|
||||
Title: {{ title }}
|
||||
Topic: {{ topic }}
|
||||
Type: {{ article_type }}
|
||||
|
||||
Key points:
|
||||
- {{ key_points }}
|
||||
|
||||
Keywords: {{ keywords }}
|
||||
|
||||
Requirements:
|
||||
- {{ min_words }}-{{ max_words }} words
|
||||
- Proper HTML (h2, h3, p, ul/li)
|
||||
- Engaging intro, strong conclusion with CTA
|
||||
- Do NOT include the title or <html>/<body> tags
|
||||
- Output HTML only, no markdown or commentary
|
||||
- Follow EVERY formatting and structural requirement from your system prompt style guide
|
||||
TPL;
|
||||
|
||||
/** Multi-step stage 1: outline. */
|
||||
public const CONTENT_OUTLINE = <<<'TPL'
|
||||
Create a detailed outline for a blog post titled: "{{ title }}"
|
||||
|
||||
Topic: {{ topic }}
|
||||
Article type: {{ article_type }}
|
||||
|
||||
Key points to cover:
|
||||
{{ key_points }}
|
||||
|
||||
Target keywords: {{ keywords }}
|
||||
|
||||
The outline should have 4-6 main sections with bullet points under each. Include an introduction hook and a conclusion with a call to action. Word count target: {{ min_words }}-{{ max_words }} words.
|
||||
|
||||
Plan the structure to satisfy EVERY requirement in your system prompt style guide.
|
||||
TPL;
|
||||
|
||||
/** Multi-step stage 2: full draft from outline. */
|
||||
public const CONTENT_DRAFT = <<<'TPL'
|
||||
Using this outline, write the full blog post in HTML format.
|
||||
|
||||
OUTLINE:
|
||||
{{ outline }}
|
||||
|
||||
Requirements:
|
||||
- Write in a {{ tone }} tone
|
||||
- Target {{ min_words }}-{{ max_words }} words
|
||||
- Use proper HTML headings (h2, h3), paragraphs, and lists
|
||||
- Include an engaging introduction and strong conclusion
|
||||
- Naturally incorporate these keywords: {{ keywords }}
|
||||
- Do NOT include the title in the HTML (it will be set separately)
|
||||
- Do NOT wrap in <html>, <head>, or <body> tags — just the article content
|
||||
- Follow EVERY formatting and structural requirement from your system prompt style guide
|
||||
TPL;
|
||||
|
||||
/** Multi-step stage 3: polish (canonical name — ARCHITECTURE once said 'edit'). */
|
||||
public const CONTENT_POLISH = <<<'TPL'
|
||||
Review and polish this blog post draft. Improve:
|
||||
1. Flow and readability
|
||||
2. SEO optimization (headings, keyword placement)
|
||||
3. Engagement (hooks, transitions, call-to-action)
|
||||
4. Accuracy and clarity
|
||||
5. Remove any filler or redundant sentences
|
||||
|
||||
IMPORTANT: Preserve all bullet points, numbered lists, hyperlinks, and structural elements from the draft. Do NOT flatten lists into prose or remove links. Ensure every requirement from your system prompt style guide is satisfied in the final output.
|
||||
|
||||
Return the polished HTML content only. Do not add commentary.
|
||||
|
||||
DRAFT:
|
||||
{{ draft }}
|
||||
TPL;
|
||||
|
||||
/**
|
||||
* Analyzer system prompt. The *_block tokens carry their own trailing
|
||||
* separators when non-empty ('' when absent) so the rendered output is
|
||||
* byte-identical to the historical concatenation.
|
||||
*/
|
||||
public const ANALYSIS_SYSTEM = <<<'TPL'
|
||||
You are a content strategist analyzing social media discussions to find article ideas for a blog{{ niche_clause }}.
|
||||
|
||||
IMPORTANT: You must identify exactly {{ target_count }} DISTINCT article ideas. Each idea must cover a substantially different topic — no two ideas should overlap in their main subject. Aim for diversity across these categories:
|
||||
1. QUESTIONS: Recurring questions people ask ("How do I...", "What is...", "Is it safe to...")
|
||||
2. COMPLAINTS: Pain points, frustrations, or problems people report
|
||||
3. COMPARISONS: Product/method comparisons people discuss ("X vs Y", "Which is better")
|
||||
4. NEWS: Recent developments, rule changes, or trends people are discussing
|
||||
5. GUIDES: How-to topics, best practices, or educational content people need
|
||||
|
||||
Spread your {{ target_count }} ideas across multiple categories. Be specific — "peptide dosing for BPC-157" is better than "peptide information". Each idea should be narrow enough to be a single focused blog post.
|
||||
|
||||
For each idea, provide:
|
||||
- type: 'question', 'complaint', 'comparison', 'news', or 'guide'
|
||||
- topic: A clear, specific, narrow topic description
|
||||
- summary: 1-2 sentence summary of why this topic matters
|
||||
- frequency: How many of the provided posts relate to this topic
|
||||
- relevance_score: 0.0 to 1.0 indicating how relevant and article-worthy this is
|
||||
- suggested_title: A compelling, unique blog post title
|
||||
- key_points: Array of 3-5 key points the article should cover
|
||||
- target_keywords: Array of SEO keywords for this topic
|
||||
|
||||
{{ performance_block }}{{ recent_articles_block }}{{ instructions_block }}Respond with valid JSON containing exactly {{ target_count }} patterns:
|
||||
{"patterns": [{"type": "...", "topic": "...", "summary": "...", "frequency": N, "relevance_score": 0.X, "suggested_title": "...", "key_points": [...], "target_keywords": [...]}]}
|
||||
TPL;
|
||||
|
||||
/** Analyzer user prompt wrapping the source-data summary. */
|
||||
public const ANALYSIS_USER = <<<'TPL'
|
||||
Here are the recent social media posts and comments to analyze. Find {{ target_count }} distinct, diverse article ideas from this data:
|
||||
|
||||
{{ summary }}
|
||||
TPL;
|
||||
|
||||
/**
|
||||
* Registry definitions for the content + analysis keys.
|
||||
*
|
||||
* Each entry: body (the v1 template), model_option (the wp_option that
|
||||
* selects the model at call time — informational in Phase 1) and params
|
||||
* (the call-time LLM parameters, snapshotted for audit comparability).
|
||||
*
|
||||
* @return array<string, array{body: string, model_option: ?string, params: array<string, mixed>}>
|
||||
*/
|
||||
public static function defs(): array {
|
||||
return array(
|
||||
'content.system' => array(
|
||||
'body' => self::CONTENT_SYSTEM,
|
||||
'model_option' => 'prautoblogger_writing_model',
|
||||
'params' => array(),
|
||||
),
|
||||
'content.single_pass' => array(
|
||||
'body' => self::CONTENT_SINGLE_PASS,
|
||||
'model_option' => 'prautoblogger_writing_model',
|
||||
'params' => array(
|
||||
'temperature' => 0.7,
|
||||
'max_tokens' => 4000,
|
||||
),
|
||||
),
|
||||
'content.outline' => array(
|
||||
'body' => self::CONTENT_OUTLINE,
|
||||
'model_option' => 'prautoblogger_writing_model',
|
||||
'params' => array(
|
||||
'temperature' => 0.5,
|
||||
'max_tokens' => 1500,
|
||||
),
|
||||
),
|
||||
'content.draft' => array(
|
||||
'body' => self::CONTENT_DRAFT,
|
||||
'model_option' => 'prautoblogger_writing_model',
|
||||
'params' => array(
|
||||
'temperature' => 0.7,
|
||||
'max_tokens' => 4000,
|
||||
),
|
||||
),
|
||||
'content.polish' => array(
|
||||
'body' => self::CONTENT_POLISH,
|
||||
'model_option' => 'prautoblogger_writing_model',
|
||||
'params' => array(
|
||||
'temperature' => 0.4,
|
||||
'max_tokens' => 4000,
|
||||
),
|
||||
),
|
||||
'analysis.system' => array(
|
||||
'body' => self::ANALYSIS_SYSTEM,
|
||||
'model_option' => 'prautoblogger_analysis_model',
|
||||
'params' => array(
|
||||
'temperature' => 0.5,
|
||||
'max_tokens' => 8000,
|
||||
),
|
||||
),
|
||||
'analysis.user' => array(
|
||||
'body' => self::ANALYSIS_USER,
|
||||
'model_option' => 'prautoblogger_analysis_model',
|
||||
'params' => array(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
143
includes/core/class-prompt-registry-writer.php
Normal file
143
includes/core/class-prompt-registry-writer.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Write side of the versioned prompt registry: create / activate / seed.
|
||||
*
|
||||
* What: Companion to PRAutoBlogger_Prompt_Registry (split for the 300-line
|
||||
* cap). Owns every mutation of `wp_prautoblogger_prompts` and
|
||||
* enforces the immutability rule: a stored body is NEVER updated —
|
||||
* a change is a new (prompt_key, version) row, and `activate()`
|
||||
* flips the single active flag per key. `seed_v1()` writes the
|
||||
* canonical v0.16.0 texts as version 1 (idempotent — keys that
|
||||
* already have rows keep their history). This is the API surface
|
||||
* the Phase-2 in-admin Prompts editor will call; Phase 1 ships no UI.
|
||||
* Who triggers it: PRAutoBlogger_Activator (seed migration on db 1.2.0),
|
||||
* Phase-2 admin (create/activate), WP-CLI (future).
|
||||
* Dependencies: WordPress $wpdb, PRAutoBlogger_Prompt_Registry (table name,
|
||||
* availability probe, defs, cache flush).
|
||||
*
|
||||
* @see core/class-prompt-registry.php — Read side (render, pins, fallback).
|
||||
* @see class-activator.php — Calls seed_v1() once, flag-gated.
|
||||
*/
|
||||
class PRAutoBlogger_Prompt_Registry_Writer {
|
||||
|
||||
/**
|
||||
* All versions of a key, newest first (Phase-2 list/diff support).
|
||||
*
|
||||
* @param string $key Registry key.
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function list_versions( string $key ): array {
|
||||
if ( ! PRAutoBlogger_Prompt_Registry::is_available() ) {
|
||||
return array();
|
||||
}
|
||||
global $wpdb;
|
||||
$table = PRAutoBlogger_Prompt_Registry::table_name();
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare( "SELECT * FROM {$table} WHERE prompt_key = %s ORDER BY version DESC", $key ),
|
||||
ARRAY_A
|
||||
);
|
||||
return is_array( $rows ) ? $rows : array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a NEW immutable version of a key and (optionally) activate it.
|
||||
* Never mutates an existing row's body — that is the whole point.
|
||||
*
|
||||
* @param string $key Registry key.
|
||||
* @param string $body Template body.
|
||||
* @param string|null $model Optional model hint.
|
||||
* @param array<string, mixed>|null $params Optional params snapshot.
|
||||
* @param string $author Who created it (seed tag, login, agent).
|
||||
* @param bool $activate Flip active to this version (default true).
|
||||
* @return int New version number, or 0 on failure / table unavailable.
|
||||
*/
|
||||
public static function create_version( string $key, string $body, ?string $model, ?array $params, string $author, bool $activate = true ): int {
|
||||
if ( ! PRAutoBlogger_Prompt_Registry::is_available() ) {
|
||||
return 0;
|
||||
}
|
||||
global $wpdb;
|
||||
$table = PRAutoBlogger_Prompt_Registry::table_name();
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$max = (int) $wpdb->get_var(
|
||||
$wpdb->prepare( "SELECT MAX(version) FROM {$table} WHERE prompt_key = %s", $key )
|
||||
);
|
||||
$next = $max + 1;
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$ok = $wpdb->insert(
|
||||
$table,
|
||||
array(
|
||||
'prompt_key' => $key,
|
||||
'version' => $next,
|
||||
'body' => $body,
|
||||
'model' => $model,
|
||||
'params_json' => null !== $params ? wp_json_encode( $params ) : null,
|
||||
'author' => $author,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
'active' => 0,
|
||||
)
|
||||
);
|
||||
if ( false === $ok ) {
|
||||
return 0;
|
||||
}
|
||||
if ( $activate ) {
|
||||
self::activate( $key, $next );
|
||||
}
|
||||
return $next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make one version the single active version of its key.
|
||||
*
|
||||
* @param string $key Registry key.
|
||||
* @param int $version Version to activate.
|
||||
* @return bool True when the version is now active.
|
||||
*/
|
||||
public static function activate( string $key, int $version ): bool {
|
||||
if ( ! PRAutoBlogger_Prompt_Registry::is_available() ) {
|
||||
return false;
|
||||
}
|
||||
global $wpdb;
|
||||
$table = PRAutoBlogger_Prompt_Registry::table_name();
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$wpdb->query( $wpdb->prepare( "UPDATE {$table} SET active = 0 WHERE prompt_key = %s", $key ) );
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$updated = $wpdb->query(
|
||||
$wpdb->prepare( "UPDATE {$table} SET active = 1 WHERE prompt_key = %s AND version = %d", $key, $version )
|
||||
);
|
||||
PRAutoBlogger_Prompt_Registry::flush_cache();
|
||||
return 1 === (int) $updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed every registry key with its canonical v1 body. Idempotent: keys
|
||||
* that already have rows are left untouched (their history is theirs).
|
||||
*
|
||||
* Side effects: up to one INSERT + two UPDATEs per missing key.
|
||||
*
|
||||
* @param string $author Audit tag for the seed rows (e.g. 'seed:v0.18.0').
|
||||
* @return int Number of keys seeded.
|
||||
*/
|
||||
public static function seed_v1( string $author = 'seed:v0.18.0' ): int {
|
||||
if ( ! PRAutoBlogger_Prompt_Registry::is_available() ) {
|
||||
return 0;
|
||||
}
|
||||
$seeded = 0;
|
||||
foreach ( PRAutoBlogger_Prompt_Registry::defs() as $key => $def ) {
|
||||
if ( ! empty( self::list_versions( $key ) ) ) {
|
||||
continue;
|
||||
}
|
||||
$model = null !== $def['model_option'] ? (string) get_option( $def['model_option'], '' ) : null;
|
||||
$version = self::create_version( $key, $def['body'], '' !== (string) $model ? $model : null, $def['params'], $author, true );
|
||||
if ( $version > 0 ) {
|
||||
++$seeded;
|
||||
}
|
||||
}
|
||||
PRAutoBlogger_Prompt_Registry::flush_cache();
|
||||
return $seeded;
|
||||
}
|
||||
}
|
||||
214
includes/core/class-prompt-registry.php
Normal file
214
includes/core/class-prompt-registry.php
Normal file
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Versioned prompt registry — immutable versions, one active row per key.
|
||||
*
|
||||
* What: Read/write API over `wp_prautoblogger_prompts`. Rendering resolves
|
||||
* the ACTIVE body for a key and fills `{{ token }}` placeholders;
|
||||
* when the table is missing or has no row for the key (half-migrated
|
||||
* site, failed seed), it falls back to the canonical defaults the
|
||||
* seed itself uses — byte-identical output, no fatals (self-healing).
|
||||
* Versions are IMMUTABLE: there is no update path for a stored body;
|
||||
* changes create a new (prompt_key, version) row and flip `active`
|
||||
* (write side lives in PRAutoBlogger_Prompt_Registry_Writer).
|
||||
* Runs pin the active version of every key at start (stored on the
|
||||
* runs row) so generation_log rows can be stamped with the exact
|
||||
* prompt_version they rendered with. No admin UI in Phase 1 — the
|
||||
* list/activate/create API is what the Phase-2 Prompts screen needs.
|
||||
* Who triggers it: Content_Prompts / Analysis_Prompts / Chief_Editor /
|
||||
* LLM_Research_Provider (render), Cost_Tracker (pins_for_run),
|
||||
* Activator (seed_v1), Cost_Tracker::set_run_id (pin_for_run).
|
||||
* Dependencies: WordPress $wpdb, Prompt_Defaults(+Editorial), wp_json_encode.
|
||||
*
|
||||
* @see core/class-prompt-defaults.php — Canonical v1 bodies (seed + fallback).
|
||||
* @see core/class-prompt-defaults-editorial.php — Editor/research/image bodies.
|
||||
* @see core/class-run-state.php — Owns the runs row the pins live on.
|
||||
* @see ARCHITECTURE.md #21 — Design rationale.
|
||||
*/
|
||||
class PRAutoBlogger_Prompt_Registry {
|
||||
|
||||
/** @var array<string, ?array<string, mixed>> Per-request active-row cache. */
|
||||
private static array $active_cache = array();
|
||||
|
||||
/** @var array<string, array<string, int>> Per-request run-pins cache. */
|
||||
private static array $pins_cache = array();
|
||||
|
||||
/** @var bool|null Per-request "table exists" probe result. */
|
||||
private static ?bool $table_ok = null;
|
||||
|
||||
/** Fully-qualified prompts table name (shared with Prompt_Registry_Writer). */
|
||||
public static function table_name(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . 'prautoblogger_prompts';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the prompts table exists and is queryable. Cached per request;
|
||||
* never throws — any failure reads as "not available" and every consumer
|
||||
* falls back to the in-code defaults.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_available(): bool {
|
||||
if ( null !== self::$table_ok ) {
|
||||
return self::$table_ok;
|
||||
}
|
||||
global $wpdb;
|
||||
if ( null === $wpdb ) {
|
||||
return false; // Not cached: $wpdb may appear later in boot.
|
||||
}
|
||||
$table = self::table_name();
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
|
||||
self::$table_ok = ( $found === $table );
|
||||
return self::$table_ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* All registry definitions (content + analysis + editor + research + image).
|
||||
*
|
||||
* @return array<string, array{body: string, model_option: ?string, params: array<string, mixed>}>
|
||||
*/
|
||||
public static function defs(): array {
|
||||
return array_merge(
|
||||
PRAutoBlogger_Prompt_Defaults::defs(),
|
||||
PRAutoBlogger_Prompt_Defaults_Editorial::defs()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical in-code default body for a key (the same text seeded as v1).
|
||||
*
|
||||
* @param string $key Registry key.
|
||||
* @return string|null Body template, or null for unknown keys.
|
||||
*/
|
||||
public static function default_body( string $key ): ?string {
|
||||
$defs = self::defs();
|
||||
return isset( $defs[ $key ] ) ? $defs[ $key ]['body'] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Active row for a key, or null when none / table unavailable.
|
||||
*
|
||||
* @param string $key Registry key.
|
||||
* @return array<string, mixed>|null Row with prompt_key, version, body, model, params_json, author, created_at, active.
|
||||
*/
|
||||
public static function get_active( string $key ): ?array {
|
||||
if ( array_key_exists( $key, self::$active_cache ) ) {
|
||||
return self::$active_cache[ $key ];
|
||||
}
|
||||
$row = null;
|
||||
if ( self::is_available() ) {
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$row = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE prompt_key = %s AND active = 1 ORDER BY version DESC LIMIT 1",
|
||||
$key
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
$row = is_array( $row ) ? $row : null;
|
||||
}
|
||||
self::$active_cache[ $key ] = $row;
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a prompt: active body for the key (fallback: canonical default),
|
||||
* with `{{ token }}` placeholders substituted.
|
||||
*
|
||||
* @param string $key Registry key (e.g. 'content.single_pass').
|
||||
* @param array<string, string> $tokens Token name => replacement value.
|
||||
* @return string Rendered prompt ('' only if the key is unknown AND absent).
|
||||
*/
|
||||
public static function render( string $key, array $tokens = array() ): string {
|
||||
$active = self::get_active( $key );
|
||||
$body = null !== $active ? (string) $active['body'] : (string) self::default_body( $key );
|
||||
return self::fill( $body, $tokens );
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute `{{ token }}` placeholders in a template body.
|
||||
*
|
||||
* @param string $body Template body.
|
||||
* @param array<string, string> $tokens Token name => replacement value.
|
||||
* @return string
|
||||
*/
|
||||
public static function fill( string $body, array $tokens ): string {
|
||||
foreach ( $tokens as $name => $value ) {
|
||||
$body = str_replace( '{{ ' . $name . ' }}', (string) $value, $body );
|
||||
}
|
||||
return $body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map of prompt_key => active version for every key present in the table.
|
||||
*
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public static function active_versions(): array {
|
||||
if ( ! self::is_available() ) {
|
||||
return array();
|
||||
}
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$rows = $wpdb->get_results( "SELECT prompt_key, version FROM {$table} WHERE active = 1", ARRAY_A );
|
||||
$map = array();
|
||||
foreach ( ( is_array( $rows ) ? $rows : array() ) as $row ) {
|
||||
$map[ (string) $row['prompt_key'] ] = (int) $row['version'];
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin the current active versions onto a run's row (once, at run start).
|
||||
* No-ops when the runs row already carries pins (resume keeps the
|
||||
* versions the run started with) or when tables are unavailable.
|
||||
*
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @return void
|
||||
*/
|
||||
public static function pin_for_run( string $run_id ): void {
|
||||
if ( '' === $run_id || ! self::is_available() ) {
|
||||
return;
|
||||
}
|
||||
$pins = self::active_versions();
|
||||
if ( empty( $pins ) ) {
|
||||
return;
|
||||
}
|
||||
PRAutoBlogger_Run_State::set_pins_if_absent( $run_id, $pins );
|
||||
unset( self::$pins_cache[ $run_id ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* The pinned prompt versions for a run (cached per request). Empty when
|
||||
* the run has no pins (pre-1.2.0 rows, standalone calls, missing table).
|
||||
*
|
||||
* @param string|null $run_id Pipeline run UUID, or null.
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public static function pins_for_run( ?string $run_id ): array {
|
||||
if ( null === $run_id || '' === $run_id ) {
|
||||
return array();
|
||||
}
|
||||
if ( isset( self::$pins_cache[ $run_id ] ) ) {
|
||||
return self::$pins_cache[ $run_id ];
|
||||
}
|
||||
$pins = PRAutoBlogger_Run_State::get_pins( $run_id );
|
||||
self::$pins_cache[ $run_id ] = $pins;
|
||||
return $pins;
|
||||
}
|
||||
|
||||
/** Reset per-request caches (tests). */
|
||||
public static function flush_cache(): void {
|
||||
self::$active_cache = array();
|
||||
self::$pins_cache = array();
|
||||
self::$table_ok = null;
|
||||
}
|
||||
}
|
||||
59
includes/core/class-run-context.php
Normal file
59
includes/core/class-run-context.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Per-PHP-process holder for the active pipeline run id.
|
||||
*
|
||||
* What: A process-scoped static slot recording which run this PHP request
|
||||
* is executing, set whenever Cost_Tracker::set_run_id() is called
|
||||
* (daily cron, manual generation, queued-article jobs, ideas
|
||||
* browser). It lets components that are constructed without a run
|
||||
* reference — the OpenRouter provider (cost-governor guard) and
|
||||
* standalone Cost_Tracker instances (e.g. the image prompt
|
||||
* rewriter's) — resolve the surrounding run for reservation and
|
||||
* prompt-version stamping without threading a parameter through
|
||||
* every constructor. Mirrors the Opik_Trace_Context precedent.
|
||||
* Who triggers it: PRAutoBlogger_Cost_Tracker::set_run_id() (set);
|
||||
* Cost_Governor + Cost_Tracker (read).
|
||||
* Dependencies: none.
|
||||
*
|
||||
* @see core/class-cost-tracker.php — Sets the context on set_run_id().
|
||||
* @see core/class-cost-governor.php — Reads it to guard LLM calls.
|
||||
*/
|
||||
class PRAutoBlogger_Run_Context {
|
||||
|
||||
/** @var string|null Active run id for this PHP process, or null. */
|
||||
private static ?string $run_id = null;
|
||||
|
||||
/**
|
||||
* Record the active run id for this process.
|
||||
*
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @return void
|
||||
*/
|
||||
public static function set_run_id( string $run_id ): void {
|
||||
if ( '' !== $run_id ) {
|
||||
self::$run_id = $run_id;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The active run id for this process, or null outside any run.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public static function current_run_id(): ?string {
|
||||
return self::$run_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the context (tests / end of run).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function clear(): void {
|
||||
self::$run_id = null;
|
||||
}
|
||||
}
|
||||
259
includes/core/class-run-state.php
Normal file
259
includes/core/class-run-state.php
Normal file
@@ -0,0 +1,259 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Run-level state: the `wp_prautoblogger_runs` ledger + lifecycle row.
|
||||
*
|
||||
* What: One row per pipeline run (keyed by run_id UUID) carrying the run
|
||||
* status (pending|running|done|failed|halted), the per-run cost
|
||||
* ledger the Cost_Governor reserves against (ceiling / reserved /
|
||||
* settled / overage), and the prompt versions pinned at run start.
|
||||
* Every method is self-healing: a missing table (half-migrated
|
||||
* site) degrades to no-ops/empty reads, never a fatal. Final states
|
||||
* are sticky — a halted or failed run cannot be flipped back to
|
||||
* running by a late writer. Per-stage state lives in
|
||||
* PRAutoBlogger_Run_Stage_State (run_stages table).
|
||||
* Who triggers it: Cost_Tracker::set_run_id() (ensure_run + pinning),
|
||||
* Cost_Governor (ledger + halt), Run_Reaper (stuck-run sweep),
|
||||
* Review Queue (halted-run surfacing), Pipeline_Runner (completion).
|
||||
* Dependencies: WordPress $wpdb, get_option (ceiling setting), wp_json_encode.
|
||||
*
|
||||
* @see core/class-cost-governor.php — Reserves against this row atomically.
|
||||
* @see core/class-prompt-registry.php — Reads/writes pins via this class.
|
||||
* @see core/class-run-stage-state.php — Per-stage rows (commit 4).
|
||||
* @see ARCHITECTURE.md #21 — Design rationale.
|
||||
*/
|
||||
class PRAutoBlogger_Run_State {
|
||||
|
||||
/** @var bool|null Per-request "runs table exists" probe result. */
|
||||
private static ?bool $table_ok = null;
|
||||
|
||||
/** Fully-qualified runs table name (shared with Cost_Governor). */
|
||||
public static function table_name(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . 'prautoblogger_runs';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the runs table exists and is queryable. Cached per request;
|
||||
* never throws.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_available(): bool {
|
||||
if ( null !== self::$table_ok ) {
|
||||
return self::$table_ok;
|
||||
}
|
||||
global $wpdb;
|
||||
if ( null === $wpdb ) {
|
||||
return false; // Not cached: $wpdb may appear later in boot.
|
||||
}
|
||||
$table = self::table_name();
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
|
||||
self::$table_ok = ( $found === $table );
|
||||
return self::$table_ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-run cost ceiling, read from the SETTING at call time
|
||||
* (never a literal at the point of use). 0 disables the per-run guard.
|
||||
*
|
||||
* @return float Ceiling in USD.
|
||||
*/
|
||||
public static function ceiling_setting(): float {
|
||||
return (float) get_option( 'prautoblogger_per_run_cost_ceiling_usd', PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD );
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the run row if it does not exist (INSERT IGNORE — concurrent
|
||||
* callers cannot double-create). Snapshots the ceiling setting at run
|
||||
* start so a mid-run settings change cannot move the goalposts.
|
||||
*
|
||||
* Side effects: one INSERT IGNORE.
|
||||
*
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @return void
|
||||
*/
|
||||
public static function ensure_run( string $run_id ): void {
|
||||
if ( '' === $run_id || ! self::is_available() ) {
|
||||
return;
|
||||
}
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
$now = current_time( 'mysql' );
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"INSERT IGNORE INTO {$table}
|
||||
(run_id, status, ceiling_usd, reserved_usd, settled_usd, overage_usd, started_at, updated_at)
|
||||
VALUES (%s, 'running', %f, 0, 0, 0, %s, %s)",
|
||||
$run_id,
|
||||
self::ceiling_setting(),
|
||||
$now,
|
||||
$now
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a run row.
|
||||
*
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @return array<string, mixed>|null Row, or null when absent/unavailable.
|
||||
*/
|
||||
public static function get_run( string $run_id ): ?array {
|
||||
if ( '' === $run_id || ! self::is_available() ) {
|
||||
return null;
|
||||
}
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$row = $wpdb->get_row(
|
||||
$wpdb->prepare( "SELECT * FROM {$table} WHERE run_id = %s", $run_id ),
|
||||
ARRAY_A
|
||||
);
|
||||
return is_array( $row ) ? $row : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current run status, or null when the run has no row.
|
||||
*
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @return string|null pending|running|done|failed|halted, or null.
|
||||
*/
|
||||
public static function get_status( string $run_id ): ?string {
|
||||
$run = self::get_run( $run_id );
|
||||
return null !== $run ? (string) $run['status'] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the pinned prompt versions — only if the run has none yet, so
|
||||
* a resumed run keeps the versions it started with.
|
||||
*
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @param array<string, int> $pins prompt_key => version map.
|
||||
* @return void
|
||||
*/
|
||||
public static function set_pins_if_absent( string $run_id, array $pins ): void {
|
||||
if ( '' === $run_id || empty( $pins ) || ! self::is_available() ) {
|
||||
return;
|
||||
}
|
||||
self::ensure_run( $run_id );
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"UPDATE {$table} SET pinned_prompts_json = %s, updated_at = %s
|
||||
WHERE run_id = %s AND (pinned_prompts_json IS NULL OR pinned_prompts_json = '')",
|
||||
wp_json_encode( $pins ),
|
||||
current_time( 'mysql' ),
|
||||
$run_id
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The pinned prompt versions for a run.
|
||||
*
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @return array<string, int> prompt_key => version ({} when none).
|
||||
*/
|
||||
public static function get_pins( string $run_id ): array {
|
||||
$run = self::get_run( $run_id );
|
||||
if ( null === $run || empty( $run['pinned_prompts_json'] ) ) {
|
||||
return array();
|
||||
}
|
||||
$pins = json_decode( (string) $run['pinned_prompts_json'], true );
|
||||
return is_array( $pins ) ? array_map( 'intval', $pins ) : array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a run to a new status. Forward transitions only touch open
|
||||
* (pending/running) runs; `halted` and `failed` are sticky and `done`
|
||||
* cannot overwrite them.
|
||||
*
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @param string $status One of done|failed|halted|running.
|
||||
* @return bool True when a row transitioned.
|
||||
*/
|
||||
public static function mark_status( string $run_id, string $status ): bool {
|
||||
if ( '' === $run_id || ! self::is_available() ) {
|
||||
return false;
|
||||
}
|
||||
if ( ! in_array( $status, array( 'running', 'done', 'failed', 'halted' ), true ) ) {
|
||||
return false;
|
||||
}
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
$now = current_time( 'mysql' );
|
||||
$finished = in_array( $status, array( 'done', 'failed', 'halted' ), true ) ? $now : null;
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$updated = $wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"UPDATE {$table} SET status = %s, updated_at = %s, finished_at = %s
|
||||
WHERE run_id = %s AND status IN ('pending','running')",
|
||||
$status,
|
||||
$now,
|
||||
$finished,
|
||||
$run_id
|
||||
)
|
||||
);
|
||||
return $updated >= 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record by how much a breaching reservation exceeded the ceiling.
|
||||
*
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @param float $overage USD amount over the ceiling.
|
||||
* @return void
|
||||
*/
|
||||
public static function record_overage( string $run_id, float $overage ): void {
|
||||
if ( '' === $run_id || $overage <= 0 || ! self::is_available() ) {
|
||||
return;
|
||||
}
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"UPDATE {$table} SET overage_usd = GREATEST(overage_usd, %f), updated_at = %s WHERE run_id = %s",
|
||||
$overage,
|
||||
current_time( 'mysql' ),
|
||||
$run_id
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent halted runs, for the Review Queue surface.
|
||||
*
|
||||
* @param int $limit Max rows (default 10).
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function halted_runs( int $limit = 10 ): array {
|
||||
if ( ! self::is_available() ) {
|
||||
return array();
|
||||
}
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE status = 'halted' ORDER BY updated_at DESC LIMIT %d",
|
||||
$limit
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
return is_array( $rows ) ? $rows : array();
|
||||
}
|
||||
|
||||
/** Reset per-request caches (tests). */
|
||||
public static function flush_cache(): void {
|
||||
self::$table_ok = null;
|
||||
}
|
||||
}
|
||||
10
includes/models/class-generation-log.php
Executable file → Normal file
10
includes/models/class-generation-log.php
Executable file → Normal file
@@ -26,6 +26,8 @@ class PRAutoBlogger_Generation_Log {
|
||||
private ?string $request_json;
|
||||
private string $response_status;
|
||||
private ?string $error_message;
|
||||
private ?string $agent_role;
|
||||
private ?string $prompt_version;
|
||||
private string $created_at;
|
||||
|
||||
/**
|
||||
@@ -44,6 +46,8 @@ class PRAutoBlogger_Generation_Log {
|
||||
$this->request_json = $data['request_json'] ?? null;
|
||||
$this->response_status = $data['response_status'] ?? 'success';
|
||||
$this->error_message = $data['error_message'] ?? null;
|
||||
$this->agent_role = $data['agent_role'] ?? null;
|
||||
$this->prompt_version = isset( $data['prompt_version'] ) ? (string) $data['prompt_version'] : null;
|
||||
$this->created_at = $data['created_at'] ?? current_time( 'mysql' );
|
||||
}
|
||||
|
||||
@@ -71,6 +75,10 @@ class PRAutoBlogger_Generation_Log {
|
||||
return $this->response_status; }
|
||||
public function get_error_message(): ?string {
|
||||
return $this->error_message; }
|
||||
public function get_agent_role(): ?string {
|
||||
return $this->agent_role; }
|
||||
public function get_prompt_version(): ?string {
|
||||
return $this->prompt_version; }
|
||||
public function get_created_at(): string {
|
||||
return $this->created_at; }
|
||||
|
||||
@@ -90,6 +98,8 @@ class PRAutoBlogger_Generation_Log {
|
||||
'request_json' => $this->request_json,
|
||||
'response_status' => $this->response_status,
|
||||
'error_message' => $this->error_message,
|
||||
'agent_role' => $this->agent_role,
|
||||
'prompt_version' => $this->prompt_version,
|
||||
'created_at' => $this->created_at,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,25 +123,9 @@ class PRAutoBlogger_LLM_Research_Provider implements PRAutoBlogger_Source_Provid
|
||||
* @return string
|
||||
*/
|
||||
private function build_system_prompt(): string {
|
||||
return 'You are a deep research analyst specializing in emerging trends, '
|
||||
. 'scientific developments, and community knowledge in niche health and '
|
||||
. "biohacking domains. Your task is to identify substantive, actionable findings.\n\n"
|
||||
. "Using your training knowledge, scan across:\n"
|
||||
. "- Recent scientific literature and research directions\n"
|
||||
. "- Active community discussions, debates, and evolving consensus\n"
|
||||
. "- Emerging products, protocols, and methodologies gaining traction\n"
|
||||
. "- Common questions, misconceptions, and points of confusion\n"
|
||||
. "- Regulatory changes, safety concerns, and legal shifts\n"
|
||||
. "- Gaps between what practitioners want to know and what content exists\n\n"
|
||||
. "Your findings must be:\n"
|
||||
. "1. SUBSTANTIVE — 2-3 paragraphs of detailed analysis per finding, not a headline\n"
|
||||
. "2. ACTIONABLE — practical enough to seed a full article\n"
|
||||
. "3. REALISTIC — grounded in what is actually discussed or researched; do NOT invent citations\n"
|
||||
. "4. TIMELY — reflect current knowledge, recent shifts, or emerging questions\n\n"
|
||||
. "Respond with valid JSON only (no preamble, no markdown fences):\n"
|
||||
. '{"findings": [{"title": "string", "content": "string (2-3 paragraphs)", '
|
||||
. '"relevance_score": 0-100, "category": "question|trend|comparison|guide|misconception|safety"}]}' . "\n\n"
|
||||
. 'Generate 8-12 findings. Prioritize depth over breadth. Avoid generic or surface-level topics.';
|
||||
// v0.18.0: renders from the versioned registry ('research.system')
|
||||
// with a byte-identical in-code fallback.
|
||||
return PRAutoBlogger_Prompt_Registry::render( 'research.system' );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user