extract_first_paragraph( $content ); $parsed = $this->rewrite_via_llm( $title, $first_para ); $style_suffix = get_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX ); return [ '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'] ?? []; $context = is_array( $comments ) && ! empty( $comments ) ? $comments[0] : ''; $parsed = $this->rewrite_via_llm( $title, $context ); $style_suffix = get_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX ); return [ '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}"; } try { $llm = $this->get_llm_provider(); $model = get_option( 'prautoblogger_analysis_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL ); $result = $llm->send_chat_completion( [ [ 'role' => 'system', 'content' => self::REWRITER_SYSTEM_PROMPT ], [ 'role' => 'user', 'content' => $user_message ], ], $model, [ '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 ( '' !== $raw ) { return $this->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 $this->synthesize_visual_concepts_fallback( $title, $context ); } /** * Parse the LLM response into separate scene and caption parts. * * Expected format from the LLM: * Scene description text here. * * "Caption punchline here." * * Falls back gracefully: if no blank-line separator is found, the entire * response becomes the scene with an empty caption. * * @param string $raw Raw LLM response text. * @return array{scene: string, caption: string} */ private function parse_scene_and_caption( string $raw ): array { // Split on blank line (the format the system prompt requests). $parts = preg_split( '/\n\s*\n/', $raw, 2 ); if ( count( $parts ) >= 2 ) { $scene = trim( $parts[0] ); $caption = trim( $parts[1] ); // Strip surrounding quotes from caption — the LLM wraps it in quotes. $caption = trim( $caption, '"\'' ); return [ 'scene' => $scene, 'caption' => $caption ]; } // No blank-line separator found — treat entire response as scene. return [ 'scene' => trim( $raw ), 'caption' => '' ]; } /** * Extract the first paragraph from HTML content. * * Removes all tags and captures the text up to the first double-newline * or 200 characters, whichever comes first. * * @param string $html HTML content. * @return string Plain text of the first paragraph. */ private function extract_first_paragraph( string $html ): string { $text = wp_strip_all_tags( $html ); $paras = preg_split( '/\n\n+/', $text ); $para = isset( $paras[0] ) ? trim( $paras[0] ) : ''; if ( strlen( $para ) > 200 ) { $para = substr( $para, 0, 200 ) . '...'; } return $para; } /** * 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 = $this->synthesize_visual_concepts_fallback( $title, '' ); $style_suffix = get_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX ); return [ 'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ), 'caption' => $parsed['caption'], ]; } /** * Rule-based fallback when LLM rewriting is unavailable. * * Kept deliberately simple — this only runs if OpenRouter is down. * * @param string $title Main heading / topic. * @param string $supporting_text Additional context. * @return array{scene: string, caption: string} Scene for image gen, caption for HTML. */ private function synthesize_visual_concepts_fallback( string $title, string $supporting_text ): array { // Fallback produces a simple comic concept when the LLM is unavailable. $scene = sprintf( 'A cartoon scientist in a lab coat looking bewildered while examining something related to: %s.', $title ); return [ 'scene' => trim( $scene ), 'caption' => 'Science is full of surprises.', ]; } /** * Lazy-load the OpenRouter provider. * * @return PRAutoBlogger_OpenRouter_Provider */ private function get_llm_provider(): PRAutoBlogger_OpenRouter_Provider { if ( null === $this->llm ) { $this->llm = new PRAutoBlogger_OpenRouter_Provider(); } return $this->llm; } }