diff --git a/CHANGELOG.md b/CHANGELOG.md index cd44cd5..5708a19 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ 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.5.2] — 2026-04-20 + +### Added +- **LLM research cost amortization.** The research LLM call (which runs once + per pipeline execution) is now divided evenly across all articles produced + in that run. Each article's cost breakdown popover shows its amortized share + of the research overhead. Previously, research costs were orphaned with no + post attribution. + - `Post_Assembler::amortize_research_costs()` — Queries the unlinked + research log entry, divides cost and tokens by article count, inserts one + row per article, and removes the original. + - `link_generation_logs()` now excludes `llm_research` stage from per-article + linking so the cost isn't grabbed wholesale by the first article. + - `Source_Collector::set_cost_tracker()` — Accepts the pipeline's cost + tracker so research costs are tagged with the pipeline's `run_id`. + - `LLM_Research_Provider` now uses the pipeline's cost tracker when available, + ensuring research log entries share the same `run_id` as article entries. + - Amortization runs after all articles complete (both single-article and + chained-job paths in `Pipeline_Runner`). + ## [0.5.1] — 2026-04-20 ### Added diff --git a/includes/core/class-pipeline-runner.php b/includes/core/class-pipeline-runner.php index d2fbd25..d888779 100755 --- a/includes/core/class-pipeline-runner.php +++ b/includes/core/class-pipeline-runner.php @@ -75,6 +75,8 @@ class PRAutoBlogger_Pipeline_Runner { if ( ! empty( $ideas ) ) { $this->queue_remaining( $ideas, $cost_tracker->get_run_id(), $result ); } else { + // Single-article run — amortize research costs now (1 article = full cost). + PRAutoBlogger_Post_Assembler::amortize_research_costs( $cost_tracker->get_run_id() ); $this->log_summary( $result ); } @@ -134,6 +136,8 @@ class PRAutoBlogger_Pipeline_Runner { $this->update_queue_status( $queue ); $this->schedule_next(); } else { + // All articles done — amortize shared research costs across them. + PRAutoBlogger_Post_Assembler::amortize_research_costs( $queue['run_id'] ); $this->write_final_status( $queue['results'] ); delete_option( self::QUEUE_KEY ); PRAutoBlogger_Generation_Lock::release(); @@ -153,7 +157,9 @@ class PRAutoBlogger_Pipeline_Runner { $target = absint( get_option( 'prautoblogger_daily_article_target', 1 ) ); $this->broadcast_stage( __( 'Collecting sources from Reddit…', 'prautoblogger' ) ); - ( new PRAutoBlogger_Source_Collector() )->collect_from_all_sources(); + ( new PRAutoBlogger_Source_Collector() ) + ->set_cost_tracker( $cost_tracker ) + ->collect_from_all_sources(); $this->broadcast_stage( __( 'Analyzing topics and scoring…', 'prautoblogger' ) ); $llm = new PRAutoBlogger_OpenRouter_Provider(); diff --git a/includes/core/class-post-assembler.php b/includes/core/class-post-assembler.php index eddef07..6c18eb2 100644 --- a/includes/core/class-post-assembler.php +++ b/includes/core/class-post-assembler.php @@ -58,10 +58,13 @@ class PRAutoBlogger_Post_Assembler { $table = $wpdb->prefix . 'prautoblogger_generation_log'; if ( null !== $run_id && '' !== $run_id ) { + // Exclude 'llm_research' stage — those shared costs are amortized + // across all articles after the full pipeline completes, not linked + // to a single post. See amortize_research_costs(). // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $wpdb->query( $wpdb->prepare( - "UPDATE {$table} SET post_id = %d WHERE post_id IS NULL AND run_id = %s", + "UPDATE {$table} SET post_id = %d WHERE post_id IS NULL AND run_id = %s AND stage != 'llm_research'", $post_id, $run_id ) @@ -79,6 +82,97 @@ class PRAutoBlogger_Post_Assembler { } } + /** + * Amortize shared LLM research costs across all articles in a pipeline run. + * + * The research LLM call runs once per pipeline execution before any articles + * are generated. Its cost is logged with the run_id but no post_id. After all + * articles finish, this method divides the research cost evenly and inserts + * one 'llm_research' row per article so the cost breakdown popover includes + * the amortized research overhead. + * + * Side effects: database SELECT, INSERT, DELETE on generation_log. + * + * @param string|null $run_id Pipeline run UUID. Null or empty is a no-op. + */ + public static function amortize_research_costs( ?string $run_id ): void { + if ( null === $run_id || '' === $run_id ) { + return; + } + + global $wpdb; + $table = $wpdb->prefix . 'prautoblogger_generation_log'; + + // Find the unlinked research cost row for this run. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $research_row = $wpdb->get_row( + $wpdb->prepare( + "SELECT id, estimated_cost, provider, model, prompt_tokens, completion_tokens + FROM {$table} + WHERE run_id = %s AND stage = 'llm_research' AND post_id IS NULL + LIMIT 1", + $run_id + ) + ); + + if ( ! $research_row || (float) $research_row->estimated_cost <= 0.0 ) { + return; // No research cost to amortize (LLM research not enabled or free). + } + + // Count distinct articles produced in this run. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $post_ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT DISTINCT post_id FROM {$table} WHERE run_id = %s AND post_id IS NOT NULL", + $run_id + ) + ); + + $post_count = count( $post_ids ); + if ( $post_count < 1 ) { + return; // No articles produced — nothing to amortize into. + } + + $total_cost = (float) $research_row->estimated_cost; + $amortized_cost = $total_cost / $post_count; + $total_prompt = (int) $research_row->prompt_tokens; + $total_completion = (int) $research_row->completion_tokens; + $amortized_prompt = (int) round( $total_prompt / $post_count ); + $amortized_compl = (int) round( $total_completion / $post_count ); + + // Insert one amortized research row per article. + foreach ( $post_ids as $post_id ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->insert( $table, [ + 'post_id' => (int) $post_id, + 'run_id' => $run_id, + 'stage' => 'llm_research', + 'provider' => $research_row->provider, + 'model' => $research_row->model, + 'prompt_tokens' => $amortized_prompt, + 'completion_tokens' => $amortized_compl, + 'estimated_cost' => $amortized_cost, + 'response_status' => 'success', + 'error_message' => '', + 'created_at' => current_time( 'mysql' ), + ] ); + } + + // Remove the original unlinked row — it's been replaced by per-article rows. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->delete( $table, [ 'id' => (int) $research_row->id ] ); + + PRAutoBlogger_Logger::instance()->info( + sprintf( + 'Amortized LLM research cost $%.4f across %d articles ($%.4f each).', + $total_cost, + $post_count, + $amortized_cost + ), + 'cost-tracker' + ); + } + /** * Strip common LLM output artifacts from generated content. * Removes markdown fences, HTML document wrappers, stray head-level tags. diff --git a/includes/core/class-source-collector.php b/includes/core/class-source-collector.php index 8b6a0b7..e615fc8 100755 --- a/includes/core/class-source-collector.php +++ b/includes/core/class-source-collector.php @@ -20,6 +20,24 @@ declare(strict_types=1); */ class PRAutoBlogger_Source_Collector { + /** + * Pipeline cost tracker — passed to providers that need run_id tagging. + * Null when called outside the pipeline (e.g., manual test collection). + */ + private ?PRAutoBlogger_Cost_Tracker $cost_tracker = null; + + /** + * Inject the pipeline's cost tracker so source providers can log costs + * with the correct run_id. Without this, provider costs are orphaned. + * + * @param PRAutoBlogger_Cost_Tracker $cost_tracker Pipeline cost tracker with run_id set. + * @return $this + */ + public function set_cost_tracker( PRAutoBlogger_Cost_Tracker $cost_tracker ): self { + $this->cost_tracker = $cost_tracker; + return $this; + } + /** * Collect data from all enabled source providers and store in database. * @@ -219,10 +237,16 @@ class PRAutoBlogger_Source_Collector { } if ( 'llm_research' === $source_type ) { - return [ + $config = [ 'model' => get_option( 'prautoblogger_research_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL ), 'prompt' => get_option( 'prautoblogger_research_prompt', '' ), ]; + // Pass the pipeline's cost tracker so research costs get tagged + // with the run_id for post-pipeline amortization across articles. + if ( null !== $this->cost_tracker ) { + $config['cost_tracker'] = $this->cost_tracker; + } + return $config; } return []; diff --git a/includes/providers/class-llm-research-provider.php b/includes/providers/class-llm-research-provider.php index 78571a4..b42318f 100644 --- a/includes/providers/class-llm-research-provider.php +++ b/includes/providers/class-llm-research-provider.php @@ -34,7 +34,7 @@ class PRAutoBlogger_LLM_Research_Provider implements PRAutoBlogger_Source_Provid * * Side effects: LLM API call via OpenRouter, cost logging. * - * @param array{prompt?: string, model?: string} $config Research-specific configuration. + * @param array{prompt?: string, model?: string, cost_tracker?: PRAutoBlogger_Cost_Tracker} $config Research-specific configuration. * @return PRAutoBlogger_Source_Data[] Array of research findings as source data. * @throws \RuntimeException On API error after retries. */ @@ -65,7 +65,9 @@ class PRAutoBlogger_LLM_Research_Provider implements PRAutoBlogger_Source_Provid ] ); - $cost_tracker = new PRAutoBlogger_Cost_Tracker(); + // Use the pipeline's cost tracker if provided (for run_id tagging), + // otherwise create a standalone one (backward compat for manual calls). + $cost_tracker = $config['cost_tracker'] ?? new PRAutoBlogger_Cost_Tracker(); $cost_tracker->log_api_call( null, 'llm_research', diff --git a/prautoblogger.php b/prautoblogger.php index 5c01fc7..1116fb8 100644 --- a/prautoblogger.php +++ b/prautoblogger.php @@ -8,7 +8,7 @@ declare(strict_types=1); * 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.5.1 + * Version: 0.5.2 * Requires at least: 6.0 * Requires PHP: 7.4 * Author: PeptideRepo @@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) { | and limits without magic strings. */ -define( 'PRAUTOBLOGGER_VERSION', '0.5.1' ); +define( 'PRAUTOBLOGGER_VERSION', '0.5.2' ); define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' ); define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ ); define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );