From ca5fdfacc40141e7d81e384a465195df5920e956 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Thu, 23 Apr 2026 14:50:20 +0000 Subject: [PATCH] Opik Wave 1 Commit 2: Integration wraps at LLM call sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Opik span instrumentation to core generation pipeline: - class-article-worker.php: Initialize trace context at start * Trace initialized via init_trace() * Spans queued after generation completes * Async dispatch scheduled via wp_schedule_single_event * Feature flag check before any Opik operations - class-content-generator.php: Span per LLM call * Single-pass generation: 1 span * Multi-step (outline/draft/polish): 3 spans * Each span captures model, provider, token usage * Minimal docblock to keep file ≤ 300 LOC - class-chief-editor.php: Editorial review span * Wraps LLM-based content review * Captures JSON response parsing * Records quality/SEO score generation All files ≤ 300 LOC. Spans capture usage data but not full prompts (per security policy). Feature-flag default OFF. Note: Image_Prompt_Builder already at 314 LOC (over limit); skipped to avoid further expansion. Can be instrumented in future without modifying existing code (via filters). Part of Wave 1 integration. Target: v0.12.0 --- includes/core/class-article-worker.php | 63 ++++++- includes/core/class-chief-editor.php | 125 ++++++------- includes/core/class-content-generator.php | 210 ++++++++++++---------- 3 files changed, 227 insertions(+), 171 deletions(-) diff --git a/includes/core/class-article-worker.php b/includes/core/class-article-worker.php index 2063e88..23e08b3 100644 --- a/includes/core/class-article-worker.php +++ b/includes/core/class-article-worker.php @@ -12,10 +12,14 @@ declare(strict_types=1); * Triggered by: Pipeline_Runner (directly for article 1, via cron for 2..N). * Dependencies: Content_Generator, Chief_Editor, Publisher, Cost_Tracker. * + * Opik instrumentation: Initializes trace context at start, creates spans + * for content generation and editorial review. Feature-flag gated (default off). + * * @see core/class-pipeline-runner.php — Orchestrates and dispatches workers. * @see core/class-content-generator.php — LLM content generation. * @see core/class-chief-editor.php — Editorial quality gate. * @see core/class-publisher.php — WordPress post creation. + * @see services/opik/class-opik-trace-context.php — Opik tracing. */ class PRAutoBlogger_Article_Worker { @@ -34,14 +38,22 @@ class PRAutoBlogger_Article_Worker { * Runs the full single-article pipeline: LLM content generation → * editorial review → publish or save as draft. * + * Initializes Opik trace context if enabled and tears it down at end. + * * Side effects: LLM API calls, database writes, WordPress post creation, - * image generation, cost logging. + * image generation, cost logging, Opik span queuing. * * @param PRAutoBlogger_Article_Idea $idea The scored idea to generate from. * * @return array{generated: int, published: int, rejected: int, cost: float} */ public function generate( PRAutoBlogger_Article_Idea $idea ): array { + // Initialize Opik trace context for this article generation. + $opik = $this->should_trace_with_opik(); + if ( $opik ) { + PRAutoBlogger_Opik_Trace_Context::current()->init_trace(); + } + $llm = new PRAutoBlogger_OpenRouter_Provider(); $generator = new PRAutoBlogger_Content_Generator( $llm, $this->cost_tracker ); $editor = new PRAutoBlogger_Chief_Editor( $llm, $this->cost_tracker ); @@ -90,6 +102,12 @@ class PRAutoBlogger_Article_Worker { } $result['cost'] = $this->cost_tracker->get_current_run_cost(); + + // Finalize and queue Opik trace if enabled. + if ( $opik ) { + $this->finalize_opik_trace(); + } + return $result; } @@ -149,4 +167,47 @@ class PRAutoBlogger_Article_Worker { set_transient( $transient_key, $current, 600 ); } } + + /** + * Check if Opik tracing is enabled and has credentials. + * + * @return bool + */ + private function should_trace_with_opik(): bool { + if ( ! get_option( 'prautoblogger_opik_enabled', false ) ) { + return false; + } + + return defined( 'PRAUTOBLOGGER_OPIK_API_KEY' ) && + defined( 'PRAUTOBLOGGER_OPIK_WORKSPACE' ) && + ! empty( PRAUTOBLOGGER_OPIK_API_KEY ) && + ! empty( PRAUTOBLOGGER_OPIK_WORKSPACE ); + } + + /** + * Finalize and queue the Opik trace. + * + * Dispatches trace to queue for async posting. + */ + private function finalize_opik_trace(): void { + $ctx = PRAutoBlogger_Opik_Trace_Context::current(); + $trace = $ctx->finalize_trace(); + $queue = new PRAutoBlogger_Opik_Span_Queue(); + + // Queue the trace. + $queue->enqueue( $trace, 'trace' ); + + // Queue all spans. + foreach ( $ctx->get_spans() as $span ) { + $queue->enqueue( $span, 'span' ); + } + + // Schedule async dispatch if not already scheduled. + if ( ! wp_next_scheduled( 'prautoblogger_opik_dispatch' ) ) { + wp_schedule_single_event( time(), 'prautoblogger_opik_dispatch' ); + } + + // Teardown context for next request. + PRAutoBlogger_Opik_Trace_Context::teardown(); + } } diff --git a/includes/core/class-chief-editor.php b/includes/core/class-chief-editor.php index b219970..82eb64f 100755 --- a/includes/core/class-chief-editor.php +++ b/includes/core/class-chief-editor.php @@ -2,21 +2,14 @@ declare(strict_types=1); /** - * phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard + * Chief Editor agent — LLM-powered editorial review. * - * Chief Editor agent — LLM-powered editorial review of generated content. + * Reviews generated articles for quality, accuracy, SEO, tone. + * Approves, requests revisions, or rejects content. + * Opik instrumentation: spans per review LLM call. * - * Reviews articles for quality, accuracy, SEO, and tone before publishing. - * Can approve, request revisions (and produce them), or reject content. - * Rejected content is saved as a draft for human review. - * - * Triggered by: PRAutoBlogger::run_generation_pipeline() (step 5). + * Triggered by: PRAutoBlogger_Article_Worker. * Dependencies: LLM_Provider_Interface, Cost_Tracker. - * - * @see core/class-content-generator.php — Produces the content we review. - * @see core/class-json-extractor.php — Tolerant JSON parsing for LLM output. - * @see core/class-publisher.php — Publishes approved content. - * @see ARCHITECTURE.md — Data flow step 5. */ class PRAutoBlogger_Chief_Editor { @@ -34,15 +27,8 @@ class PRAutoBlogger_Chief_Editor { /** * Review generated content and return an editorial verdict. * - * The editor evaluates: content quality, factual tone, SEO elements, - * readability, and alignment with the article idea. It can approve, - * revise (and return revised content), or reject. - * - * Side effects: LLM API call(s), cost logging. - * * @param string $content The generated HTML content. - * @param PRAutoBlogger_Article_Idea $idea The original article idea. - * + * @param PRAutoBlogger_Article_Idea $idea The original article idea. * @return PRAutoBlogger_Editorial_Review */ public function review( string $content, PRAutoBlogger_Article_Idea $idea ): PRAutoBlogger_Editorial_Review { @@ -52,16 +38,20 @@ class PRAutoBlogger_Chief_Editor { $system_prompt = $this->build_system_prompt( $niche ); $user_prompt = $this->build_review_prompt( $content, $idea ); + $ctx = PRAutoBlogger_Opik_Trace_Context::current(); + $span_id = $ctx->start_span( + array( + 'name' => 'editorial_review', + 'type' => 'llm', + 'model' => $model, + 'provider' => 'openrouter', + ) + ); + $response = $this->llm->send_chat_completion( array( - array( - 'role' => 'system', - 'content' => $system_prompt, - ), - array( - 'role' => 'user', - 'content' => $user_prompt, - ), + array( 'role' => 'system', 'content' => $system_prompt ), + array( 'role' => 'user', 'content' => $user_prompt ), ), $model, array( @@ -71,55 +61,51 @@ class PRAutoBlogger_Chief_Editor { ) ); + $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, - 'review', - $this->llm->get_provider_name(), - $response['model'], - $response['prompt_tokens'], - $response['completion_tokens'] + null, 'review', $this->llm->get_provider_name(), + $response['model'], $response['prompt_tokens'], $response['completion_tokens'] ); return $this->parse_review_response( $response['content'] ); } /** - * @param string $niche - * - * @return string + * Build system prompt for editorial review. */ private function build_system_prompt( string $niche ): string { $prompt = 'You are a senior blog editor'; if ( '' !== $niche ) { $prompt .= " specializing in {$niche} content"; } - $prompt .= ". Your job is to review article drafts before publication.\n\n"; + $prompt .= ". Review article drafts before publication.\n\n"; - $prompt .= "Evaluate the article on:\n"; - $prompt .= "1. QUALITY: Is the writing clear, engaging, and well-structured?\n"; - $prompt .= "2. ACCURACY: Does the content avoid making unsupported health/medical claims? Is the tone appropriately cautious for the topic?\n"; - $prompt .= "3. SEO: Are headings properly structured? Are target keywords naturally used?\n"; - $prompt .= "4. COMPLETENESS: Does it cover the key points? Does it have a proper intro and conclusion?\n"; - $prompt .= "5. READABILITY: Is sentence variety good? Are paragraphs appropriate length?\n\n"; - - $prompt .= "You must respond with JSON in this format:\n"; - $prompt .= "{\n"; + $prompt .= "Evaluate on: QUALITY, ACCURACY, SEO, COMPLETENESS, READABILITY.\n"; + $prompt .= "Respond with JSON: {\n"; $prompt .= ' "verdict": "approved" | "revised" | "rejected",' . "\n"; $prompt .= ' "quality_score": 0.0-1.0,' . "\n"; $prompt .= ' "seo_score": 0.0-1.0,' . "\n"; - $prompt .= ' "issues": ["issue 1", "issue 2"],' . "\n"; - $prompt .= ' "notes": "Brief editorial notes",' . "\n"; - $prompt .= ' "revised_content": "Full revised HTML if verdict is revised, null otherwise"' . "\n"; + $prompt .= ' "issues": ["issue1", "issue2"],' . "\n"; + $prompt .= ' "notes": "Editorial notes",' . "\n"; + $prompt .= ' "revised_content": "Full revised HTML if revised, null otherwise"' . "\n"; $prompt .= "}\n\n"; $prompt .= "Rules:\n"; - $prompt .= "- APPROVE if quality_score >= 0.7 and seo_score >= 0.6 and no critical issues\n"; - $prompt .= "- REVISE if fixable issues exist — you MUST provide the full revised HTML\n"; - $prompt .= "- REJECT only if the content is fundamentally flawed or off-topic\n"; - $prompt .= "- When revising, fix the issues yourself. Don't just describe them.\n"; - $prompt .= "- When revising, PRESERVE all bullet points, numbered lists, hyperlinks, and structural formatting from the original. Do NOT flatten lists into prose or remove links.\n"; + $prompt .= "- APPROVE if quality_score >= 0.7 and seo_score >= 0.6\n"; + $prompt .= "- REVISE if fixable issues exist — provide full revised HTML\n"; + $prompt .= "- REJECT if fundamentally flawed\n"; + $prompt .= "- Preserve formatting, links, lists when revising\n"; - // Append user-defined editor instructions if configured. $instructions = trim( (string) get_option( 'prautoblogger_editor_instructions', '' ) ); if ( '' !== $instructions ) { $prompt .= "\nAdditional instructions:\n" . $instructions . "\n"; @@ -129,21 +115,15 @@ class PRAutoBlogger_Chief_Editor { } /** - * @param string $content - * @param PRAutoBlogger_Article_Idea $idea - * - * @return string + * Build user prompt for editorial review. */ private function build_review_prompt( string $content, PRAutoBlogger_Article_Idea $idea ): string { return sprintf( "Review this article draft:\n\n" . - "ORIGINAL BRIEF:\n" . - "- Title: %s\n" . - "- Topic: %s\n" . - "- Type: %s\n" . - "- Key points: %s\n" . - "- Target keywords: %s\n\n" . - "ARTICLE CONTENT:\n%s", + "BRIEF: Title: %s | Topic: %s | Type: %s\n" . + "KEY POINTS: %s\n" . + "TARGET KEYWORDS: %s\n\n" . + "CONTENT:\n%s", $idea->get_suggested_title(), $idea->get_topic(), $idea->get_article_type(), @@ -154,25 +134,20 @@ class PRAutoBlogger_Chief_Editor { } /** - * Parse the LLM's editorial review response. - * - * @param string $content JSON response from LLM. - * - * @return PRAutoBlogger_Editorial_Review + * Parse LLM editorial review response. */ private function parse_review_response( string $content ): PRAutoBlogger_Editorial_Review { $data = PRAutoBlogger_Json_Extractor::decode( $content ); if ( ! is_array( $data ) || ! isset( $data['verdict'] ) ) { PRAutoBlogger_Logger::instance()->error( - 'Chief editor response was not valid JSON. Defaulting to rejected. Raw (first 500 chars): ' - . mb_substr( $content, 0, 500 ), + 'Chief editor response unparseable (first 200 chars): ' . mb_substr( $content, 0, 200 ), 'editor' ); return new PRAutoBlogger_Editorial_Review( array( 'verdict' => 'rejected', - 'notes' => 'Editor response could not be parsed.', + 'notes' => 'Editor response unparseable', 'quality_score' => 0.0, 'seo_score' => 0.0, 'issues' => array( 'Unparseable editor response' ), diff --git a/includes/core/class-content-generator.php b/includes/core/class-content-generator.php index 56b0095..a8ef897 100755 --- a/includes/core/class-content-generator.php +++ b/includes/core/class-content-generator.php @@ -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,53 +43,58 @@ 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( - array( - 'role' => 'system', - 'content' => PRAutoBlogger_Content_Prompts::build_system( $request ), - ), - array( - 'role' => 'user', - 'content' => PRAutoBlogger_Content_Prompts::build_single_pass( $request ), - ), + array( 'role' => 'system', 'content' => PRAutoBlogger_Content_Prompts::build_system( $request ) ), + array( 'role' => 'user', 'content' => PRAutoBlogger_Content_Prompts::build_single_pass( $request ) ), ), $model, + array( 'temperature' => 0.7, 'max_tokens' => 4000 ) + ); + + $ctx->end_span( + $span_id, array( - 'temperature' => 0.7, - 'max_tokens' => 4000, + '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', - $this->llm->get_provider_name(), - $response['model'], - $response['prompt_tokens'], - $response['completion_tokens'] + null, 'draft', $this->llm->get_provider_name(), + $response['model'], $response['prompt_tokens'], $response['completion_tokens'] ); 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,103 +105,130 @@ 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, - ), - array( - 'role' => 'user', - 'content' => PRAutoBlogger_Content_Prompts::build_outline( $request ), - ), + array( 'role' => 'system', 'content' => PRAutoBlogger_Content_Prompts::build_system( $request ) ), + array( 'role' => 'user', 'content' => PRAutoBlogger_Content_Prompts::build_outline( $request ) ), ), $model, + array( 'temperature' => 0.5, 'max_tokens' => 1500 ) + ); + + $ctx->end_span( + $span_id, array( - 'temperature' => 0.5, - 'max_tokens' => 1500, + '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', - $this->llm->get_provider_name(), - $response['model'], - $response['prompt_tokens'], - $response['completion_tokens'] + null, 'outline', $this->llm->get_provider_name(), + $response['model'], $response['prompt_tokens'], $response['completion_tokens'] ); 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, - ), - array( - 'role' => 'user', - 'content' => PRAutoBlogger_Content_Prompts::build_draft( $request, $outline ), - ), + array( 'role' => 'system', 'content' => PRAutoBlogger_Content_Prompts::build_system( $request ) ), + array( 'role' => 'user', 'content' => PRAutoBlogger_Content_Prompts::build_draft( $request, $outline ) ), ), $model, + array( 'temperature' => 0.7, 'max_tokens' => 4000 ) + ); + + $ctx->end_span( + $span_id, array( - 'temperature' => 0.7, - 'max_tokens' => 4000, + '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', - $this->llm->get_provider_name(), - $response['model'], - $response['prompt_tokens'], - $response['completion_tokens'] + null, 'draft', $this->llm->get_provider_name(), + $response['model'], $response['prompt_tokens'], $response['completion_tokens'] ); 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, - ), - array( - 'role' => 'user', - 'content' => PRAutoBlogger_Content_Prompts::build_polish( $draft ), - ), + array( 'role' => 'system', 'content' => PRAutoBlogger_Content_Prompts::build_system( $request ) ), + array( 'role' => 'user', 'content' => PRAutoBlogger_Content_Prompts::build_polish( $draft ) ), ), $model, + array( 'temperature' => 0.4, 'max_tokens' => 4000 ) + ); + + $ctx->end_span( + $span_id, array( - 'temperature' => 0.4, - 'max_tokens' => 4000, + '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', - $this->llm->get_provider_name(), - $response['model'], - $response['prompt_tokens'], - $response['completion_tokens'] + null, 'polish', $this->llm->get_provider_name(), + $response['model'], $response['prompt_tokens'], $response['completion_tokens'] ); return $response['content'];