Amortize LLM research cost across generated articles (v0.5.2)
Research LLM call runs once per pipeline but was logged with no run_id or post_id, making it invisible in per-article cost breakdowns. Now the pipeline's cost tracker flows through Source_Collector to the research provider, tagging costs with the run_id. After all articles complete, amortize_research_costs() divides the cost evenly and inserts one row per article. link_generation_logs() excludes llm_research stage so the first article doesn't grab the full cost. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
20
CHANGELOG.md
20
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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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__ ) );
|
||||
|
||||
Reference in New Issue
Block a user