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:
peptiderepo
2026-04-20 00:44:33 +07:00
parent 327cdc08d9
commit 264fcf0f84
6 changed files with 153 additions and 7 deletions

View File

@@ -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

View File

@@ -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();

View File

@@ -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.

View File

@@ -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 [];

View File

@@ -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',

View File

@@ -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__ ) );