feat: Opik LLM observability tracing skeleton (Wave 1) — v0.12.0

Wires PRAutoblogger into Comet Opik cloud for per-call tracing.

- Thin PHP REST client (class-opik-client.php, 276 LOC)
- Async fire-and-forget dispatch via wp_schedule_single_event
- Feature flag PRAUTOBLOGGER_OPIK_ENABLED defaults OFF
- One trace per article, 5 spans across content-generator + chief-editor
- Exponential backoff 2s/4s/8s, max 3 retries, drops on failure
- Admin settings page (api-key configured indicator only, never rendered)
- ARCHITECTURE.md + CONVENTIONS.md updated

PR #113 — QA gate: PASS (qa-reviews/prautoblogger/2026-04-23-0d8dc67.md)
This commit is contained in:
peptiderepo
2026-04-23 22:36:24 +07:00
committed by GitHub
parent 7401addbd9
commit fbbb387311
17 changed files with 2453 additions and 99 deletions

View File

@@ -2,21 +2,13 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Writer agent — generates blog post content from article ideas.
*
* Supports two configurable pipeline modes:
* - single_pass: One LLM call produces the complete article (cheaper, faster).
* - multi_step: Outline → Draft → Polish pipeline (higher quality, more cost).
* Supports: single_pass (one LLM call) or multi_step (outline → draft → polish).
* Opik instrumentation: spans per LLM call when feature-flag enabled.
*
* Triggered by: PRAutoBlogger::run_generation_pipeline() (step 4).
* Triggered by: PRAutoBlogger_Article_Worker.
* Dependencies: LLM_Provider_Interface, Cost_Tracker, Content_Prompts.
*
* @see core/class-content-prompts.php — Builds all LLM prompts used here.
* @see core/class-chief-editor.php — Reviews output of this class.
* @see providers/interface-llm-provider.php — LLM used for generation.
* @see ARCHITECTURE.md — Data flow step 4.
*/
class PRAutoBlogger_Content_Generator {
@@ -34,12 +26,8 @@ class PRAutoBlogger_Content_Generator {
/**
* Generate a blog post from an article idea.
*
* Reads pipeline mode from settings and dispatches to the appropriate method.
* Side effects: LLM API calls (1-3 depending on mode), cost logging.
*
* @param PRAutoBlogger_Article_Idea $idea The scored idea to generate content for.
* @return string Generated HTML content.
* @throws \RuntimeException On generation failure.
*/
public function generate( PRAutoBlogger_Article_Idea $idea ): string {
$mode = get_option( 'prautoblogger_writing_pipeline', 'multi_step' );
@@ -55,21 +43,26 @@ class PRAutoBlogger_Content_Generator {
get_option( 'prautoblogger_writing_instructions', '' )
);
if ( 'single_pass' === $mode ) {
return $this->generate_single_pass( $request );
}
return $this->generate_multi_step( $request );
return 'single_pass' === $mode
? $this->generate_single_pass( $request )
: $this->generate_multi_step( $request );
}
/**
* Single-pass generation: one LLM call produces the complete article.
*
* @param PRAutoBlogger_Content_Request $request Content request with settings.
* @return string Generated HTML content.
* Single-pass: one LLM call produces complete article.
*/
private function generate_single_pass( PRAutoBlogger_Content_Request $request ): string {
$model = get_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
$ctx = PRAutoBlogger_Opik_Trace_Context::current();
$span_id = $ctx->start_span(
array(
'name' => 'single_pass_generation',
'type' => 'llm',
'model' => $model,
'provider' => 'openrouter',
)
);
$response = $this->llm->send_chat_completion(
array(
@@ -89,6 +82,17 @@ class PRAutoBlogger_Content_Generator {
)
);
$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'],
),
)
);
$this->cost_tracker->log_api_call(
null,
'draft',
@@ -101,7 +105,9 @@ class PRAutoBlogger_Content_Generator {
return $response['content'];
}
/** Multi-step generation: Outline → Draft → Polish. */
/**
* Multi-step: outline → draft → polish.
*/
private function generate_multi_step( PRAutoBlogger_Content_Request $request ): string {
$model = get_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
@@ -112,15 +118,26 @@ class PRAutoBlogger_Content_Generator {
return $polished;
}
/** Multi-step stage 1: Generate an article outline. */
/**
* Stage 1: Generate article outline.
*/
private function stage_outline( PRAutoBlogger_Content_Request $request, string $model ): string {
$system = PRAutoBlogger_Content_Prompts::build_system( $request );
$ctx = PRAutoBlogger_Opik_Trace_Context::current();
$span_id = $ctx->start_span(
array(
'name' => 'outline_generation',
'type' => 'llm',
'model' => $model,
'provider' => 'openrouter',
)
);
$response = $this->llm->send_chat_completion(
array(
array(
'role' => 'system',
'content' => $system,
'content' => PRAutoBlogger_Content_Prompts::build_system( $request ),
),
array(
'role' => 'user',
@@ -134,6 +151,17 @@ class PRAutoBlogger_Content_Generator {
)
);
$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'],
),
)
);
$this->cost_tracker->log_api_call(
null,
'outline',
@@ -146,15 +174,26 @@ class PRAutoBlogger_Content_Generator {
return $response['content'];
}
/** Multi-step stage 2: Write the full draft from the outline. */
/**
* Stage 2: Write full draft from outline.
*/
private function stage_draft( PRAutoBlogger_Content_Request $request, string $outline, string $model ): string {
$system = PRAutoBlogger_Content_Prompts::build_system( $request );
$ctx = PRAutoBlogger_Opik_Trace_Context::current();
$span_id = $ctx->start_span(
array(
'name' => 'draft_generation',
'type' => 'llm',
'model' => $model,
'provider' => 'openrouter',
)
);
$response = $this->llm->send_chat_completion(
array(
array(
'role' => 'system',
'content' => $system,
'content' => PRAutoBlogger_Content_Prompts::build_system( $request ),
),
array(
'role' => 'user',
@@ -168,6 +207,17 @@ class PRAutoBlogger_Content_Generator {
)
);
$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'],
),
)
);
$this->cost_tracker->log_api_call(
null,
'draft',
@@ -180,15 +230,26 @@ class PRAutoBlogger_Content_Generator {
return $response['content'];
}
/** Multi-step stage 3: Polish and refine the draft. */
/**
* Stage 3: Polish and refine draft.
*/
private function stage_polish( PRAutoBlogger_Content_Request $request, string $draft, string $model ): string {
$system = PRAutoBlogger_Content_Prompts::build_system( $request );
$ctx = PRAutoBlogger_Opik_Trace_Context::current();
$span_id = $ctx->start_span(
array(
'name' => 'polish_generation',
'type' => 'llm',
'model' => $model,
'provider' => 'openrouter',
)
);
$response = $this->llm->send_chat_completion(
array(
array(
'role' => 'system',
'content' => $system,
'content' => PRAutoBlogger_Content_Prompts::build_system( $request ),
),
array(
'role' => 'user',
@@ -202,6 +263,17 @@ class PRAutoBlogger_Content_Generator {
)
);
$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'],
),
)
);
$this->cost_tracker->log_api_call(
null,
'polish',