Files
prautoblogger/includes/admin/class-settings-fields-extended.php
peptiderepo 33d3ba4ea1 v0.8.1: orphan research-row reaper + GA4 description fix
* feat(cost): persist run_id on generated posts + back-populate for v0.8.1 reaper

The orphan-research-row reaper (upcoming commit) needs a cheap way to
answer "which posts belong to this run_id?" when amortizing a stranded
`llm_research` row back to its siblings. Relying on the generation_log
post_id column is circular: if the pipeline died before amortize_research
ran, we'd trust the same table we're trying to repair.

- `Publisher::build_meta()` now accepts `?string $run_id` and writes
  `_prautoblogger_run_id => $run_id` when non-empty. Null/empty preserves
  existing null-safe legacy behaviour.
- `Publisher::create_post()` forwards the $run_id it already receives
  into build_meta — one-line plumbing, no new parameters at call sites.
- `Activator::backpopulate_run_id_meta_v081()` runs once per site,
  gated by `prautoblogger_migrated_run_id_meta_v081` option. Walks
  `wp_prautoblogger_generation_log`, pairs (post_id, min(run_id)),
  writes `_prautoblogger_run_id` on any post that doesn't already have
  it. Uses `metadata_exists()` so it's a no-op on re-run.
- Activator also schedules the new daily `prautoblogger_reap_orphan_research_rows`
  cron at 03:15 — 15 min after the primary generation cron — so live
  pipelines have time to finish amortizing cleanly before the reaper scans.
- Deactivator unschedules the new hook alongside the existing cron hooks.

No user-visible behaviour change yet. The reaper handler ships in the
next commit; this one just ensures it will have the data + schedule it
needs when it lands.

Part of the v0.8.1 bundle (thread research-cost-reaper).

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

* feat(cost): daily reaper for orphan llm_research rows

Implements the orphan-research-row reaper per thread research-cost-reaper
seq 1. When a pipeline dies between post creation and
`Post_Assembler::amortize_research_costs()` (Hostinger exec kill, OOM,
LiteSpeed cut, fatal), the shared research row stays in
`wp_prautoblogger_generation_log` with `post_id = NULL` and sibling
articles' cost popovers understate their true spend.

New class `PRAutoBlogger_Research_Reaper` owns:

- `on_cron()` — WP-Cron entrypoint hooked to the daily
  `prautoblogger_reap_orphan_research_rows` event (already scheduled
  by the Activator in the previous commit at 03:15).
- `reap()` — returns a `{reaped, deleted, skipped}` summary. For each
  orphan `llm_research` row older than 1 hour (grace window for live
  pipelines):
  - `_prautoblogger_run_id` post_meta primary lookup.
  - Falls back to distinct post_ids in gen_log with the same run_id.
  - If ≥1 post found → delegate to the existing idempotent
    `Post_Assembler::amortize_research_costs()`. That method inserts
    the per-article rows and deletes the original orphan in one pass,
    so the reaper is idempotent on re-run.
  - If 0 posts found AND orphan is >7 days old → delete outright as
    sunk cost with nothing to attribute to.
  - Else → skip; try again the next day.
- Never throws. DB errors caught and logged WARNING.

New WP-CLI command `wp prautoblogger reap-research` registered in the
main loader when `WP_CLI` is defined — manual ops trigger that prints
the same summary.

Departure from the CTO's spec: the reap logic lives on
`Research_Reaper` instead of `Post_Assembler`. Placing it on
Post_Assembler pushed that file to 391 lines, over the 300-line cap.
Moving it to the thin class colocates the attribution logic with the
cron/CLI plumbing that triggers it. `amortize_research_costs()` stays
on Post_Assembler — the reaper delegates to it unchanged. Flagged for
CTO review.

Part of the v0.8.1 bundle (thread research-cost-reaper).

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

* test(cost): ResearchReaperTest — five cases per spec

Covers the five scenarios from convo thread research-cost-reaper seq 1:

- test_reaps_orphan_with_matching_run_id_posts — meta lookup returns
  two post IDs; reaper delegates to amortize_research_costs which
  inserts two rows and deletes the orphan.
- test_skips_orphan_within_grace_period — the SELECT's
  `created_at < now - 1h` clause filters fresh orphans at the DB; the
  reaper's stats stay zero and no side effects fire.
- test_deletes_orphan_with_no_articles_after_7_days — orphan is 8 days
  old, zero siblings via meta or gen_log; reaper deletes outright.
- test_falls_back_to_gen_log_when_post_meta_missing — meta returns
  empty (legacy pre-v0.8.1 post); gen_log has two distinct post_ids;
  amortize still runs.
