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; } }