Commit 2 (final scope) of the in-plugin editorial image pipeline (ARCHITECTURE.md #21). A deterministic local composer now runs between provider bytes and sideload: Image A yields a corner-marked featured (1200x632) + branded OG (1200x630, teal band, logo, baked caption in Poppins SemiBold) + square card (1080x1080, cream panel, Poppins Bold, teal footer with the reverse lockup); Image B gets the corner mark only. - Capability ladder: imagick (full compositing) -> wp_get_image_editor resize-only (unbranded, WARNING once) -> pass-through (WARNING once); probe cached in prautoblogger_image_compose_capability, fingerprinted by PHP version + imagick/gd presence so host flips auto-invalidate. compose() never throws; composition can never fail a publish. - Variants sideloaded with a role filename suffix; attachment meta _prautoblogger_image_role + _prautoblogger_base_attachment_id; post meta _prautoblogger_og_image_id (frozen for the rebuilt SEO stage) + _prautoblogger_square_image_id. No cost-tracker rows ($0 local stage; duration logged on the image_composer channel). - New Images settings: compose_enabled ('1'), compose_variants ('og,square'), featured_mark_enabled ('1') — read at point of use. Geometry ships as Layout defaults behind the prautoblogger_image_compose_layout filter. - Vendored brand rasters (assets/brand) + bundled OFL Poppins faces (assets/fonts) for per-environment byte-stable output (stripImage + PNG date/time chunks excluded). Validated on Hostinger PHP 8.3.30 / ImageMagick 7.1.1-43: php -l clean on all touched files, double render byte-identical (sha256), exact variant geometry. - Fix: featured/Image B alt text is the editorial caption (was the model name); model still persists in _prautoblogger_image_model. - Image_Attacher extracted from Image_Pipeline (300-line compliance); sideloader gains an optional filename suffix param (back-compat ''). - Version 0.16.0 -> 0.17.0 + CHANGELOG + ARCHITECTURE.md #21 + CONVENTIONS.md composer guide in this commit. Unit tests: layout clamp boundaries, capability ladder, cache invalidation, attacher meta-linking, pipeline integration, imagick determinism (gated). Agent-Session: engineer-prautoblogger-image-composer-20260611
21 KiB
PRAutoBlogger — Conventions
This document codifies the naming patterns, coding conventions, and step-by-step guides for extending PRAutoBlogger. Update this file whenever a new pattern is introduced.
Naming Conventions
PHP Classes
- Format:
PRAutoBlogger_{PascalCase}(e.g.,PRAutoBlogger_Content_Generator) - File naming:
class-{kebab-case}.php(e.g.,class-content-generator.php) - Interfaces:
interface-{kebab-case}.phpwithPRAutoBlogger_{Name}_Interfaceclass name
Hooks (Actions & Filters)
- Prefix: All hooks start with
prautoblogger_ - Actions:
prautoblogger_{event}(e.g.,prautoblogger_before_content_generation) - Filters:
prautoblogger_filter_{what}(e.g.,prautoblogger_filter_article_idea_score) - Hook callbacks registered in:
class-prautoblogger.php(central loader), never in the classes themselves
Database
- Table prefix:
{$wpdb->prefix}prautoblogger_(e.g.,wp_prautoblogger_source_data) - Option prefix:
prautoblogger_(e.g.,prautoblogger_openrouter_api_key) - Post meta prefix:
_prautoblogger_(underscore prefix hides from custom fields UI) - Transient prefix:
prautoblogger_(e.g.,prautoblogger_reddit_token)
Constants
- Format:
PRAUTOBLOGGER_{UPPER_SNAKE}(e.g.,PRAUTOBLOGGER_MAX_RETRIES) - Defined in:
prautoblogger.php(bootstrap file) for global constants
Hook Callback Methods
- Action callbacks: Prefixed with
on_(e.g.,on_daily_generation_triggered) - Filter callbacks: Prefixed with
filter_(e.g.,filter_content_quality)
Error Handling & Logging
Structured Logger
All logging uses PRAutoBlogger_Logger (singleton) instead of raw error_log(). The logger writes to the prab_event_log custom table and forwards errors/warnings to PHP's error_log() for server-level monitoring. The verbosity threshold is user-configurable via Settings → Publishing → Log Level.
Never call error_log() directly. Always use the Logger:
$logger = PRAutoBlogger_Logger::instance();
$logger->error( 'API call failed: ' . $error_message, 'openrouter' );
$logger->warning( 'Budget nearing limit.', 'cost-tracker' );
$logger->info( 'Pipeline complete: 3 posts generated.', 'pipeline' );
$logger->debug( 'Token counts: prompt=1200, completion=800', 'openrouter' );
Log levels (ascending verbosity)
| Level | Numeric | Use for |
|---|---|---|
| error | 0 | Failures: API errors, parse failures, DB write failures |
| warning | 1 | Degraded behavior: budget approaching, missing providers, fallback pricing |
| info | 2 | Key events: pipeline start/stop, articles generated, metrics collected |
| debug | 3 | Verbose detail: token counts, timing, skipped posts, intermediate values |
Context strings
The second argument to every log call is a short context tag identifying the origin:
pipeline, scheduler, collector, analyzer, editor, openrouter, reddit, ga4, metrics, cost-tracker, encryption.
What gets shown to the user
- API key issues → admin notice (persistent until resolved)
- Budget exceeded → admin notice + email to site admin
- Generation failures → logged to
prab_generation_logwithresponse_status = 'error' - Generation failures also shown in the admin dashboard widget
- All log entries visible in PRAutoBlogger → Activity Log (filterable by level)
What gets retried
- LLM API calls: exponential backoff, max 3 retries, then fail loudly
- Reddit API calls: exponential backoff, max 3 retries, then skip this collection cycle
- Never retry silently. Every retry is logged. Every final failure is surfaced.
Error severity levels
- Fatal: Plugin cannot function (missing API key, budget exhausted) → admin notice, stop all scheduled jobs
- Retriable: Transient API failure → retry with backoff, log each attempt
- Skippable: Single post generation failed → log, skip this post, continue with others
How To: Add a New LLM Provider
- Create
includes/providers/class-{name}-provider.php - Implement
PRAutoBlogger_LLM_Provider_Interface(seeinterface-llm-provider.php) - Required methods:
send_chat_completion(),get_available_models(),estimate_cost() - Add provider option to the settings page in
admin/class-admin-page.php - Register the provider in
class-prautoblogger.phploader - Update ARCHITECTURE.md external API integrations table
Interface contract:
interface PRAutoBlogger_LLM_Provider_Interface {
public function send_chat_completion(array $messages, string $model, array $options = []): array;
public function get_available_models(): array;
public function estimate_cost(string $model, int $prompt_tokens, int $completion_tokens): float;
public function get_provider_name(): string;
}
How To: Add a New Source Provider (Social Platform)
- Create
includes/providers/class-{platform}-provider.php - Implement
PRAutoBlogger_Source_Provider_Interface(seeinterface-source-provider.php) - Required methods:
collect_data(),get_source_type(),validate_credentials() - Add API credentials fields to the settings page in
admin/class-admin-page.php - Register the provider in
class-prautoblogger.phploader - Add the source type string to the
ab_source_data.source_typeallowed values - Update ARCHITECTURE.md file tree and external API integrations table
Interface contract:
interface PRAutoBlogger_Source_Provider_Interface {
public function collect_data(array $config): array; // Returns PRAutoBlogger_Source_Data[]
public function get_source_type(): string; // e.g., 'reddit', 'tiktok'
public function validate_credentials(): bool;
public function get_rate_limit_status(): array;
}
How To: Add a New Image Provider
- Create
includes/providers/class-{name}-image-provider.php - Implement
PRAutoBlogger_Image_Provider_Interface(seeinterface-image-provider.php) - Required methods:
generate_image(),estimate_cost(),get_provider_name(),validate_credentials_detailed() - Split helpers into companions if you'd exceed the 300-line cap. The Cloudflare provider splits into provider + pricing + validator — mirror that shape.
- Add API credentials fields to
admin/class-settings-fields.phpunder theprautoblogger_imagessection - Register the provider in
class-prautoblogger.phploader once the image pipeline lands (commit 1b of the image workstream) - Update
ARCHITECTURE.mdfile tree, external API integrations table, and the options table
Interface contract:
interface PRAutoBlogger_Image_Provider_Interface {
public function generate_image( string $prompt, int $width, int $height, array $options = [] ): array; // {bytes, mime_type, width, height, model, seed, cost_usd, latency_ms}
public function estimate_cost( int $width, int $height, array $options = [] ): float;
public function get_provider_name(): string; // e.g., 'cloudflare_workers_ai'
public function validate_credentials_detailed(): array; // {status, message, debug?}
}
Retry + cost rules (non-negotiable)
- 5xx / 429 / network errors: retry with exponential backoff up to
PRAUTOBLOGGER_MAX_RETRIES, respectRetry-Afterwhen present. - 4xx other than 429: fail loudly on the first response — never retry a deterministic bad request.
- Validation check must be non-destructive (no image generation).
- Every attempt logs via
PRAutoBlogger_Loggerwith context{provider-slug}-image.
How To: Tune the Image Composer (v0.17.0+)
The deterministic composer (ARCHITECTURE.md #21) renders branded variants between provider bytes and sideload. Three rules when touching it:
- Geometry lives in code, not settings. All sizes/colors/opacities come from
PRAutoBlogger_Image_Composer_Layout::defaults()and can be overridden via theprautoblogger_image_compose_layoutfilter:Promote a value to a settings field only on explicit CEO request.add_filter( 'prautoblogger_image_compose_layout', function ( array $layout ): array { $layout['featured']['mark_opacity'] = 0.40; return $layout; } ); - Keep renderer classes WP-free.
Image_Composer_Imagick,Image_Composer_Canvas, andImage_Composer_Layoutmust not call WordPress functions — they are exercised by a standalone render harness (and the determinism test) without a WP bootstrap. Only the orchestrator (Image_Composer) and the GD rung (Image_Composer_Editor) may touch WP APIs. - Never fatal a publish. Any new render path needs a try/catch that degrades to
pass-through and logs at most one WARNING per run (
warn_once()). Capability checks go through the cached probe (prautoblogger_image_compose_capabilityoption + same-named override filter) — never re-probe per image.
Adding a new variant role: extend Layout::defaults() + the renderer, whitelist the
role in Image_Composer::configured_variants() and in
Image_Attacher::ROLE_POST_META (new _prautoblogger_{role}_image_id post meta keeps
the uninstall prefix-sweep effective), then cover it in the unit ladder tests.
How To: Add a New Admin Setting
- Define the option key in the settings array in
admin/class-admin-page.php→get_settings_fields()method - Format: add an entry to the appropriate section array:
[ 'id' => 'prautoblogger_new_setting', 'label' => __('Setting Label', 'prautoblogger'), 'type' => 'text', // text, number, select, textarea, checkbox, password 'default' => '', 'description' => __('Help text shown below the field.', 'prautoblogger'), 'sanitize' => 'sanitize_text_field', // sanitization callback ] - The settings page renderer handles registration, rendering, and sanitization automatically from this array.
- Access the setting anywhere with:
get_option('prautoblogger_new_setting', $default) - For encrypted settings (API keys): use
PRAutoBlogger_Encryption::encrypt()/decrypt()
How To: Add a New Pipeline Stage
The writing pipeline is an ordered array of stages. Each stage is a class method on Content_Generator.
- Add the stage method to
class-content-generator.php:protected function stage_{name}(PRAutoBlogger_Content_Request $request): string - Register the stage in the
get_pipeline_stages()method for the appropriate mode (single_pass or multi_step) - Each stage receives the accumulated context and returns its output
- Each stage logs its cost via
Cost_Tracker::log_api_call() - If a stage fails after retries, the entire pipeline fails (no partial publishes)
How To: Add a New Frontend Component
Frontend components use wp.element (WordPress-bundled React) — no JSX, no build step.
-
Create the PHP class in
includes/frontend/class-{name}.php:- Register a shortcode via
add_shortcode()in anon_register_shortcode()method - Register a REST endpoint if the component needs async data (in
on_register_rest_route()) - Enqueue JS (
wp-elementdependency) and CSS viawp_enqueue_script/style() - Pass config to JS via
wp_localize_script()
- Register a shortcode via
-
Create JS in
assets/js/{name}.js:- Use
wp.element.createElement(aliased asel) for rendering - Import hooks:
var useState = wp.element.useState; - Read config from
window.{localizedObjectName} - Handle all states: loading, loaded, empty, error
- Mount into the shortcode's
<div id="...">mount point
- Use
-
Create CSS in
assets/css/{name}.css:- Use Peptide Starter theme CSS custom properties with fallbacks:
var(--color-text-primary, #f9fafb) - Prefix all classes with
prab-to avoid collisions - Include responsive breakpoints, focus-visible states, and loading skeletons
- Use Peptide Starter theme CSS custom properties with fallbacks:
-
Wire hooks in
class-prautoblogger.php:- Instantiate the PHP class in
register_frontend_hooks() - Register shortcode on
init, REST routes onrest_api_init
- Instantiate the PHP class in
-
Add
'frontend/'to the autoloader's$directoriesarray (already done for the first component) -
Update ARCHITECTURE.md file tree and add a data flow section
REST endpoint conventions:
- Namespace:
prautoblogger/v1 - Permission callback: use a filter hook for extensibility (e.g.,
prautoblogger_rest_{name}_public) - Sanitize all params with
sanitize_callbackin arg registration - Return only the fields the frontend needs — no unnecessary data
- Set
Cache-Controlheader for cacheable responses
Code Quality Rules
declare(strict_types=1);in every PHP file- Type declarations on all method parameters, return types, and class properties
- No file exceeds 300 lines. Split when approaching this limit
- Every class has a 3-question preamble docblock: What / Who triggers / Dependencies
- Every public method has a docblock with
@param,@return, and side effects @seereferences at the top of files that participate in multi-class flows- All strings translatable via
__()/_e()withprautobloggertext domain - No magic methods without explicit justification documented in code
- No
echoof raw data — always escape withesc_html(),esc_attr(), etc. - Nonce on every form/AJAX —
wp_nonce_field()/check_ajax_referer()
How To: Add an OpenRouter model picker field (v0.11.0+)
Use the model_select field type to let admins choose OpenRouter models with a searchable dropdown, capability filtering, and estimated cost preview based on usage history.
1. Declare the field in class-settings-fields.php
[
'id' => 'my_plugin_model',
'label' => __( 'My Model Setting', 'my-plugin' ),
'type' => 'model_select',
'section' => 'my_plugin_settings',
'default' => 'anthropic/claude-3.5-haiku',
'capability' => 'text→text', // see ARCHITECTURE.md §18 for all capability strings
'description' => __( 'Choose a model...', 'my-plugin' ),
'badge' => __( 'Quality', 'my-plugin' ),
],
2. Map the setting to pipeline stages (for cost preview)
If you want cost preview to work, add your setting ID and stage list to the get_stages_for_setting() method in includes/admin/fields/class-open-router-model-field.php:
private static function get_stages_for_setting( string $field_id ): array {
$map = [
'prautoblogger_analysis_model' => [ 'analysis' ],
'prautoblogger_writing_model' => [ 'outline', 'draft', 'polish' ],
'prautoblogger_editor_model' => [ 'review' ],
'my_new_model_setting' => [ 'my_stage' ], // ADD HERE
];
return $map[ $field_id ] ?? [];
}
The cost preview queries get_avg_tokens_for_stages() on the Cost Tracker to show estimated cost per generation based on the last 30 days of usage.
3. That's it
The field renderer, AJAX refresh, model picker JS, and capability filtering are already wired. The admin will see a "Refresh Model List" button on the AI Models tab (or whichever tab contains the picker field).
Capability strings (locked in v0.11.0)
text→text— text input, text output (default for analysis/writing/editor)text+image→text— vision modelstext+audio→text— audio input modelstext→image— image generationtext→audio— voice synthesistext→video— video generationtext→embedding— embeddings
Phase 3 will add provider selection; v0.11.0 is OpenRouter-only.
Git Workflow — PR-Gated, Soft-Enforced
This repo is private on GitHub's free plan, which does not support branch protection or rulesets. The review gate is enforced at the agent layer, not server-side. Every agent and human contributor follows these rules; the .github/workflows/main-push-audit.yml tripwire opens an audit issue on any direct push to main that did not come from a merged PR.
Rules
-
Never push to
maindirectly. Every change lands via a pull request that the maintainer merges. No self-merging. No force-pushing tomain. -
Branch naming:
claude/<scope>-<YYYYMMDD>for agent-authored work,fix/<scope>orfeat/<scope>for human-authored. The scope is a 1–3 word kebab-case description. -
Commit trailer: every commit authored by an agent must end with an
Agent-Session:trailer so commits can be correlated back to the conversation that produced them, even though all commits share thepeptiderepobot identity.feat: add per-request cost tracking Agent-Session: cowork-2026-04-14-cost-audit -
PR description template: every PR description covers
- What changed (one paragraph)
- Why (motivation or incident link)
- Risk flags (schema changes, API contract changes, cost impact, compatibility)
- Test plan (what was run locally, what to smoke-test after merge)
-
Emergency push exception: if a situation genuinely requires pushing to
mainwithout a PR (site down, CI broken, one-line hotfix), surface it in the chat before doing it. Every emergency push gets a follow-up PR that commits the same changes through the normal flow so git stays the source of truth. The tripwire will open an audit issue — close it with a comment explaining why.
Opening a PR from an agent session
The gh CLI is not installed in the Cowork sandbox. Use curl with the PAT from the workspace .env.credentials:
GH_TOKEN=$(grep "^GITHUB_PAT=" "$WORKSPACE/.env.credentials" | cut -d= -f2)
git push -u origin HEAD
curl -s -X POST -H "Authorization: token $GH_TOKEN" \
-H "Content-Type: application/json" \
"https://api.github.com/repos/peptiderepo/<repo>/pulls" \
-d "$(jq -cn --arg title "<title>" --arg head "<branch>" --arg body "<body>" \
'{title:$title, head:$head, base:"main", body:$body}')"
When this changes
If the peptiderepo GitHub account is ever upgraded to Pro (or the repo goes public), replace this soft-enforcement section with a note pointing to the real branch protection rules in repo settings.
Instrumenting a New LLM Call Site for Opik (v0.12.0+)
When you add a new LLM call (via $llm->send_chat_completion()), follow this pattern to capture it in Opik:
1. Get the trace context
At the start of your method, grab the per-request singleton:
$ctx = PRAutoBlogger_Opik_Trace_Context::current();
2. Start the span
Before the LLM call, declare a span with metadata:
$span_id = $ctx->start_span(
array(
'name' => 'my_llm_operation', // kebab-case, descriptive
'type' => 'llm',
'model' => $model, // e.g. 'gpt-4', from get_option()
'provider' => 'openrouter', // or the actual provider name
'input' => array( 'key' => 'value' ), // optional metadata (no full prompts!)
)
);
3. Make the LLM call (unchanged)
$response = $this->llm->send_chat_completion(
array(
array( 'role' => 'system', 'content' => $system_prompt ),
array( 'role' => 'user', 'content' => $user_prompt ),
),
$model,
array( 'temperature' => 0.7, 'max_tokens' => 4000 )
);
4. End the span with output
After the response, record the outcome:
$ctx->end_span(
$span_id,
array(
'output' => array( 'response_length' => strlen( $response['content'] ?? '' ) ),
'usage' => array(
'prompt_tokens' => $response['prompt_tokens'],
'completion_tokens' => $response['completion_tokens'],
'total_tokens' => $response['prompt_tokens'] + $response['completion_tokens'],
),
)
);
Key rules
- Never log full prompts. Use prompt hashes or version tags in
input. Full text blows payload size and leaks content policy. - Check the feature flag at call-site level? No. The singleton is always available; Opik_Dispatcher checks the flag before dispatch.
- Async dispatch? Yes. Spans are queued; dispatch happens via
prautoblogger_opik_dispatchcron (no blocking). - Cost logging? Unchanged. Your
cost_tracker->log_api_call()remains separate from Opik; Opik is observability-only.
Example: minimal instrumentation
public function my_operation( $input ) {
$model = get_option( 'my_model', 'default-model' );
$ctx = PRAutoBlogger_Opik_Trace_Context::current();
$span_id = $ctx->start_span(
array(
'name' => 'my_operation',
'type' => 'llm',
'model' => $model,
'provider' => 'openrouter',
)
);
$response = $this->llm->send_chat_completion( /* ... */ );
$ctx->end_span(
$span_id,
array(
'usage' => array(
'prompt_tokens' => $response['prompt_tokens'],
'completion_tokens' => $response['completion_tokens'],
'total_tokens' => $response['prompt_tokens'] + $response['completion_tokens'],
),
)
);
return $response['content'];
}
That's it. No config, no feature-flag checks, no try/catch needed.