- test_idempotent_on_repeat_run — second pass on a cleaned state
  returns 0/0/0 with no inserts or deletes.

All DB interaction mocked via $wpdb stub — no real DB round-trips.
Fixtures are driven by property mutations in the test methods so
PHPUnit's "one stub per method" restriction on mocks doesn't bite.

Part of the v0.8.1 bundle.

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

* chore(v0.8.1): version bump + CHANGELOG + ARCHITECTURE.md for reaper

Bump plugin header and PRAUTOBLOGGER_VERSION 0.8.0 → 0.8.1. Patch
bump — the reaper is internal infrastructure; no user-visible UX
change.

CHANGELOG:
- Fixed: orphan `llm_research` rows now reaped daily.
- Added: WP-CLI `wp prautoblogger reap-research`; `_prautoblogger_run_id`
  post_meta is now written by Publisher on every post.

ARCHITECTURE.md:
- Decision #13 (Run-ID based log linking) cross-refs the new post_meta.
- New Decision #19 documents the reaper's scan/amortize/delete policy,
  the 1-hour grace window, the 7-day stale cutoff, and the one-shot
  activator back-populate migration.

DB schema unchanged — no PRAUTOBLOGGER_DB_VERSION bump.

Final commit of the v0.8.1 bundle.

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

* test(cost): widen $wpdb mock so ResearchReaperTest actually runs

BaseTestCase::create_mock_wpdb() only adds methods
[prepare, get_var, get_results, insert, query, get_row, update] — the
reaper and amortize_research_costs also call `delete` and `get_col`,
which the default mock can't stub. CI on PR #100 fired all five new
ResearchReaperTest cases as errors at the first `->method('delete')`
call during setUp.

Fix: build a bespoke $wpdb mock in this file's setUp with the full
superset of methods it needs. Self-contained; no change to
BaseTestCase so existing tests aren't affected.

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

* test(cost): filter gen_log inserts from event_log inserts in reaper tests

CI on ec1efee showed the two reap-and-amortize tests failing because
`assertCount(2, $this->inserted_rows)` was seeing 4 rows, not 2. Root
cause: the Logger singleton writes INFO lines via `$wpdb->insert` into
the event_log table — the reaper logs once ("Reaped...") and amortize
logs once ("Amortized LLM research cost...") before the two per-post
gen_log inserts, so the capture ended up with 4 total rows.

Add a `gen_log_inserts()` helper that filters the capture down to rows
whose `__table` contains "generation_log" — the assertions on
attribution count should only measure the actual cost inserts, not
incidental log writes. Two tests updated; logic unchanged.

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

* fix(admin): correct misleading GA4 Property ID description

The GA4 client at includes/core/class-ga4-client.php:85 already wraps the
property_id option with "properties/" in the outbound URL path. The admin
description told users to enter "properties/XXXXXXXXX" — which would have
produced a double-prefixed URL (properties/properties/XXXXXXXXX) and a
guaranteed 404 from the Data API. Description now asks for digits only,
matching actual code behavior.

No code logic change — UX copy + CHANGELOG only. Flagged by CEO while
inspecting the Analytics tab; folded into the in-flight v0.8.1 PR per
CTO call rather than cutting a separate patch.

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 09:48:34 +07:00

