Files
prautoblogger/includes/core/class-image-prompt-builder.php
peptiderepo 50ae7ca530 refactor: extract image scene parser from prompt builder
Split class-image-prompt-builder.php (331 lines) into two files:
- class-image-prompt-builder.php (now ~300 lines)
- class-image-scene-parser.php (~91 lines) with three public static methods:
  - parse_scene_and_caption()
  - extract_first_paragraph()
  - synthesize_visual_concepts_fallback()

Builder now calls these via PRAutoBlogger_Image_Scene_Parser:: static calls.
Maintains identical external behavior; internal refactor only.

Target: prevents CI violations when adding Opik instrumentation.
2026-04-24 04:25:27 +00:00

301 lines
11 KiB
PHP

<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Builds image prompts from article content or source data.
*
* Uses a cheap LLM (via OpenRouter) to distill article content into concise
* visual scene descriptions optimised for image generation. Falls back to rule-based
* synthesis if the LLM call fails, so image generation never blocks on a
* prompt-rewriting outage.
*
* Triggered by: PRAutoBlogger_Image_Pipeline during content generation run.
* Dependencies: PRAutoBlogger_OpenRouter_Provider (LLM call),
* PRAutoBlogger_Cost_Tracker (logs prompt-rewrite cost),
* PRAutoBlogger_Logger (diagnostics).
*
* @see core/class-image-pipeline.php — Consumes build_article_prompt() and build_source_prompt().
* @see providers/class-open-router-provider.php — LLM provider used for rewriting.
* @see ARCHITECTURE.md — Image generation data flow.
*/
class PRAutoBlogger_Image_Prompt_Builder {
/**
* Default system prompt that teaches the LLM how to write image-gen
* prompts. Exposed as a `public const` so the admin-settings layer can
* use it as the default value for `prautoblogger_image_prompt_instructions`.
* The option, when non-empty, wins at call time (see rewrite_via_llm).
*/
public const REWRITER_SYSTEM_PROMPT = <<<'PROMPT'
You are a comedy writer and single-panel cartoon creator, like Gary Larson (The Far Side) meets science humor.
Given an article title and summary about peptides, supplements, or biohacking, create a SINGLE-PANEL COMIC concept. Output TWO parts separated by a blank line:
SCENE: One sentence describing the visual gag — a funny, absurd, or ironic situation related to the article's topic. Favor CONCRETE, HUMAN-SCALE scenes: people reacting to things, doctors and patients, scientists in a lab, gym bros with peptide vials, partners at a kitchen table, ordinary objects in ordinary rooms. Keep it simple — one clear visual joke, 1-3 characters max.
CAPTION: A short, punchy caption or speech bubble line (under 15 words) that delivers the punchline. The humor should be dry, nerdy, and accessible — the reader should chuckle even if they only half-understand the science.
Rules:
- The joke MUST relate to the article's actual subject matter, not generic science humor
- DO NOT personify molecules, peptides, hormones, proteins, or other chemical structures (no "anthropomorphic molecule", no "cartoon hormone"). Image models render anthropomorphized chemistry as incoherent blobs. If the article is about a substance, show a PERSON reacting to or using the substance; if the article is abstract (a mechanism, a process, a concept), fall back on a human observer reacting to its effect.
- Characters can have simple cartoon faces (round heads, dot eyes, expressive eyebrows)
- Keep the scene physically simple — few objects, clear staging, easy to read at thumbnail size
- The caption should work as a standalone joke even without the image
- No logos, watermarks, or branding
- Output ONLY the scene and caption. No preamble, no explanation.
Example output format:
A muscular gym bro stares, deflated, at a tiny glass vial in his hand while his friend squats a barbell effortlessly in the background.
"They told me the peptide did the work, not the squats."
PROMPT;
/**
* Max tokens for the rewriter response. Comic concepts need more room
* for the scene description plus the caption punchline.
*/
private const REWRITER_MAX_TOKENS = 180;
/**
* OpenRouter provider for LLM calls. Null until first use.
*
* @var PRAutoBlogger_OpenRouter_Provider|null
*/
private ?PRAutoBlogger_OpenRouter_Provider $llm = null;
/**
* Opik trace context for instrumentation.
*
* @var PRAutoBlogger_Opik_Trace_Context|null
*/
private ?PRAutoBlogger_Opik_Trace_Context $trace_context = null;
/**
* Constructor.
*
* @param PRAutoBlogger_Opik_Trace_Context|null $trace_context Optional trace context for instrumentation.
*/
public function __construct( ?PRAutoBlogger_Opik_Trace_Context $trace_context = null ) {
$this->trace_context = $trace_context;
}
/**
* Build a visual prompt from finished article content.
*
* Tries LLM rewriting first; falls back to rule-based synthesis on
* failure. Splits scene (for image gen) from caption (HTML below the
* image) and appends the style suffix.
*
* @param array{post_title?: string, post_content?: string, suggested_title?: string} $article_data
* @return array{prompt: string, caption: string}
*/
public function build_article_prompt( array $article_data ): array {
$title = $article_data['post_title'] ?? $article_data['suggested_title'] ?? 'Product';
$content = $article_data['post_content'] ?? '';
$first_para = PRAutoBlogger_Image_Scene_Parser::extract_first_paragraph( $content );
$parsed = $this->rewrite_via_llm( $title, $first_para );
$style_suffix = $this->get_style_suffix();
return array(
'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ),
'caption' => $parsed['caption'],
);
}
/**
* Build a visual prompt from source Reddit thread data. Tries LLM
* rewriting first; falls back to rule-based synthesis on failure.
*
* @param array{title?: string, selftext?: string, comments?: string[]} $source_data
* @return array{prompt: string, caption: string}
*/
public function build_source_prompt( array $source_data ): array {
$title = $source_data['title'] ?? 'Reddit Discussion';
$comments = $source_data['comments'] ?? array();
$context = is_array( $comments ) && ! empty( $comments ) ? $comments[0] : '';
$parsed = $this->rewrite_via_llm( $title, $context );
$style_suffix = $this->get_style_suffix();
return array(
'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ),
'caption' => $parsed['caption'],
);
}
/**
* Use a cheap LLM to distill title + context into a visual scene + caption.
*
* The LLM response contains a SCENE line and a CAPTION line separated by a
* blank line. This method parses them apart so the scene drives image gen
* (no text baked in) and the caption is inserted as HTML below the image.
*
* Falls back to rule-based synthesis if the LLM call fails for any
* reason (network, auth, timeout, unexpected response shape).
*
* Side effects: one OpenRouter API call; one cost-tracker log entry.
*
* @param string $title Article or thread title.
* @param string $context First paragraph or top comment.
* @return array{scene: string, caption: string} Scene for image gen, caption for HTML.
*/
private function rewrite_via_llm( string $title, string $context ): array {
$title = trim( sanitize_text_field( $title ) );
$context = trim( sanitize_text_field( $context ) );
// Truncate context to keep prompt tokens low.
if ( strlen( $context ) > 300 ) {
$context = substr( $context, 0, 300 ) . '...';
}
$user_message = "Article title: {$title}";
if ( '' !== $context ) {
$user_message .= "\n\nSummary: {$context}";
}
$span_id = null;
try {
if ( null !== $this->trace_context ) {
$span_id = $this->trace_context->start_span(
array(
'name' => 'image_prompt_rewrite',
'type' => 'llm',
'model' => get_option( 'prautoblogger_analysis_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL ),
'provider' => 'openrouter',
'input' => array(
'title' => $title,
'context' => substr( $context, 0, 200 ),
),
)
);
}
$llm = $this->get_llm_provider();
$model = get_option( 'prautoblogger_analysis_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL );
$system = $this->resolve_system_prompt();
$result = $llm->send_chat_completion(
array(
array(
'role' => 'system',
'content' => $system,
),
array(
'role' => 'user',
'content' => $user_message,
),
),
$model,
array(
'temperature' => 0.7,
'max_tokens' => self::REWRITER_MAX_TOKENS,
)
);
$raw = trim( $result['content'] ?? '' );
// Log the rewrite cost so it shows in the analytics dashboard.
( new PRAutoBlogger_Cost_Tracker() )->log_api_call(
null,
'image_prompt_rewrite',
'openrouter',
$model,
$result['prompt_tokens'] ?? 0,
$result['completion_tokens'] ?? 0
);
$cost = $llm->estimate_cost(
$model,
$result['prompt_tokens'] ?? 0,
$result['completion_tokens'] ?? 0
);
PRAutoBlogger_Logger::instance()->debug(
sprintf( 'Image prompt rewritten (%d→%d chars, $%.6f): %s', strlen( $user_message ), strlen( $raw ), $cost, substr( $raw, 0, 120 ) ),
'image_prompt_builder'
);
if ( null !== $span_id && null !== $this->trace_context ) {
$this->trace_context->end_span(
$span_id,
array(
'output' => array(
'raw_response' => substr( $raw, 0, 200 ),
),
'usage' => array(
'prompt_tokens' => $result['prompt_tokens'] ?? 0,
'completion_tokens' => $result['completion_tokens'] ?? 0,
'total_tokens' => ( $result['prompt_tokens'] ?? 0 ) + ( $result['completion_tokens'] ?? 0 ),
),
)
);
}
if ( '' !== $raw ) {
return PRAutoBlogger_Image_Scene_Parser::parse_scene_and_caption( $raw );
}
} catch ( \Throwable $e ) {
// LLM failure is not fatal — fall back to rule-based synthesis.
PRAutoBlogger_Logger::instance()->warning(
sprintf( 'Image prompt LLM rewrite %s, using fallback: %s', get_class( $e ), $e->getMessage() ),
'image_prompt_builder'
);
}
return PRAutoBlogger_Image_Scene_Parser::synthesize_visual_concepts_fallback( $title, $context );
}
/**
* Public entry into the rule-based fallback, used by NSFW retry to
* rebuild a provider-safe prompt from just the article title. Matches
* the return shape of build_article_prompt() / build_source_prompt().
*
* @param string $title Article or source title.
* @return array{prompt: string, caption: string}
*/
public function build_fallback_prompt( string $title ): array {
$parsed = PRAutoBlogger_Image_Scene_Parser::synthesize_visual_concepts_fallback( $title, '' );
$style_suffix = $this->get_style_suffix();
return array(
'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ),
'caption' => $parsed['caption'],
);
}
/** Lazy-load the OpenRouter provider. */
private function get_llm_provider(): PRAutoBlogger_OpenRouter_Provider {
if ( null === $this->llm ) {
$this->llm = new PRAutoBlogger_OpenRouter_Provider();
}
return $this->llm;
}
/** Admin option wins; blank falls back to REWRITER_SYSTEM_PROMPT. */
private function resolve_system_prompt(): string {
$override = (string) get_option( 'prautoblogger_image_prompt_instructions', '' );
return '' !== trim( $override ) ? $override : self::REWRITER_SYSTEM_PROMPT;
}
/**
* Get image style suffix with empty-string safeguard.
*
* Treats both absent and empty string as "use default" to prevent
* silent style loss if the admin settings field is saved empty.
*
* @return string The style suffix with safe fallback.
*/
private function get_style_suffix(): string {
$style_suffix = (string) get_option( 'prautoblogger_image_style_suffix', '' );
if ( '' === trim( $style_suffix ) ) {
$style_suffix = PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX;
}
return $style_suffix;
}
}