batchPath = config('mcp.content.batch_path', 'app/Mod/Agentic/Resources/tasks'); $this->promptPath = config('mcp.content.prompt_path', 'app/Mod/Agentic/Resources/prompts/content'); $this->draftsPath = config('mcp.content.drafts_path', 'app/Mod/Agentic/Resources/drafts'); } /** * Load a batch specification from markdown file. */ public function loadBatch(string $batchId): ?array { $file = base_path("{$this->batchPath}/{$batchId}.md"); if (! File::exists($file)) { return null; } $content = File::get($file); return $this->parseBatchSpec($content); } /** * List all available batches. */ public function listBatches(): array { $files = File::glob(base_path("{$this->batchPath}/batch-*.md")); $batches = []; foreach ($files as $file) { $batchId = pathinfo($file, PATHINFO_FILENAME); $spec = $this->loadBatch($batchId); if ($spec) { $batches[] = [ 'id' => $batchId, 'service' => $spec['service'] ?? 'Unknown', 'category' => $spec['category'] ?? 'Unknown', 'article_count' => count($spec['articles'] ?? []), 'priority' => $spec['priority'] ?? 'normal', ]; } } return $batches; } /** * Get batch generation status. */ public function getBatchStatus(string $batchId): array { $spec = $this->loadBatch($batchId); if (! $spec) { return ['error' => 'Batch not found']; } $articles = $spec['articles'] ?? []; $generated = 0; $drafted = 0; $published = 0; foreach ($articles as $article) { $slug = $article['slug'] ?? null; if (! $slug) { continue; } // Check if draft exists $draftPath = $this->getDraftPath($spec, $slug); if (File::exists($draftPath)) { $drafted++; } // Check if published in WordPress $item = ContentItem::where('slug', $slug)->first(); if ($item) { $generated++; if ($item->status === 'publish') { $published++; } } } return [ 'batch_id' => $batchId, 'service' => $spec['service'] ?? 'Unknown', 'category' => $spec['category'] ?? 'Unknown', 'total' => count($articles), 'drafted' => $drafted, 'generated' => $generated, 'published' => $published, 'remaining' => count($articles) - $drafted, ]; } /** * Generate content for a batch. * * @param string $batchId Batch identifier (e.g., 'batch-001-link-getting-started') * @param string $provider AI provider ('gemini' for bulk, 'claude' for refinement) * @param bool $dryRun If true, shows what would be generated without creating files * @return array Generation results */ public function generateBatch( string $batchId, string $provider = 'gemini', bool $dryRun = false ): array { $spec = $this->loadBatch($batchId); if (! $spec) { return ['error' => "Batch not found: {$batchId}"]; } $results = [ 'batch_id' => $batchId, 'provider' => $provider, 'articles' => [], 'generated' => 0, 'skipped' => 0, 'failed' => 0, ]; $promptTemplate = $this->loadPromptTemplate('help-article'); foreach ($spec['articles'] ?? [] as $article) { $slug = $article['slug'] ?? null; if (! $slug) { continue; } $draftPath = $this->getDraftPath($spec, $slug); // Skip if already drafted if (File::exists($draftPath)) { $results['articles'][$slug] = ['status' => 'skipped', 'reason' => 'already drafted']; $results['skipped']++; continue; } if ($dryRun) { $results['articles'][$slug] = ['status' => 'would_generate', 'path' => $draftPath]; continue; } try { $content = $this->generateArticle($article, $spec, $promptTemplate, $provider); $this->saveDraft($draftPath, $content, $article); $results['articles'][$slug] = ['status' => 'generated', 'path' => $draftPath]; $results['generated']++; } catch (\Exception $e) { $results['articles'][$slug] = ['status' => 'failed', 'error' => $e->getMessage()]; $results['failed']++; } } return $results; } /** * Generate a single article. */ public function generateArticle( array $article, array $spec, string $promptTemplate, string $provider = 'gemini' ): string { $prompt = $this->buildPrompt($article, $spec, $promptTemplate); $response = $this->ai->provider($provider)->generate( systemPrompt: 'You are a professional content writer for Host Hub.', userPrompt: $prompt, config: [ 'temperature' => 0.7, 'max_tokens' => 4000, ] ); return $response->content; } /** * Refine a draft using Claude for quality improvement. */ public function refineDraft(string $draftPath): string { if (! File::exists($draftPath)) { throw new \InvalidArgumentException("Draft not found: {$draftPath}"); } $draft = File::get($draftPath); $refinementPrompt = $this->loadPromptTemplate('quality-refinement'); $prompt = str_replace( ['{{DRAFT_CONTENT}}'], [$draft], $refinementPrompt ); $response = $this->ai->claude()->generate($prompt, [ 'temperature' => 0.3, 'max_tokens' => 4000, ]); return $response->content; } /** * Validate a draft against quality gates. */ public function validateDraft(string $draftPath): array { if (! File::exists($draftPath)) { return ['valid' => false, 'errors' => ['Draft file not found']]; } $content = File::get($draftPath); $errors = []; $warnings = []; // Word count check $wordCount = str_word_count(strip_tags($content)); if ($wordCount < 600) { $errors[] = "Word count too low: {$wordCount} (minimum 600)"; } elseif ($wordCount > 1500) { $warnings[] = "Word count high: {$wordCount} (target 800-1200)"; } // UK English spelling check (basic) $usSpellings = ['color', 'customize', 'organize', 'optimize', 'analyze']; foreach ($usSpellings as $us) { if (stripos($content, $us) !== false) { $errors[] = "US spelling detected: '{$us}' - use UK spelling"; } } // Check for banned words $bannedWords = ['leverage', 'utilize', 'synergy', 'cutting-edge', 'revolutionary', 'seamless', 'robust']; foreach ($bannedWords as $banned) { if (stripos($content, $banned) !== false) { $errors[] = "Banned word detected: '{$banned}'"; } } // Check for required sections if (stripos($content, '## ') === false && stripos($content, '### ') === false) { $errors[] = 'No headings found - article needs structure'; } // Check for FAQ section if (stripos($content, 'FAQ') === false && stripos($content, 'frequently asked') === false) { $warnings[] = 'No FAQ section found'; } return [ 'valid' => empty($errors), 'word_count' => $wordCount, 'errors' => $errors, 'warnings' => $warnings, ]; } /** * Parse a batch specification markdown file. */ protected function parseBatchSpec(string $content): array { $spec = [ 'service' => null, 'category' => null, 'priority' => null, 'variables' => [], 'articles' => [], ]; // Extract header metadata if (preg_match('/\*\*Service:\*\*\s*(.+)/i', $content, $m)) { $spec['service'] = trim($m[1]); } if (preg_match('/\*\*Category:\*\*\s*(.+)/i', $content, $m)) { $spec['category'] = trim($m[1]); } if (preg_match('/\*\*Priority:\*\*\s*(.+)/i', $content, $m)) { $spec['priority'] = strtolower(trim(explode('(', $m[1])[0])); } // Extract generation variables from YAML block if (preg_match('/```yaml\s*(SERVICE_NAME:.*?)```/s', $content, $m)) { try { $spec['variables'] = Yaml::parse($m[1]); } catch (\Exception $e) { // Ignore parse errors } } // Extract articles (YAML blocks after ### Article headers) preg_match_all('/### Article \d+:.*?\n```yaml\s*(.+?)```/s', $content, $matches); foreach ($matches[1] as $yaml) { try { $article = Yaml::parse($yaml); if (isset($article['SLUG'])) { $spec['articles'][] = array_change_key_case($article, CASE_LOWER); } } catch (\Exception $e) { // Skip malformed YAML } } return $spec; } /** * Load a prompt template. */ protected function loadPromptTemplate(string $name): string { $file = base_path("{$this->promptPath}/{$name}.md"); if (! File::exists($file)) { throw new \InvalidArgumentException("Prompt template not found: {$name}"); } return File::get($file); } /** * Build the full prompt for an article. */ protected function buildPrompt(array $article, array $spec, string $template): string { $vars = array_merge($spec['variables'] ?? [], $article); // Replace template variables $prompt = $template; foreach ($vars as $key => $value) { if (is_string($value)) { $placeholder = '{{'.strtoupper($key).'}}'; $prompt = str_replace($placeholder, $value, $prompt); } elseif (is_array($value)) { $placeholder = '{{'.strtoupper($key).'}}'; $prompt = str_replace($placeholder, implode(', ', $value), $prompt); } } // Build outline section if (isset($article['outline'])) { $outlineText = $this->formatOutline($article['outline']); $prompt = str_replace('{{OUTLINE}}', $outlineText, $prompt); } return $prompt; } /** * Format an outline array into readable text. */ protected function formatOutline(array $outline, int $level = 0): string { $text = ''; $indent = str_repeat(' ', $level); foreach ($outline as $key => $value) { if (is_array($value)) { $text .= "{$indent}- {$key}:\n"; $text .= $this->formatOutline($value, $level + 1); } else { $text .= "{$indent}- {$value}\n"; } } return $text; } /** * Get the draft file path for an article. */ protected function getDraftPath(array $spec, string $slug): string { $service = strtolower($spec['service'] ?? 'general'); $category = strtolower($spec['category'] ?? 'general'); // Map service to folder $serviceFolder = match ($service) { 'host link', 'host bio' => 'bio', 'host social' => 'social', 'host analytics' => 'analytics', 'host trust' => 'trust', 'host notify' => 'notify', default => 'general', }; // Map category to subfolder $categoryFolder = match (true) { str_contains($category, 'getting started') => 'getting-started', str_contains($category, 'blog') => 'blog', str_contains($category, 'api') => 'api', str_contains($category, 'integration') => 'integrations', default => str_replace(' ', '-', $category), }; return base_path("{$this->draftsPath}/help/{$categoryFolder}/{$slug}.md"); } /** * Save a draft to file. */ protected function saveDraft(string $path, string $content, array $article): void { $dir = dirname($path); if (! File::isDirectory($dir)) { File::makeDirectory($dir, 0755, true); } // Add frontmatter $frontmatter = $this->buildFrontmatter($article); $fullContent = "---\n{$frontmatter}---\n\n{$content}"; File::put($path, $fullContent); } /** * Build YAML frontmatter for a draft. */ protected function buildFrontmatter(array $article): string { $meta = [ 'title' => $article['title'] ?? '', 'slug' => $article['slug'] ?? '', 'status' => 'draft', 'difficulty' => $article['difficulty'] ?? 'beginner', 'reading_time' => $article['reading_time'] ?? 5, 'primary_keyword' => $article['primary_keyword'] ?? '', 'generated_at' => now()->toIso8601String(), ]; return Yaml::dump($meta); } }