266 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* Extended settings fields: schedule, publishing, analytics, display, and images.
*
* 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,
* PRAutoBlogger_Article_Typography (font choices), PRAutoBlogger_Image_Model_Registry.
*
* @see admin/class-settings-fields.php — Core fields and sections; calls get_extended_fields().
* @see admin/class-admin-page.php — Renders fields registered from these definitions.
* @see admin/class-image-model-registry.php — Static image model list extracted for 300-line compliance.
* @see frontend/class-article-typography.php — Reads Display settings on the frontend.
* @see CONVENTIONS.md — "How To: Add a New Admin Setting".
*/
class PRAutoBlogger_Settings_Fields_Extended {
/**
* Static image model list for the admin model picker.
* Delegates to Image_Model_Registry (extracted for 300-line file limit).
*
* @return array<int, array<string, mixed>>
*/
public static function get_image_models(): array {
return PRAutoBlogger_Image_Model_Registry::get_models();
}
/**
* Get schedule, publishing, analytics, and image settings fields.
*
* @return array<int, array<string, mixed>>
*/
public static function get_fields(): array {
return [
// ── Sources (extended) ──────────────────────────────────────
[
'id' => 'prautoblogger_research_model',
'label' => __( 'Research Model', 'prautoblogger' ),
'type' => 'model_select',
'section' => 'prautoblogger_sources',
'default' => PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL,
'capability' => 'text→text',
'description' => __( 'Model for LLM Deep Research. Pick a reasoning-capable model (e.g. Grok 4.1 Fast, DeepSeek-R1) for best results. Only used when LLM Deep Research is enabled above.', 'prautoblogger' ),
'badge' => __( 'Reasoning', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_research_prompt',
'label' => __( 'Research Prompt', 'prautoblogger' ),
'type' => 'textarea',
'section' => 'prautoblogger_sources',
'default' => 'Conduct a deep research sweep of the {niche} space and identify the most substantive, actionable topics that practitioners and curious newcomers are searching for or actively discussing.' . "\n\n" . 'Focus on:' . "\n" . '- Practical questions real readers would search for (not theoretical debates)' . "\n" . '- Emerging trends gaining momentum in the community' . "\n" . '- Common misconceptions that deserve clarification' . "\n" . '- Comparative analysis (product vs. product, protocol vs. protocol)' . "\n" . '- Safety and efficacy topics that drive concern or curiosity' . "\n" . '- Beginner-to-advanced mix — cover entry-level and depth-focused topics' . "\n\n" . 'Aim for topics that feel timely, underserved by existing content, and likely to drive reader engagement. Return 8-12 findings with detailed analysis in each.',
'description' => __( 'The research brief sent to the LLM. Use {niche} as a placeholder for your niche description. The system prompt handles output format — this controls WHAT to research.', 'prautoblogger' ),
],
// ── AI Models (extended) ────────────────────────────────────
[
'id' => 'prautoblogger_reasoning_enabled',
'label' => __( 'Enable Reasoning', 'prautoblogger' ),
'type' => 'toggle',
'section' => 'prautoblogger_models',
'default' => '0',
'description' => __( 'Send reasoning instructions to models that support it (e.g. Grok, DeepSeek-R1). Reasoning tokens are billed as output tokens — expect higher costs per call. Models that don\'t support reasoning will ignore this.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_reasoning_effort',
'label' => __( 'Reasoning Effort', 'prautoblogger' ),
'type' => 'select',
'section' => 'prautoblogger_models',
'default' => 'medium',
'options' => [
'xhigh' => __( 'Extra High — maximum depth, highest cost', 'prautoblogger' ),
'high' => __( 'High — thorough reasoning', 'prautoblogger' ),
'medium' => __( 'Medium — balanced (recommended)', 'prautoblogger' ),
'low' => __( 'Low — light reasoning, lower cost', 'prautoblogger' ),
'minimal' => __( 'Minimal — barely any reasoning', 'prautoblogger' ),
],
'description' => __( 'How much effort the model spends reasoning before answering. Higher effort = more reasoning tokens = higher cost. Only applies when reasoning is enabled above.', 'prautoblogger' ),
],
// ── Schedule & Budget ───────────────────────────────────────
[
'id' => 'prautoblogger_daily_article_target',
'label' => __( 'Articles per Day', 'prautoblogger' ),
'type' => 'number',
'section' => 'prautoblogger_schedule',
'default' => 1,
'min' => 1,
'max' => 10,
],
[
'id' => 'prautoblogger_schedule_time',
'label' => __( 'Generation Time', 'prautoblogger' ),
'type' => 'time',
'section' => 'prautoblogger_schedule',
'default' => '03:00',
'description' => __( 'Daily generation runs at this time (server timezone).', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_monthly_budget_usd',
'label' => __( 'Monthly Budget (USD)', 'prautoblogger' ),
'type' => 'number',
'section' => 'prautoblogger_schedule',
'default' => 50,
'min' => 0,
'step' => '0.01',
'description' => __( 'Hard stop when reached. 0 = unlimited.', 'prautoblogger' ),
],
// ── Publishing ──────────────────────────────────────────────
[
'id' => 'prautoblogger_auto_publish',
'label' => __( 'Auto-Publish', 'prautoblogger' ),
'type' => 'toggle',
'section' => 'prautoblogger_publishing',
'default' => '0',
'description' => __( 'Automatically publish editor-approved posts. When off, all posts go to the Review Queue as drafts.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_default_author',
'label' => __( 'Default Author', 'prautoblogger' ),
'type' => 'author_select',
'section' => 'prautoblogger_publishing',
'default' => 0,
'description' => __( 'WordPress user assigned as author of generated posts.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_default_category',
'label' => __( 'Default Category', 'prautoblogger' ),
'type' => 'category_select',
'section' => 'prautoblogger_publishing',
'default' => 0,
'description' => __( 'Fallback category for generated posts.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_log_level',
'label' => __( 'Log Level', 'prautoblogger' ),
'type' => 'select',
'section' => 'prautoblogger_publishing',
'default' => 'info',
'options' => [
'error' => __( 'Error — only failures', 'prautoblogger' ),
'warning' => __( 'Warning — errors + warnings', 'prautoblogger' ),
'info' => __( 'Info — key events (recommended)', 'prautoblogger' ),
'debug' => __( 'Debug — everything (verbose)', 'prautoblogger' ),
],
'description' => __( 'Controls detail level in the Activity Log.', 'prautoblogger' ),
],
// ── Analytics ───────────────────────────────────────────────
[
'id' => 'prautoblogger_ga4_property_id',
'label' => __( 'GA4 Property ID', 'prautoblogger' ),
'type' => 'text',
'section' => 'prautoblogger_analytics',
'description' => __( 'Your GA4 Property ID — digits only, e.g. 123456789 (the code adds the "properties/" prefix internally). Find it in GA4 → Admin → Property details. Leave blank to skip GA4.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_ga4_credentials_json',
'label' => __( 'Service Account JSON', 'prautoblogger' ),
'type' => 'password',
'section' => 'prautoblogger_analytics',
'description' => __( 'Paste the full JSON key file for a service account with Analytics read access.', 'prautoblogger' ),
],
// ── Display ────────────────────────────────────────────────
[
'id' => 'prautoblogger_article_font_family',
'label' => __( 'Article Font', 'prautoblogger' ),
'type' => 'select',
'section' => 'prautoblogger_display',
'default' => 'default',
'options' => PRAutoBlogger_Article_Typography::get_font_choices(),
'description' => __( 'Font family used for generated article body text. Serif fonts improve long-form readability.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_article_font_size',
'label' => __( 'Article Font Size (px)', 'prautoblogger' ),
'type' => 'number',
'section' => 'prautoblogger_display',
'default' => 0,
'min' => 0,
'max' => 32,
'description' => __( 'Base font size for article body text. 0 uses the theme default (13px). Recommended: 16–18px for comfortable reading.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_table_borders',
'label' => __( 'Table Borders', 'prautoblogger' ),
'type' => 'toggle',
'section' => 'prautoblogger_display',
'default' => '1',
'description' => __( 'Add visible borders, padding, and alternating row colors to tables in generated articles.', 'prautoblogger' ),
],
// ── Images ─────────────────────────────────────────────────
[
'id' => 'prautoblogger_image_enabled',
'label' => __( 'Enable Image Generation', 'prautoblogger' ),
'type' => 'toggle',
'section' => 'prautoblogger_images',
'default' => '0',
'description' => __( 'Generate images for each published article.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_image_b_enabled',
'label' => __( 'Generate Second Image (B)', 'prautoblogger' ),
'type' => 'toggle',
'section' => 'prautoblogger_images',
'default' => '1',
'description' => __( 'Generate a second image from source data for A/B testing. Disabling saves one image generation + one LLM prompt rewrite per article.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_image_model',
'label' => __( 'Image Model', 'prautoblogger' ),
'type' => 'model_select',
'section' => 'prautoblogger_images',
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL,
'capability' => 'image_generation',
'description' => __( 'Pick an image model. The provider (OpenRouter or Cloudflare Workers AI) is derived from the model registry on save, so mismatched pairs are no longer possible.', 'prautoblogger' ),
'badge' => __( 'Quality', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_cloudflare_ai_token',
'label' => __( 'Cloudflare API Token', 'prautoblogger' ),
'type' => 'password',
'section' => 'prautoblogger_images',
'description' => __( 'Only needed if using Cloudflare provider. Workers AI token with Read + Edit scope.', 'prautoblogger' ),
'icon' => '🔑',
],
[
'id' => 'prautoblogger_cloudflare_account_id',
'label' => __( 'Cloudflare Account ID', 'prautoblogger' ),
'type' => 'text',
'section' => 'prautoblogger_images',
'default' => '',
'description' => __( 'Only needed if using Cloudflare provider.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_image_style_suffix',
'label' => __( 'Style Suffix', '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' ),
],
[
'id' => 'prautoblogger_image_prompt_instructions',
'label' => __( 'Image Prompt Instructions', 'prautoblogger' ),
'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' ),
],
[
'id' => 'prautoblogger_image_nsfw_retry',
'label' => __( 'Retry NSFW-Blocked Images', 'prautoblogger' ),
'type' => 'toggle',
'section' => 'prautoblogger_images',
'default' => '1',
'description' => __( 'When the provider rejects an image prompt as NSFW (e.g. Cloudflare code 3030), retry once with a generic fallback scene built from the article title. Disable to fail fast if the filter gets trigger-happy.', 'prautoblogger' ),
],
];
}
}