diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 54b5042..e4e4b0e 100755 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -147,6 +147,7 @@ prautoblogger/ │ │ ├── class-post-assembler.php # Post-creation helpers: taxonomy, log linking, images, sanitization │ │ ├── class-image-pipeline.php # Orchestrates A/B image generation (parallel via batch) │ │ ├── class-image-prompt-builder.php # Generates visual prompts from article/source data +│ │ ├── class-image-template-filler.php # Fills the editorial style template's {{ topic_summary }} slot (v0.16.0) │ │ ├── class-image-media-sideloader.php # Imports images into WordPress media library │ │ ├── class-cost-tracker.php # Logs all API costs, enforces budget limits │ │ ├── class-logger.php # Structured logging singleton (error/warning/info/debug) @@ -437,13 +438,14 @@ All prefixed with `prautoblogger_`: | `prautoblogger_schedule_time` | Daily generation time (HH:MM, default: '03:00') | | `prautoblogger_image_model` | Image model slug from `Image_Model_Registry::get_models()`; provider is derived from this on save | | `prautoblogger_image_provider` | Derived from the chosen model on save (v0.8.0+); not editable in admin UI | -| `prautoblogger_image_prompt_instructions` | System prompt given to the image rewriter LLM (v0.8.0+); falls back to `Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT` when empty | +| `prautoblogger_image_prompt_instructions` | System prompt given to the image rewriter LLM (v0.8.0+); falls back to `Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT` when empty. v0.16.0: the default now instructs the LLM to emit a 1-2 sentence editorial topic/mechanism summary as the SCENE (was a comic gag) | | `prautoblogger_image_nsfw_retry` | Toggle: retry NSFW-blocked image slots with a rule-based fallback prompt (default: '1') | | `prautoblogger_migrated_image_provider_v080` | One-shot migration flag — auto-heals mismatched provider/model pairs on first admin_init after v0.8.0 upgrade | | `prautoblogger_article_font_family` | Font family key: 'default', 'inter', 'georgia', 'merriweather', 'lora', 'open_sans', 'roboto', 'system' | | `prautoblogger_article_font_size` | Body font size in px (0 = theme default). Recommended: 16–18. | | `prautoblogger_table_borders` | Toggle: add borders/padding/striping to tables (default: '1') | -| `prautoblogger_image_style_suffix` | Text appended to every image prompt (default: CEO-locked 90s infomercial prompt) | +| `prautoblogger_image_style_template` | Full image prompt template (v0.16.0+); contains exactly one `{{ topic_summary }}` token filled per-article with the rewriter scene. Default = `PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE` (editorial scientific illustration). Replaces the Style Suffix. Validated on save (single-token, brief A5) | +| `prautoblogger_image_style_suffix` | DEPRECATED (v0.16.0). Former comic style suffix appended to every image prompt; no longer read by the builder. Mirrored to `prautoblogger_image_style_suffix_deprecated` for one cycle and not deleted yet | | `prautoblogger_openrouter_model_registry` | Normalized OpenRouter model list (JSON array, daily refresh, serves as durable cache) | | `prautoblogger_openrouter_model_registry_fetched_at` | Unix timestamp of last successful model registry refresh | @@ -575,6 +577,16 @@ Reddit rejected our OAuth API application (April 2026). We initially switched to The admin model picker (v1) needs to list OpenRouter models with pricing and capabilities. We fetch `https://openrouter.ai/api/v1/models` (free, unauthenticated) once daily and store the normalized payload in a WP option fronted by a 24h transient. On stale-and-fetch-fails, we serve last-good + surface a warning. The registry class (`class-open-router-model-registry.php`) takes all config via constructor (option name, transient name, endpoint URL) — no PRAUTOBLOGGER_* constants inside the class body. Phase 2 lifts it into a shared Composer package with zero internal edits. Phase 3 adds a parallel `Cloudflare_WorkersAI_Model_Registry` behind the same interface. Capability vocabulary: `text→text`, `text+image→text`, `text+audio→text`, `text→image`, `text→audio`, `text→video`, `text→embedding`. Cost: $0/month. +### #20: Editorial illustration prompt + style-template setting (v0.16.0, May 2026) + +Commit 1 of the in-plugin editorial image pipeline (brief `docs/proposals/2026-05-29-image-pipeline-in-plugin-brief.md`). The image style pivots from a single-panel newspaper comic to a **text-free editorial scientific illustration**. Two changes ship here; the deterministic PHP composer is a later commit. + +1. **Prompt structure.** `Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT` now asks the rewriter LLM for a concise 1-2 sentence topic/mechanism summary (one concrete centered focal subject, no text/people-as-gag/logos) as the SCENE, keeping the short CAPTION line for the unchanged HTML-caption-below-image path. The scene/caption parsing contract (`Image_Scene_Parser`) is unchanged. +2. **Template substitution.** The old `trim($scene . ' ' . $style_suffix)` concat is replaced by `PRAutoBlogger_Image_Template_Filler::fill($scene)` in all three entry points (`build_article_prompt`, `build_source_prompt`, `build_fallback_prompt`). The filler resolves the active template (admin override → default), strips control chars and clamps the summary length (brief A5), then substitutes the single `{{ topic_summary }}` token. Degradation (brief A5/A6): if the template lacks exactly one token it appends the summary and logs a warning; if the summary is empty it emits the style-only prompt — a blank or token-only prompt is never sent to the provider. +3. **Setting.** New textarea `prautoblogger_image_style_template` (default `PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE`) replaces the Style Suffix field. On save it is sanitised with `sanitize_textarea_field` and validated to contain exactly one token (`Image_Template_Filler::sanitize_for_save`). The old `prautoblogger_image_style_suffix` is mirrored to `..._deprecated` for one cycle and not deleted (one-time migration `prautoblogger_migrated_style_template_v0160`). + +Provider/model are unchanged — only the prompt text changes, so production keeps emitting via Runware FLUX.1 schnell but now produces text-free editorial base images. + ### #17: Runware as default image backend (v0.9.0, Apr 2026) After a comic-style A/B round in Apr 2026, FLUX.1 schnell via Runware was chosen as the default image backend. Cost is ~$0.0006/image vs ~$0.039/image for Gemini 2.5 Flash Image via OpenRouter (≈65× cheaper), and the looser schnell fidelity reads as editorial-cartoon style — a feature, not a bug, for our single-panel comic aesthetic. FLUX.1 dev (~$0.02/image, 28 steps) remains available as an opt-in for higher-fidelity runs. The Runware layer mirrors the OpenRouter split (interface + provider + support + pricing + batch), all under the 300-line cap. The batch class dispatches `imageInference` POSTs via `curl_multi_init/exec/select` — wall-clock time for the A/B pair drops to ≈ the slowest single image. A v0.9.0 one-time migration (`prautoblogger_migrated_default_image_v090`) flips sites still on the legacy default; explicit user selections are preserved. Cloudflare Workers AI was removed as an image provider in v0.10.0 (see ADR #16 note below). diff --git a/CHANGELOG.md b/CHANGELOG.md index 945c3ce..c714ac4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,43 @@ All notable changes to PRAutoBlogger will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project uses [Semantic Versioning](https://semver.org/). +## [0.16.0] - 2026-05-29 + +### Changed +- **Editorial image prompt (Commit 1 of the in-plugin editorial image pipeline).** + The image style pivots from a single-panel newspaper comic to a text-free + editorial scientific illustration. The rewriter LLM now emits a concise + 1-2 sentence topic/mechanism summary as the SCENE (one centered focal + subject, no text/people-as-gag/logos), keeping the short CAPTION line for the + unchanged HTML-caption-below-image behavior. The scene/caption parsing + contract is unchanged. Provider/model are unchanged — production keeps + generating via Runware FLUX.1 schnell but now produces text-free editorial + base images. See `docs/proposals/2026-05-29-image-pipeline-in-plugin-brief.md`. +- The prompt builder now substitutes the rewriter scene into an editable style + template's `{{ topic_summary }}` slot via the new + `PRAutoBlogger_Image_Template_Filler`, replacing the old + `scene + style suffix` concatenation, across all three entry points + (`build_article_prompt`, `build_source_prompt`, `build_fallback_prompt`). + Before substitution the scene is stripped of control characters and + length-clamped (brief A5). If the template lacks exactly one token the + summary is appended and a warning logged; if the summary is empty the + style-only prompt is emitted — a blank or token-only prompt is never sent to + the provider (brief A5/A6). + +### Added +- New setting **Style Template** (`prautoblogger_image_style_template`, + textarea) replacing the Style Suffix field. Default = + `PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE` (the editorial template). On save + it is validated to contain exactly one `{{ topic_summary }}` token; an invalid + template keeps the previous value and surfaces a settings error. + +### Deprecated +- `prautoblogger_image_style_suffix` is deprecated and no longer read by the + prompt builder. A one-time migration (`prautoblogger_migrated_style_template_v0160`) + mirrors its value to `prautoblogger_image_style_suffix_deprecated` for one + version cycle and seeds the new template default. The old option is NOT + deleted this cycle. + ## [0.15.1] - 2026-05-14 ### Fixed diff --git a/includes/admin/class-admin-page.php b/includes/admin/class-admin-page.php index 59e90dc..8738b6b 100755 --- a/includes/admin/class-admin-page.php +++ b/includes/admin/class-admin-page.php @@ -330,6 +330,11 @@ class PRAutoBlogger_Admin_Page { if ( 'prautoblogger_image_model' === $option_name ) { return $this->sanitize_image_model( (string) $value ); } + if ( 'prautoblogger_image_style_template' === $option_name ) { + // Multi-line template: validation + textarea-safe sanitisation + // (incl. the single-token check, brief A5) lives in the filler. + return PRAutoBlogger_Image_Template_Filler::sanitize_for_save( (string) $value ); + } return sanitize_text_field( (string) $value ); } diff --git a/includes/admin/class-settings-fields-extended.php b/includes/admin/class-settings-fields-extended.php index 7301235..3b903a8 100644 --- a/includes/admin/class-settings-fields-extended.php +++ b/includes/admin/class-settings-fields-extended.php @@ -8,7 +8,7 @@ declare(strict_types=1); * * What: Declarative field definitions for operational settings sections. * Who calls it: PRAutoBlogger_Settings_Fields::get_fields() merges these in. - * Dependencies: PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL, PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX constants, + * Dependencies: PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL, PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE constants, * PRAutoBlogger_Article_Typography (font choices), PRAutoBlogger_Image_Model_Registry. * * @see admin/class-settings-fields.php — Core fields and sections; calls get_extended_fields(). @@ -235,12 +235,12 @@ class PRAutoBlogger_Settings_Fields_Extended { 'icon' => '🔑', ), array( - 'id' => 'prautoblogger_image_style_suffix', - 'label' => __( 'Style Suffix', 'prautoblogger' ), + 'id' => 'prautoblogger_image_style_template', + 'label' => __( 'Style Template', 'prautoblogger' ), 'type' => 'textarea', 'section' => 'prautoblogger_images', - 'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX, - 'description' => __( 'Appended to every image prompt. Controls visual look. Changing mid-run causes visible style drift.', 'prautoblogger' ), + 'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE, + 'description' => __( 'The full image prompt template. Must contain exactly one {{ topic_summary }} token, which is filled with a 1-2 sentence topic/mechanism summary per article. The rest is brand-locked editorial style. Replaces the old Style Suffix (now deprecated). Changing mid-run causes visible style drift.', 'prautoblogger' ), ), array( 'id' => 'prautoblogger_image_prompt_instructions', @@ -248,7 +248,7 @@ class PRAutoBlogger_Settings_Fields_Extended { 'type' => 'textarea', 'section' => 'prautoblogger_images', 'default' => PRAutoBlogger_Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT, - 'description' => __( 'System prompt given to the rewriter LLM that turns each article into a SCENE + CAPTION for the image generator. Changing this reshapes the look of all future images. Leave blank to use the default.', 'prautoblogger' ), + 'description' => __( 'System prompt given to the rewriter LLM that turns each article into a SCENE (a 1-2 sentence editorial topic/mechanism summary, substituted into the Style Template) plus a CAPTION (HTML text shown below the image). Changing this reshapes the look of all future images. Leave blank to use the default.', 'prautoblogger' ), ), array( 'id' => 'prautoblogger_image_nsfw_retry', diff --git a/includes/class-prautoblogger.php b/includes/class-prautoblogger.php index f32df6d..24c5b1a 100644 --- a/includes/class-prautoblogger.php +++ b/includes/class-prautoblogger.php @@ -267,6 +267,24 @@ class PRAutoBlogger { update_option( 'prautoblogger_migrated_style_suffix_v4', '1' ); } + // One-time migration (v0.16.0): editorial pivot. The comic Style Suffix + // is replaced by the editorial Style Template. Mirror the old suffix + // value into a deprecated-keyed option for one version cycle (so a + // customised value is recoverable / auditable) and seed the new + // template with its editorial default. The old option is intentionally + // NOT deleted this cycle. See docs/proposals/2026-05-29-image-pipeline-in-plugin-brief.md §8 Commit 1. + if ( ! get_option( 'prautoblogger_migrated_style_template_v0160' ) ) { + $old_suffix = get_option( 'prautoblogger_image_style_suffix', '' ); + if ( '' !== $old_suffix && false === get_option( 'prautoblogger_image_style_suffix_deprecated', false ) ) { + update_option( 'prautoblogger_image_style_suffix_deprecated', $old_suffix ); + } + // Only seed the new template if the admin has not already set one. + if ( '' === (string) get_option( 'prautoblogger_image_style_template', '' ) ) { + update_option( 'prautoblogger_image_style_template', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE ); + } + update_option( 'prautoblogger_migrated_style_template_v0160', '1' ); + } + // v0.9.0 — Runware as default image model. v0.10.0 — remove CF Workers AI. PRAutoBlogger_Activator::migrate_default_image_v090(); PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run(); diff --git a/includes/core/class-image-prompt-builder.php b/includes/core/class-image-prompt-builder.php index c609af4..28c089f 100644 --- a/includes/core/class-image-prompt-builder.php +++ b/includes/core/class-image-prompt-builder.php @@ -6,13 +6,16 @@ declare(strict_types=1); * * 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 + * Uses a cheap LLM (via OpenRouter) to distill article content into a concise + * 1-2 sentence editorial topic/mechanism summary, then substitutes that summary + * into the admin-editable editorial style template (the {{ topic_summary }} + * slot) to produce a text-free image-gen prompt. 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_Image_Template_Filler (template substitution), * PRAutoBlogger_Cost_Tracker (logs prompt-rewrite cost), * PRAutoBlogger_Logger (diagnostics). * @@ -27,34 +30,39 @@ class PRAutoBlogger_Image_Prompt_Builder { * 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). + * + * Editorial pivot (v0.16.0): the model emits a concise topic/mechanism + * summary as the SCENE — concrete, single centered focal subject, no text, + * no people-as-gag, no logos — which the prompt builder substitutes into + * the editorial style template's {{ topic_summary }} slot. The CAPTION line + * is preserved unchanged for the existing HTML-caption-below-image path. */ 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. +You write subject descriptions for a text-free editorial science illustration about peptides, supplements, or biohacking. -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: +Given an article title and summary, 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. +SCENE: A concise 1-2 sentence description of the article's core topic or mechanism, written as the SUBJECT of a single editorial illustration. Name ONE concrete, centered focal subject and the few supporting visual elements around it (e.g. a labelled-free vial and dropper, a stylised receptor on a cell membrane, a molecular chain, a dosing syringe, an organ cross-section). Favor a clear central focal point with margin around it so the image crops well. This is image-gen direction, not prose. -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. +CAPTION: A short, informative caption line (under 15 words) for display as HTML text below the image. Plain and editorial, not a joke. 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 +- The SCENE must depict the article's actual subject/mechanism, not a generic science scene. +- NO text, words, captions, speech bubbles, labels, or logos described in the SCENE — the illustration is text-free; text is rendered separately. +- NO people used as a visual gag and NO cartoon/comic framing. A human anatomical element (a cell, an organ, a hand holding a vial) is fine if it is the legitimate subject. +- Do NOT personify molecules, peptides, hormones, or proteins (no "smiling molecule"); depict them as clean stylised diagrams or structures. +- Keep the staging simple with one clear focal subject, readable at small sizes. - 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. +A single glass peptide vial on a clean surface with a fine dropper above it, a faint stylised molecular chain arcing behind as a backdrop, centered with generous negative space. -"They told me the peptide did the work, not the squats." +A concentrated look at how the compound is prepared before use. PROMPT; /** - * Max tokens for the rewriter response. Comic concepts need more room - * for the scene description plus the caption punchline. + * Max tokens for the rewriter response. Enough room for the 1-2 sentence + * topic/mechanism summary plus the short editorial caption line. */ private const REWRITER_MAX_TOKENS = 180; @@ -85,8 +93,9 @@ PROMPT; * 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. + * failure. Splits scene (the topic summary, for image gen) from caption + * (HTML below the image) and substitutes the scene into the editorial + * style template's {{ topic_summary }} slot. * * @param array{post_title?: string, post_content?: string, suggested_title?: string} $article_data * @return array{prompt: string, caption: string} @@ -98,10 +107,8 @@ PROMPT; $parsed = $this->rewrite_via_llm( $title, $first_para ); - $style_suffix = $this->get_style_suffix(); - return array( - 'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ), + 'prompt' => PRAutoBlogger_Image_Template_Filler::fill( $parsed['scene'] ), 'caption' => $parsed['caption'], ); } @@ -120,10 +127,8 @@ PROMPT; $parsed = $this->rewrite_via_llm( $title, $context ); - $style_suffix = $this->get_style_suffix(); - return array( - 'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ), + 'prompt' => PRAutoBlogger_Image_Template_Filler::fill( $parsed['scene'] ), 'caption' => $parsed['caption'], ); } @@ -260,10 +265,9 @@ PROMPT; * @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(); + $parsed = PRAutoBlogger_Image_Scene_Parser::synthesize_visual_concepts_fallback( $title, '' ); return array( - 'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ), + 'prompt' => PRAutoBlogger_Image_Template_Filler::fill( $parsed['scene'] ), 'caption' => $parsed['caption'], ); } @@ -281,20 +285,4 @@ PROMPT; $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; - } } diff --git a/includes/core/class-image-scene-parser.php b/includes/core/class-image-scene-parser.php index 6b270e2..f42fcfb 100644 --- a/includes/core/class-image-scene-parser.php +++ b/includes/core/class-image-scene-parser.php @@ -70,22 +70,25 @@ class PRAutoBlogger_Image_Scene_Parser { /** * Rule-based fallback when LLM rewriting is unavailable. * - * Kept deliberately simple — this only runs if OpenRouter is down. + * Kept deliberately simple — this only runs if OpenRouter is down. Produces + * a text-free editorial-illustration subject (not a comic) so the prompt + * builder always has a non-empty topic summary to substitute into the + * style template, never shipping a blank prompt to the provider (brief A6). * * @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. */ public static function synthesize_visual_concepts_fallback( string $title, string $supporting_text ): array { - // Fallback produces a simple comic concept when the LLM is unavailable. + // Fallback produces a simple editorial-illustration subject when the LLM is unavailable. $scene = sprintf( - 'A cartoon scientist in a lab coat looking bewildered while examining something related to: %s.', + 'A clean, centered editorial science illustration representing the topic of %s, with a single clear focal subject and generous negative space.', $title ); return array( 'scene' => trim( $scene ), - 'caption' => 'Science is full of surprises.', + 'caption' => '', ); } } diff --git a/includes/core/class-image-template-filler.php b/includes/core/class-image-template-filler.php new file mode 100644 index 0000000..5f52ae0 --- /dev/null +++ b/includes/core/class-image-template-filler.php @@ -0,0 +1,185 @@ +warning( + 'Editorial prompt fill received an empty topic summary; emitting style-only prompt.', + 'image_template_filler' + ); + return self::strip_token( $template ); + } + + $token_count = substr_count( $template, self::TOKEN ); + + if ( 1 === $token_count ) { + return trim( str_replace( self::TOKEN, $summary, $template ) ); + } + + // Brief A5: admin removed or duplicated the token. Fall back to + // appending the summary to the style text and log a warning rather + // than shipping a malformed prompt. + PRAutoBlogger_Logger::instance()->warning( + sprintf( + 'Image style template has %d "%s" tokens (expected 1); appending summary instead of substituting.', + $token_count, + self::TOKEN + ), + 'image_template_filler' + ); + + $base = self::strip_token( $template ); + return trim( $base . ' ' . $summary ); + } + + /** + * Sanitise and validate the admin-submitted style template on save. + * + * Multi-line, so sanitised with sanitize_textarea_field() (not + * sanitize_text_field(), which would strip newlines). Per brief A5 the + * template must contain exactly one TOKEN; otherwise the previous value is + * kept and a settings error is surfaced. A blank submission falls back to + * the editorial default so image generation can never be bricked. + * + * @param string $value Submitted template. + * @return string Sanitised, validated template. + */ + public static function sanitize_for_save( string $value ): string { + $clean = sanitize_textarea_field( $value ); + + if ( '' === trim( $clean ) ) { + return PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE; + } + + $token_count = substr_count( $clean, self::TOKEN ); + if ( 1 !== $token_count ) { + add_settings_error( + 'prautoblogger_image_style_template', + 'prautoblogger_image_style_template_token', + sprintf( + /* translators: 1: required token, 2: number of tokens found. */ + esc_html__( 'The Style Template must contain exactly one %1$s token (found %2$d). Keeping the previous value.', 'prautoblogger' ), + esc_html( self::TOKEN ), + (int) $token_count + ) + ); + return (string) get_option( 'prautoblogger_image_style_template', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE ); + } + + return $clean; + } + + /** + * Resolve the active template: admin override wins; blank → default. + * + * @return string Non-empty template string. + */ + private static function resolve_template(): string { + $override = (string) get_option( 'prautoblogger_image_style_template', '' ); + if ( '' !== trim( $override ) ) { + return $override; + } + return PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE; + } + + /** + * Strip control characters and clamp length of the rewriter summary + * (brief A5). Whitespace runs (including newlines) are collapsed to single + * spaces so the summary sits cleanly inside the template. + * + * @param string $scene Raw rewriter scene. + * @return string Sanitised, length-clamped summary (possibly empty). + */ + private static function sanitize_summary( string $scene ): string { + // Remove control characters (C0/C1) except via whitespace collapse below. + $clean = preg_replace( '/[\x00-\x1F\x7F]+/u', ' ', $scene ); + if ( null === $clean ) { + // preg_replace can return null on bad UTF-8; degrade conservatively. + $clean = preg_replace( '/[\x00-\x1F\x7F]+/', ' ', $scene ); + $clean = is_string( $clean ) ? $clean : ''; + } + + // Collapse whitespace runs to single spaces. + $collapsed = preg_replace( '/\s+/u', ' ', $clean ); + $clean = is_string( $collapsed ) ? $collapsed : $clean; + $clean = trim( $clean ); + + if ( '' === $clean ) { + return ''; + } + + // Clamp length (multibyte-aware where available). + if ( function_exists( 'mb_strlen' ) ) { + if ( mb_strlen( $clean ) > self::MAX_SUMMARY_CHARS ) { + $clean = rtrim( mb_substr( $clean, 0, self::MAX_SUMMARY_CHARS ) ) . '...'; + } + } elseif ( strlen( $clean ) > self::MAX_SUMMARY_CHARS ) { + $clean = rtrim( substr( $clean, 0, self::MAX_SUMMARY_CHARS ) ) . '...'; + } + + return $clean; + } + + /** + * Remove every occurrence of the token from a template, tidying the + * leftover whitespace so the residual style text is still well-formed. + * + * @param string $template Template possibly containing the token. + * @return string Token-free, trimmed template. + */ + private static function strip_token( string $template ): string { + $stripped = str_replace( self::TOKEN, '', $template ); + $stripped = preg_replace( '/[ \t]+/', ' ', $stripped ); + $stripped = is_string( $stripped ) ? $stripped : $template; + return trim( $stripped ); + } +} diff --git a/prautoblogger.php b/prautoblogger.php index 7cf49a9..1db73bb 100644 --- a/prautoblogger.php +++ b/prautoblogger.php @@ -9,7 +9,7 @@ * Plugin Name: PRAutoBlogger * Plugin URI: https://peptiderepo.com/prautoblogger * Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics. - * Version: 0.15.1 + * Version: 0.16.0 * Requires at least: 6.0 * Requires PHP: 7.4 * Author: PeptideRepo @@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) { | Defined here so every file in the plugin can reference paths, versions, | and limits without magic strings. */ -define( 'PRAUTOBLOGGER_VERSION', '0.15.1' ); +define( 'PRAUTOBLOGGER_VERSION', '0.16.0' ); define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' ); define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ ); define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); @@ -66,6 +66,22 @@ define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX', 'Style: a single-panel newspaper comic in classic black ink on white paper, simple clean linework like The Far Side or B.C., slightly yellowed newsprint texture, hand-drawn crosshatching for shading, bold panel border, warm and humorous tone, visible in a newspaper comics section. IMPORTANT: Do not render any text, captions, speech bubbles, or words inside the image.' ); +// Default editorial-illustration prompt template for the image generator. +// Updated 2026-05-29 (v0.16.0): pivot from single-panel comic to text-free +// editorial scientific illustration. The {{ topic_summary }} token is filled +// at build time with a 1-2 sentence topic/mechanism description from the +// rewriter LLM; the rest is brand-locked. Replaces the old Style Suffix. +// Users may override via Settings -> Images -> Style Template. Per brief A5 +// the template must contain exactly one {{ topic_summary }} token. +define( + 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE', + "Editorial scientific illustration, flat vector aesthetic, limited palette of\n" . + "teal (#1B8A92) and cream (#FAF6EE) with ink (#0D0D0D) line work and accents\n" . + "of orange (#FF8A3D) and lime (#7FD600). Subject: {{ topic_summary }}.\n" . + "Style reference: Quanta Magazine, The Pudding, scientific editorial illustration.\n" . + "Composition: clear focal point, balanced negative space, subtle textural detail.\n" . + 'Mood: authoritative, clinical, calm. NO TEXT. NO PEOPLE. NO LOGOS. NO LABELS.' +); // Opik observability (feature-flag gated). // API credentials must be defined in wp-config.php constants: // - PRAUTOBLOGGER_OPIK_API_KEY: API key for authentication diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 8e03d05..26b34d3 100755 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -67,11 +67,20 @@ if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER' ) ) { define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER', 'runware' ); } -// Default image style suffix used by ImagePromptBuilder and settings UI. +// Default image style suffix used by legacy ImagePromptBuilder paths / settings UI. if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX' ) ) { define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX', 'Style: test infomercial style.' ); } +// Editorial style template (v0.16.0): default for prautoblogger_image_style_template. +// Must contain exactly one {{ topic_summary }} token (brief A5). +if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE' ) ) { + define( + 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE', + "Editorial scientific illustration. Subject: {{ topic_summary }}. NO TEXT. NO PEOPLE. NO LOGOS. NO LABELS." + ); +} + // WordPress database constants used in $wpdb queries. if ( ! defined( 'ARRAY_A' ) ) { define( 'ARRAY_A', 'ARRAY_A' ); diff --git a/tests/unit/Core/ImagePromptBuilderTest.php b/tests/unit/Core/ImagePromptBuilderTest.php index 5896f3f..9820398 100644 --- a/tests/unit/Core/ImagePromptBuilderTest.php +++ b/tests/unit/Core/ImagePromptBuilderTest.php @@ -3,7 +3,7 @@ * Tests for PRAutoBlogger_Image_Prompt_Builder. * * Validates prompt generation from article content and source data, - * including style suffix appending and HTML stripping. + * including editorial-template substitution ({{ topic_summary }}) and HTML stripping. * * @package PRAutoBlogger\Tests\Core */ @@ -68,7 +68,9 @@ class ImagePromptBuilderTest extends BaseTestCase { [ 'prompt' => $scene, 'caption' => $caption ] = $builder->build_article_prompt( $article_data ); $this->assertStringContainsString( 'How to Train Your Dragon', $scene ); - // Style suffix should be appended. + // Editorial template wraps the topic summary; token must be substituted. + $this->assertStringNotContainsString( '{{ topic_summary }}', $scene ); + $this->assertStringContainsString( 'Editorial scientific illustration', $scene ); $this->assertNotEmpty( $scene ); $this->assertGreaterThan( 50, strlen( $scene ) ); $this->assertIsString( $caption );