Files
prautoblogger/ARCHITECTURE.md
peptiderepo 5c7736bf4e v0.8.0: fix silent image failures + NSFW retry + prompt instructions (#97)
* fix(image-pipeline): surface per-image failures in the event log

Two gaps made posts 650/657 appear to silently "lose" their featured
images on 2026-04-20:

1. The batch provider tagged each failed key with ['error' => ...] but
   the ERROR log line it emitted didn't identify which key failed, so
   operators couldn't tell image_a from image_b in the event log when
   one of two parallel requests died.

2. Image_Pipeline::process_image_a/_b folded the per-key error into
   $result['errors'] and returned silently, with no WARNING row. Ops
   only learned about the miss when the outer catch in the caller fired
   — and on the 2026-04-20 incident that outer catch didn't fire, so
   nothing was logged at all except an unrelated pricing warning.

Fix:

- class-open-router-image-batch.php: collect_result() takes the request
  key as its first argument and every Logger::error message reads
  'OpenRouter batch image gen <class> for key "<key>": <detail>'.
  Matches the shape of OpenRouter_Image_Support::log_retry.

- class-image-pipeline.php: process_image_a and process_image_b now
  emit a Logger::warning('image_pipeline') row whenever $image_data is
  missing or carries an error key, in addition to pushing to
  $result['errors']. The warning fires whether or not the outer catch
  runs.

Test: ImagePipelineTest::test_partial_batch_failure_emits_warning_log_row
mocks $wpdb, runs the pipeline with image_a erroring, and asserts at
least one warning/image_pipeline row was inserted.

No behavior change beyond logging; cost impact zero.

Part of the v0.8.0 image bundle (issues from thread image-mime-bug).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(images): retry Cloudflare NSFW-blocked prompts with a sanitized fallback

Cloudflare's Workers AI FLUX.1 schnell content filter sometimes rejects
peptide-comic prompts as NSFW (HTTP 400, errors[].code === 3030). On
2026-04-20 this produced a silent publish-without-featured-image for
post #650 ("Semaglutide vs Tirzepatide") — the LLM-rewritten scene
tripped the filter and we had no recovery path.

New behaviour: on a 3030 response, the Cloudflare provider throws a
typed PRAutoBlogger_Image_NSFW_Blocked exception. The batch wrapper
catches it and tags the per-slot result with
`error_type: nsfw_blocked`. Image_Pipeline hands the results to a new
PRAutoBlogger_Image_NSFW_Retry helper, which reruns the blocked slot
once with a sanitized fallback prompt (title + style suffix, no LLM
rewrite). On success the retry result replaces the error entry; on a
second block, a WARNING is logged and the pipeline publishes without
that image — same terminal behaviour we had before, just explicit.

Files:

- New providers/class-image-nsfw-blocked.php — typed exception.
- providers/class-cloudflare-image-support.php — is_nsfw_error() +
  throw_if_nsfw() helpers.
- providers/class-cloudflare-image-provider.php — calls throw_if_nsfw
  inside the 4xx branch; batch catches the typed exception and marks
  the result with error_type.
- core/class-image-prompt-builder.php — new public build_fallback_prompt
  shim so the retry layer can reach the rule-based fallback without
  going through the LLM path.
- New core/class-image-nsfw-retry.php — single-shot retry helper.
- core/class-image-pipeline.php — invokes the retry between batch
  completion and per-slot processing.
- admin/class-settings-fields-extended.php — new toggle
  `prautoblogger_image_nsfw_retry` (default on) in the Images section.

Tests:

- CloudflareImageProviderTest — 3030 response throws NSFW exception;
  batch tags the per-slot result with `error_type: nsfw_blocked`.
- ImagePipelineTest — retry succeeds when provider returns a good
  result on the second call; retry is skipped when the setting is off.

Cost impact: worst-case an extra Cloudflare call per NSFW-blocked slot
(~$0.000834). Current incidence ~2% of generations; ~+$0.00002 per
generation amortized. Immaterial.

Uninstall: new option covered by the existing prautoblogger_% LIKE
sweep in uninstall.php.

Part of the v0.8.0 image bundle (issues from thread image-mime-bug).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(images): expose rewriter system prompt as admin setting

CEO feedback: every generated image is anchoring on the cartoon
peptide-with-arms-and-legs character because the hardcoded rewriter
system prompt both lists "anthropomorphized molecules" first in its
primer and ships a few-shot example that literally shows one. The ask
is to give CEO the lever without a code change.

Adds `prautoblogger_image_prompt_instructions` (textarea) in the
Images section. Default value is the existing REWRITER_SYSTEM_PROMPT
constant verbatim, so behaviour is unchanged on upgrade until someone
edits it. `Image_Prompt_Builder::rewrite_via_llm()` reads the option
at call time via a new `resolve_system_prompt()` helper and falls back
to the constant if the option is blank or whitespace-only, so a saved
empty value cannot brick image generation.

Const visibility flipped from `private` to `public` so the settings
field can reference it directly as the field default.

Tests:

- `test_rewrite_uses_setting_when_present` — option set, assert the
  LLM's system message equals the option content.
- `test_rewrite_falls_back_to_default_when_setting_empty` — option
  whitespace, assert the LLM's system message equals the constant.

Uninstall: new option covered by the existing prautoblogger_% LIKE
sweep in uninstall.php.

Cost impact: none by default. Future CEO edits will change prompt
length; current default is ~180 tokens, budget impact linear from
there.

Part of the v0.8.0 image bundle (thread image-mime-bug, message 03).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(images): collapse Image Provider + Image Model into a single dropdown

Until now, Image Provider and Image Model were two independent selects
in the admin UI. That let the CEO set Model=flux-1-schnell while
Provider was still openrouter — OpenRouter has no such model so every
image gen on posts 650/657 on 2026-04-20 returned HTTP 400, and the
article published without a featured image.

v0.8.0 removes the Image Provider field from the admin UI entirely.
The provider is now derived from the model's entry in the registry
every time the model is saved. A new
PRAutoBlogger_Image_Model_Registry::provider_for() helper centralises
the mapping, so admin save, the on-upgrade migration, and future
callers all read from one place.

On save, PRAutoBlogger_Admin_Page::sanitize_image_model() validates
the submitted slug against the registry:
- Known slug: persist it, and update `prautoblogger_image_provider` to
  the paired provider as a side-effect.
- Unknown slug: add_settings_error with a clear message, and return
  the previously-saved value so the site stays on a working model.

A one-shot v0.8.0 migration in
PRAutoBlogger::on_check_db_version() derives the provider from the
currently-saved model on first admin_init after the plugin is updated.
This auto-heals sites that were running mismatched pairs before the
upgrade, without requiring the admin to re-open settings.

`prautoblogger_image_provider` option key is preserved — all existing
reads in the pipeline, cost tracker, and validators continue to work
unchanged.

Tests (Services/ImageModelRegistryTest):

- Every registry entry has an id and a provider in {openrouter, cloudflare}.
- provider_for() returns the paired provider for a known slug.
- provider_for() returns '' for unknown/empty slugs.

Cost impact: none.

Migration idempotent via the `prautoblogger_migrated_image_provider_v080`
flag. Uninstall sweeps it along with every other plugin option.

Part of the v0.8.0 image bundle (thread image-mime-bug, Issue 1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(v0.8.0): version bump + CHANGELOG + ARCHITECTURE.md for image bundle

Bump plugin header and PRAUTOBLOGGER_VERSION 0.7.3 → 0.8.0. Minor bump
because Commit D removes the Image Provider dropdown from admin (a
UX-visible change), not a patch.

CHANGELOG:
- Changed: collapsed Image Provider + Model dropdowns; provider derived.
- Added: Image Prompt Instructions setting; Retry NSFW-Blocked Images
  setting.
- Fixed: silent per-image failures in the event log.

ARCHITECTURE.md:
- Stage 6b (Image Pipeline) callout now describes provider derivation
  and the NSFW-retry fallback path.
- wp_options table lists the four new/changed keys
  (`prautoblogger_image_provider` as derived, new
  `prautoblogger_image_prompt_instructions`,
  `prautoblogger_image_nsfw_retry`, and the one-shot migration flag
  `prautoblogger_migrated_image_provider_v080`).

DB schema unchanged — no PRAUTOBLOGGER_DB_VERSION bump needed.

Final commit of the v0.8.0 image bundle (thread image-mime-bug).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: peptiderepo <peptiderepo@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 02:02:20 +07:00

42 KiB
Executable File
Raw Blame History

PRAutoBlogger — Architecture

Cross-app context: decisions that affect multiple plugins (Cloudflare AI Gateway routing, OpenRouter account sharing, the interface pattern, image-generation stack, social distributor choice) are recorded in Peptide Repo CTO/docs/engineering/decisions/. The incident runbook for cross-app failure modes is at Peptide Repo CTO/docs/engineering/INCIDENT-RUNBOOK.md. Engineer PRAutoBlogger should read both before making decisions that cross plugin boundaries.

PRAutoBlogger is a WordPress plugin that monitors social media (starting with Reddit) for recurring questions, complaints, and comparisons in a configured niche, uses LLM agents (via OpenRouter) to generate high-quality blog articles from those insights, runs them through a chief editor agent for quality review, and auto-publishes them on a configurable daily schedule. All collected data and generation metrics are stored for a self-improvement feedback loop.


Data Flow

┌─────────────────────────────────────────────────────────────────────────┐
│                    WP-Cron / Action Scheduler                          │
│                    (Daily trigger, configurable)                        │
└────────────┬────────────────────────────────────────────────────────────┘
             │
             ▼
┌─────────────────────────┐
│  1. Source Collector     │  Pulls data from enabled sources:
│  (Reddit + LLM Research)│  - Reddit RSS/Atom (primary) / .json (fallback)
│                         │  - LLM Deep Research (reasoning model)
│                         │  Stores raw data in `ab_source_data` table
└────────────┬────────────┘
             │
             ▼
┌─────────────────────────┐
│  2. Content Analyzer    │  LLM call (cheap model) to detect:
│  (Analysis Agent)       │  - Recurring questions
│                         │  - Complaints / pain points
│                         │  - Product comparisons
│                         │  Stores analysis in `ab_analysis_results` table
│                         │  Outputs ranked article ideas
└────────────┬────────────┘
             │
             ▼
┌─────────────────────────┐
│  3. Idea Scorer &       │  Deduplicates against existing posts
│     Deduplicator        │  Scores ideas by relevance, frequency, freshness
│                         │  Picks top N ideas (N = daily article target)
└────────────┬────────────┘
             │
             ▼
┌─────────────────────────┐
│  4. Writer Agent        │  Configurable pipeline:
│  (Content Generator)    │  Single-pass: one LLM call → complete article
│                         │  Multi-step: outline → draft → polish
│                         │  Uses quality model via OpenRouter
└────────────┬────────────┘
             │
             ▼
┌─────────────────────────┐
│  5. Chief Editor Agent  │  LLM-powered editorial review:
│                         │  - Checks quality, accuracy, tone
│                         │  - Checks SEO (title, headings, keyword density)
│                         │  - Can request rewrites or approve
│                         │  - Approved → publish; Rejected → flag for human
└────────────┬────────────┘
             │
             ▼
┌─────────────────────────┐
│  6. Publisher           │  Creates/updates WordPress post
│                         │  Sets categories, tags, featured image
│                         │  Stores generation metadata in post_meta
│                         │  Logs cost/tokens in `prab_generation_log` table
└────────────┬────────────┘
             │
             ▼
┌─────────────────────────┐
│  6b. Image Pipeline     │  Generates two A/B images in parallel:
│  (optional, if enabled) │  - Image A: article-driven (featured)
│                         │  - Image B: source-driven (post meta)
│                         │  Provider derived from the saved Image Model
│                         │  via Image_Model_Registry::provider_for().
│                         │  NSFW-blocked slots (CF error code 3030) are
│                         │  retried once with a rule-based fallback
│                         │  prompt via Image_NSFW_Retry; a second block
│                         │  logs WARNING and publishes without that image.
│                         │  Sideloads to media library, logs costs.
└────────────┬────────────┘
             │
             ▼
┌─────────────────────────┐
│  7. Metrics Collector   │  Tracks post-publish performance:
│  (Separate cron job)    │  - WordPress native (views, comments)
│                         │  - GA4 (pageviews, bounce rate, time on page)
│                         │  - Composite "content score"
│                         │  Feeds back into Analysis step for self-improvement
└─────────────────────────┘

File Tree

prautoblogger/
├── prautoblogger.php                  # Plugin bootstrap — minimal, only hook registration
├── uninstall.php                      # Clean removal of ALL plugin data
├── readme.txt                         # WordPress.org standard readme
├── composer.json                      # Autoloading (PSR-4) and dependencies
├── ARCHITECTURE.md                    # This file
├── CONVENTIONS.md                     # Naming patterns, extension guides
├── CHANGELOG.md                       # Semantic versioning changelog
│
├── assets/
│   ├── css/
│   │   ├── admin.css                  # Admin page styles (wp-admin conventions)
│   │   └── posts-widget.css           # Frontend posts widget styles (uses theme CSS vars)
│   └── js/
│       ├── admin.js                   # Admin page interactivity (vanilla JS / Alpine.js)
│       └── posts-widget.js            # React component for frontend post cards (wp.element)
│
├── includes/
│   ├── class-prautoblogger.php        # Main orchestrator — registers all hooks, delegates execution
│   ├── class-executor.php             # Cron handlers, generation AJAX (start + status), model registry
│   ├── class-ajax-handlers.php        # Non-generation AJAX: images, models, test connections
│   ├── class-generation-lock.php      # DB-level atomic mutex for single-writer generation
│   ├── class-activator.php            # Activation: create DB tables, set defaults, schedule cron
│   ├── class-deactivator.php          # Deactivation: clear cron, cleanup transients
│   ├── class-autoloader.php           # PSR-4-style autoloader for plugin classes
│   │
│   ├── admin/
│   │   ├── class-admin-page.php       # Main settings page (tabbed SaaS-style UI)
│   │   ├── class-settings-fields.php  # Declarative settings: sections + core fields (API/Models/Content/Sources)
│   │   ├── class-settings-fields-extended.php # Operational fields: schedule, publishing, display, analytics, images
│   │   ├── class-image-model-registry.php # Static list of image generation models for the model picker
│   │   ├── class-admin-notices.php    # Onboarding notices, error alerts, budget warnings
│   │   ├── class-dashboard-widget.php # WP Dashboard widget showing generation status
│   │   ├── class-post-metabox.php     # Metabox on posts showing generation metadata
│   │   ├── class-metrics-page.php     # Admin page for cost dashboard and content scores
│   │   ├── class-ideas-browser.php    # Browse all analysis results (article ideas)
│   │   ├── class-review-queue.php     # Approve/edit/reject queue for generated drafts
│   │   └── class-log-viewer.php       # Activity log viewer with level filtering
│   │
│   ├── core/
│   │   ├── class-scheduler.php        # WP-Cron / Action Scheduler job management
│   │   ├── class-source-collector.php # Orchestrates data collection from all sources
│   │   ├── class-content-analyzer.php # LLM-powered analysis of collected social data
│   │   ├── class-analysis-prompts.php # System/user prompt builders + performance context for analyzer
│   │   ├── class-idea-scorer.php      # Ranks and deduplicates article ideas (delegates to Semantic_Dedup)
│   │   ├── class-semantic-dedup.php   # Embedding-based cosine similarity dedup with keyword fallback
│   │   ├── class-content-generator.php# Writer agent — manages the generation pipeline
│   │   ├── class-content-prompts.php  # Builds all LLM prompts for content generation (system, stage, linking rules)
│   │   ├── class-peptide-linker.php   # Deterministic post-processor: hyperlinks peptide mentions to /peptides/ pages
│   │   ├── class-chief-editor.php     # Editor agent — LLM-powered editorial review
│   │   ├── class-publisher.php        # Creates WordPress posts from approved content
│   │   ├── 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-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)
│   │   ├── class-article-worker.php   # Single-article generation (content + edit + publish)
│   │   ├── class-pipeline-runner.php  # Orchestrates pipeline; chains per-article cron jobs
│   │   ├── class-ga4-client.php       # Google Analytics 4 API client (OAuth + Data API)
│   │   └── class-metrics-collector.php# Collects post performance data (WP + GA4)
│   │
│   ├── providers/
│   │   ├── interface-llm-provider.php    # Contract for any LLM provider
│   │   ├── class-open-router-provider.php # OpenRouter API implementation (chat completions)
│   │   ├── class-open-router-embedding-provider.php # Text embeddings for semantic dedup
│   │   ├── class-open-router-pricing.php  # Model pricing lookup and cost estimation
│   │   ├── interface-source-provider.php # Contract for any social media source
│   │   ├── class-reddit-json-client.php  # Reddit HTTP client — RSS (primary) + .json (fallback)
│   │   ├── class-llm-research-provider.php # LLM deep research source (reasoning models)
│   │   ├── class-reddit-provider.php     # Reddit data collection orchestrator (RSS primary)
│   │   ├── interface-image-provider.php  # Contract for any image generation provider (incl. batch)
│   │   ├── class-open-router-image-provider.php  # OpenRouter image gen (single + batch dispatch)
│   │   ├── class-open-router-image-batch.php     # Parallel curl_multi execution for batch image gen
│   │   ├── class-open-router-image-support.php   # API key, response parsing, retry/backoff helpers
│   │   ├── class-open-router-image-pricing.php   # Model resolution + per-image cost estimation
│   │   ├── class-open-router-config.php          # API base URL (direct vs AI Gateway)
│   │   ├── class-open-router-request-builder.php # Header building + Hostinger cURL auth workaround
│   │   ├── class-cloudflare-image-provider.php   # Cloudflare Workers AI (sequential batch fallback)
│   │   ├── class-cloudflare-image-pricing.php    # Model alias + per-MP cost estimation
│   │   ├── class-cloudflare-image-validator.php  # Non-destructive credential + connectivity check
│   │   ├── class-cloudflare-image-support.php    # Token, account, URL, and log helpers for CF provider
│   │   └── (new providers go here — see CONVENTIONS.md)
│   │
│   ├── services/
│   │   ├── interface-model-registry.php           # Contract for provider-specific model registries
│   │   ├── class-open-router-model-registry.php    # Fetches, caches, queries the OpenRouter model list
│   │   ├── class-open-router-model-normalizer.php  # Maps raw OpenRouter data → standardized record shape
│   │   └── (Phase 3: class-cloudflare-workers-ai-model-registry.php)
│   │
│   ├── frontend/
│   │   ├── class-article-typography.php # Inline CSS for font, size, table borders on generated posts
│   │   └── class-posts-widget.php     # [prautoblogger_posts] shortcode + REST endpoint
│   │
│   └── models/
│       ├── class-source-data.php      # Value object: raw social media post/comment
│       ├── class-analysis-result.php  # Value object: analyzed topic with scores
│       ├── class-article-idea.php     # Value object: scored article idea
│       ├── class-content-request.php  # Value object: generation request with params
│       ├── class-editorial-review.php # Value object: editor verdict, scores, revised content
│       ├── class-generation-log.php   # Value object: log entry for a generation run
│       └── class-content-score.php    # Value object: composite performance score
│
├── templates/
│   └── admin/
│       ├── settings-page.php          # Settings page template (tabbed sidebar layout)
│       ├── metrics-page.php           # Metrics/cost dashboard template
│       ├── review-queue.php           # Review queue table template
│       ├── log-viewer.php             # Activity log viewer template
│       └── metabox-generation-info.php# Post metabox template
│
├── languages/
│   └── prautoblogger.pot              # i18n translation template
│
└── tests/
    ├── unit/                          # PHPUnit tests (mocked dependencies)
    └── integration/                   # WordPress integration tests

Frontend Widget Data Flow

┌──────────────────────────────┐
│  WordPress Page/Post         │  Contains [prautoblogger_posts] shortcode
│  (e.g. Home page)            │
└────────────┬─────────────────┘
             │  Shortcode renders:
             │  1. <div id="prab-posts-root"></div>
             │  2. Enqueues posts-widget.js + posts-widget.css
             │  3. Passes config via wp_localize_script
             ▼
┌──────────────────────────────┐
│  React Component (wp.element)│  Mounts into #prab-posts-root
│  posts-widget.js             │  Shows loading skeleton on mount
└────────────┬─────────────────┘
             │  Fetches asynchronously
             ▼
┌──────────────────────────────┐
│  REST API Endpoint           │  GET /wp-json/prautoblogger/v1/posts
│  class-posts-widget.php      │  Queries published posts with
│                              │  _prautoblogger_generated = '1' meta
│                              │  Returns slim JSON (id, title, excerpt,
│                              │  url, date, category, image, word_count)
│                              │  5-minute Cache-Control header
└──────────────────────────────┘

Shortcode Attributes

Attribute Default Description
count 6 Posts to display (max 12)
category (all) Filter by category slug
title "Latest Research & Insights" Widget heading
subtitle "Evidence-based articles..." Widget subheading

CSS Integration

The widget CSS (posts-widget.css) uses the Peptide Starter theme's CSS custom properties (--color-*, --spacing-*, --text-*, --radius-*, --transition-*) with hardcoded fallbacks. This means:

  • On sites using Peptide Starter: widget automatically adapts to light/dark mode via data-theme.
  • On other themes: fallback values render a sensible dark-themed design.

Background Generation Flow (v0.3.0)

The "Generate Now" button runs the pipeline in a background WP-Cron process to avoid Hostinger's 120-second LiteSpeed connection timeout. The frontend polls for status.

┌──────────────────────────────┐
│  Admin clicks "Generate Now" │  admin.js sends AJAX to on_ajax_generate_now()
└────────────┬─────────────────┘
             │  Handler returns immediately (< 1 second):
             │  1. Writes "running" transient with started timestamp
             │  2. Schedules one-shot cron: prautoblogger_manual_generation
             │  3. Fires non-blocking loopback to wp-cron.php
             │  4. Returns JSON success to browser
             ▼
┌──────────────────────────────┐
│  admin.js starts polling     │  setInterval every 5 seconds
│  on_ajax_generation_status() │  Shows stage text + spinner
└────────────┬─────────────────┘
             │
             ▼ (separate PHP process)
┌──────────────────────────────┐
│  on_manual_generation()      │  WP-Cron fires this in a new request
│  (class-executor.php)        │  1. ignore_user_abort(true) — survives HTTP kill
│                              │  2. set_time_limit(300)
│                              │  3. Acquires PRAutoBlogger_Generation_Lock
│                              │  4. Runs PRAutoBlogger_Pipeline_Runner::run()
│                              │     (pipeline broadcasts stage updates to transient)
│                              │  5. Writes final result to transient
│                              │  6. Releases lock
└────────────┬─────────────────┘
             │
             ▼
┌──────────────────────────────┐
│  Poller detects completion   │  Reads transient: status = 'complete' or 'error'
│  Shows result message        │  Resets button to idle state
└──────────────────────────────┘

Fallback detection: If Hostinger kills the PHP process before it updates the transient (despite ignore_user_abort), the status endpoint detects the orphan after 180 seconds by checking for recently-created posts with _prautoblogger_run_id meta. It also releases any stale generation lock. After 600 seconds (STATUS_TTL), it gives up unconditionally and reports a timeout.


Database Schema

Custom Tables

All tables use $wpdb->prefix + prautoblogger_ prefix.

ab_source_data — Raw social media data

Column Type Description
id BIGINT UNSIGNED Auto-increment PK
source_type VARCHAR(50) 'reddit' (extensible for future providers)
source_id VARCHAR(255) Platform-specific unique ID (post/comment ID)
subreddit VARCHAR(255) Subreddit or channel name (nullable)
title TEXT Post/video title
content LONGTEXT Post body / comment text / transcript
author VARCHAR(255) Original author username
score INT Upvotes/likes/engagement metric
comment_count INT Number of comments/replies
permalink VARCHAR(500) URL to original content
collected_at DATETIME When we collected this data
metadata_json LONGTEXT Platform-specific extra data (JSON)
  • INDEX: source_type, collected_at
  • UNIQUE INDEX: source_type, source_id

ab_analysis_results — Analyzed topics and patterns

Column Type Description
id BIGINT UNSIGNED Auto-increment PK
analysis_type VARCHAR(50) 'question', 'complaint', 'comparison'
topic VARCHAR(500) Detected topic/theme
summary TEXT LLM-generated summary of the pattern
frequency INT How many source posts mention this
relevance_score FLOAT LLM-assigned relevance score (0-1)
source_ids_json LONGTEXT JSON array of ab_source_data.id references
analyzed_at DATETIME When analysis was performed
metadata_json LONGTEXT Extra analysis data (JSON)
  • INDEX: analysis_type, relevance_score
  • INDEX: analyzed_at

prab_generation_log — API cost and generation tracking

Column Type Description
id BIGINT UNSIGNED Auto-increment PK
post_id BIGINT UNSIGNED WordPress post ID (nullable, set after publish)
run_id VARCHAR(36) UUID linking all entries from one pipeline run
stage VARCHAR(50) 'analysis', 'outline', 'draft', 'edit', 'review'
provider VARCHAR(50) 'openrouter'
model VARCHAR(100) Model identifier used
prompt_tokens INT Input tokens consumed
completion_tokens INT Output tokens generated
estimated_cost DECIMAL(10,6) Estimated USD cost
request_json LONGTEXT Full request payload (for debugging)
response_status VARCHAR(20) 'success', 'error', 'timeout'
error_message TEXT Error details if failed (nullable)
created_at DATETIME When the API call was made
  • INDEX: post_id
  • INDEX: run_id
  • INDEX: created_at
  • INDEX: stage

prab_event_log — Structured application logging

Column Type Description
id BIGINT UNSIGNED Auto-increment PK
level VARCHAR(10) 'error', 'warning', 'info', 'debug'
context VARCHAR(100) Origin: 'pipeline', 'reddit', 'ga4', etc.
message TEXT Human-readable log message
meta_json LONGTEXT Optional structured data (nullable)
created_at DATETIME When the event occurred
  • INDEX: level, created_at
  • INDEX: created_at

ab_content_scores — Post performance metrics

Column Type Description
id BIGINT UNSIGNED Auto-increment PK
post_id BIGINT UNSIGNED WordPress post ID
pageviews INT Total pageviews (WP native or GA4)
avg_time_on_page FLOAT Average seconds on page (GA4)
bounce_rate FLOAT Bounce rate percentage (GA4)
comment_count INT WordPress comment count
composite_score FLOAT LLM-computed content quality score (0-100)
score_factors_json LONGTEXT JSON breakdown of what contributed to score
measured_at DATETIME When metrics were collected
  • INDEX: post_id
  • INDEX: measured_at
  • INDEX: composite_score

WordPress Options (wp_options)

All prefixed with prautoblogger_:

Option Key Description
prautoblogger_openrouter_api_key Encrypted OpenRouter API key
prautoblogger_ai_gateway_base_url Optional Cloudflare AI Gateway URL (proxies OpenRouter); empty = direct
prautoblogger_ai_gateway_cache_ttl Seconds Cloudflare may cache identical LLM responses (0 = off)
prautoblogger_ga4_property_id Google Analytics 4 property ID
prautoblogger_ga4_credentials_json Encrypted GA4 service account credentials
prautoblogger_analysis_model OpenRouter model for analysis (default: cheap)
prautoblogger_writing_model OpenRouter model for writing (default: quality)
prautoblogger_editor_model OpenRouter model for chief editor (default: quality)
prautoblogger_daily_article_target Number of articles per day (1-10, default: 1)
prautoblogger_writing_pipeline 'single_pass' or 'multi_step' (default: multi_step)
prautoblogger_niche_description Text description of the site's niche
prautoblogger_target_subreddits JSON array of subreddits to monitor
prautoblogger_monthly_budget_usd Monthly API spend limit in USD
prautoblogger_tone Content tone (informational, conversational, etc.)
prautoblogger_min_word_count Minimum article word count (default: 800)
prautoblogger_max_word_count Maximum article word count (default: 2000)
prautoblogger_topic_exclusions JSON array of topics to never write about
prautoblogger_enabled_sources JSON array of active source types
prautoblogger_auto_publish Toggle: auto-publish approved posts (default: '0')
prautoblogger_default_author WP user ID for generated post authorship
prautoblogger_default_category WP category ID fallback for generated posts
prautoblogger_log_level Logging threshold: error/warning/info/debug
prautoblogger_db_version Schema version for migrations
prautoblogger_schedule_time Daily generation time (HH:MM, default: '03:00')
prautoblogger_cloudflare_ai_token Encrypted Cloudflare Workers AI API token
prautoblogger_cloudflare_account_id Cloudflare account UUID (plaintext — identifier, not secret)
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_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_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

Post Meta

Stored on every PRAutoBlogger-generated post:

Meta Key Description
_prautoblogger_generated Boolean flag — '1' if generated by plugin
_prautoblogger_analysis_id FK to ab_analysis_results.id
_prautoblogger_source_ids JSON array of source data IDs used
_prautoblogger_model_used Model that generated the content
_prautoblogger_pipeline_mode 'single_pass' or 'multi_step'
_prautoblogger_total_cost Total USD cost for this post
_prautoblogger_total_tokens Total tokens consumed for this post
_prautoblogger_editor_verdict 'approved', 'revised', 'rejected'
_prautoblogger_editor_notes Chief editor's review notes
_prautoblogger_generated_at ISO 8601 timestamp of generation
_prautoblogger_research_sources JSON array of source URLs used

External API Integrations

Service Purpose Auth Rate Limit Code
OpenRouter All LLM calls (analysis, writing, editing) API key (encrypted in wp_options) Per-model providers/class-open-router-provider.php, providers/class-open-router-pricing.php
Cloudflare AI Gateway (optional) Transparent proxy in front of OpenRouter — adds response caching, cost logging, rate limiting, provider fallback Same OpenRouter key; gateway URL in prautoblogger_ai_gateway_base_url Gateway-side quotas Same provider file; activated when gateway URL option is non-empty
Reddit RSS Primary Reddit data source — Atom feeds for subreddit hot posts None (unauthenticated) No known rate limit; reliable from datacenter IPs providers/class-reddit-json-client.php
Reddit .json Fallback for posts + only source for comments None (unauthenticated) ~10 req/min (datacenter IPs often blocked) providers/class-reddit-json-client.php
Google Analytics 4 Post performance metrics OAuth2 service account Standard GA4 limits core/class-ga4-client.php, core/class-metrics-collector.php
Cloudflare Workers AI Image generation (FLUX.1 schnell / dev) for article hero, thumbnail, and IG placements API token (encrypted in wp_options) + account ID Workers AI per-account quotas providers/class-cloudflare-image-provider.php, providers/class-cloudflare-image-pricing.php, providers/class-cloudflare-image-validator.php

Key Decisions

#1: OpenRouter over direct provider APIs

Gives access to many models through one API, simplifies provider management, and lets the user switch models without code changes. Trade-off: slight latency overhead and dependency on OpenRouter's availability.

#2: Reddit as first social source (RSS primary, .json fallback)

Reddit has rich discussion data ideal for identifying recurring questions and pain points. Reddit RSS/Atom feeds are the primary data source — they work reliably from datacenter IPs (Hostinger) where .json endpoints return 403. The .json endpoints serve as a fallback for posts and are the only source for comment fetching (RSS doesn't include comments). Trade-off: RSS lacks engagement metrics (score, comment count), but post titles and content are sufficient for topic analysis.

#3: Chief editor agent instead of human review queue

The user wants full automation — a second LLM pass reviews quality, SEO, and accuracy before publishing. Posts that fail editorial review are flagged for human intervention rather than published. Trade-off: doubles the LLM cost of the "review" step, but catches quality issues.

#4: Configurable pipeline (single-pass vs. multi-step)

Some users want cheap/fast, others want high quality. Making this configurable avoids forcing one approach. Trade-off: more code paths to maintain.

#5: Composite content score using LLM

Rather than just tracking raw metrics, we periodically have the LLM evaluate what made high-performing posts succeed and low-performing posts fail. This feeds back into the analysis and generation prompts. Trade-off: additional API cost for scoring, but enables the self-improvement loop.

#6: Source provider interface for future platforms

Reddit is the only implemented source, but the interface pattern means adding a new source (YouTube, TikTok, etc.) is one class implementation plus a settings checkbox. We removed the old stub provider files (TikTok, Instagram, YouTube) because dead code confuses AI agents and humans alike — the interface is the contract, not empty classes. Trade-off: slight over-engineering for day one, but pays off immediately at source #2.

#7: Custom tables for high-volume data

Source data, analysis results, and generation logs are high-write, time-series data. WordPress post_meta and options are wrong for this. Custom tables with proper indexes. Trade-off: more complex activation/uninstall, but correct.

#8: All API keys encrypted at rest

Using wp_salt() as encryption key with OpenSSL. Not bulletproof (salt is in wp-config.php) but significantly better than plaintext in wp_options. Trade-off: adds complexity to option get/set, but necessary for security.

#9: Structured logger instead of raw error_log()

All application logging flows through PRAutoBlogger_Logger singleton, which writes to the prab_event_log table and forwards errors/warnings to PHP's error_log(). This gives users an in-admin Activity Log page with filtering by level, search, and pagination — much more accessible than server logs. Trade-off: one extra DB write per log entry, but the table is prunable and indexed.

#10: Database-level atomic mutex for generation lock

Uses INSERT IGNORE on wp_options (which has a UNIQUE index on option_name) instead of transient-based locking. This eliminates the TOCTOU race condition where two concurrent cron runs could both read the transient as "not locked" before either sets it. Expired locks older than 1 hour are cleaned up first to prevent permanent deadlock.

#11: wp.element for frontend React (no build step)

The posts widget uses WordPress-bundled React (wp.element) with raw createElement calls instead of JSX. This avoids requiring a Node.js build pipeline for what is a simple card grid. Trade-off: more verbose component code, but zero tooling dependencies for the plugin consumer.

#12: REST API for frontend data fetching

The widget fetches posts asynchronously from a dedicated REST endpoint rather than rendering server-side. This keeps initial page load fast and allows the endpoint to be cached independently (5-minute Cache-Control). The permission callback uses a filter (prautoblogger_rest_posts_public) so sites can restrict access if needed.

#13: Run-ID based log linking

Each pipeline execution generates a UUID (run_id) that tags every prab_generation_log entry. When a post is published, link_generation_logs() uses UPDATE WHERE run_id = X to associate entries with the post_id. This is more reliable than the previous timestamp-window approach, especially in batch runs where multiple posts are generated in quick succession.

#14: Reddit RSS replaces PullPush.io (and earlier Reddit OAuth)

Reddit rejected our OAuth API application (April 2026). We initially switched to PullPush.io, but its index was frequently stale or unavailable. Reddit's RSS/Atom feeds (/r/{sub}/hot.rss) proved the most reliable option — they work from datacenter IPs where .json gets 403, require no auth, and have no apparent rate limit. The .json endpoints are kept as a fallback for posts and as the sole source for comment data. Each collected item's metadata includes a data_source field (reddit_rss or reddit_json) for auditability.

#18: OpenRouter model registry — daily refresh, WP option + transient cache, zero-coupling

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.

#16: Image generation via Cloudflare Workers AI (FLUX.1), direct (not via AI Gateway)

The image-generation layer (started 2026-04-15, tracked in convo/prautoblogger/threads/2026-04-image-pipeline/) uses FLUX.1 [schnell] on Cloudflare Workers AI as its default model, chosen for its ~$0.0011/MP cost and 2–3 sec latency. FLUX.1 [dev] is a ~4× cost upgrade exposed as a dropdown for specific posts that warrant it. Calls go directly to https://api.cloudflare.com/client/v4/accounts/{id}/ai/run/@cf/black-forest-labs/..., not through the Cloudflare AI Gateway that we use for OpenRouter — the gateway route to Workers AI currently 403s (pre-existing open issue). The provider lives behind PRAutoBlogger_Image_Provider_Interface, so the decision is trivially reversible: switching to a different image API (DALL-E, Replicate, a future AI Gateway route) is a new class implementation plus a one-line swap in the pipeline wiring. Trade-off: we run two different Cloudflare integration paths (gateway for OpenRouter LLMs, direct for Workers AI images) until the gateway route stabilizes — minor cognitive overhead, zero functional downside.

#15: Optional Cloudflare AI Gateway in front of OpenRouter

We already use Cloudflare for DNS/CDN on peptiderepo.com, so layering AI Gateway in front of OpenRouter is zero marginal infrastructure. It gives us response caching (meaningful for repeated classification/scoring calls), a unified cost/latency dashboard, rate limiting, and provider fallback — all of which we would otherwise have to build ourselves to satisfy the CTO cost-tracking rules. Kept as an opt-in URL setting (prautoblogger_ai_gateway_base_url) so the plugin still works unchanged out of the box and can be bypassed instantly if the gateway misbehaves. The gateway is a transparent OpenRouter-compatible proxy; no new provider class is needed, and the response parsing path (usage, choices[0].message.content) is unchanged.


Cross-System LLM Budget Coordination

PRAutoBlogger and Peptide News both call OpenRouter and may share a single API key / billing account. Their combined spend should be considered when setting per-plugin budgets.

Plugin Default Models Typical Daily Spend Budget Control
PRAutoBlogger Gemini 2.5 Flash Lite (analysis + editing), Claude Sonnet 4 (writing) $0.05–$0.30 depending on article count Hard-stop monthly budget in plugin settings
Peptide News Google Gemini 2.0 Flash (keywords + summaries) $0.01–$0.05 No hard budget yet (planned)

Important: If your OpenRouter account has a global spending limit, set each plugin's budget to less than half the total. PRAutoBlogger will hard-stop when its budget is exhausted, but Peptide News currently has no budget enforcement — a spike in news fetches could consume shared quota.

Future improvement: A shared wp_options key (e.g., ecosystem_monthly_llm_budget) that both plugins read, with each plugin reserving its allocation on startup. This requires coordination at the ecosystem level and is tracked as a medium-term goal.