2026-01-27 00:28:29 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
2026-01-27 16:12:58 +00:00
|
|
|
namespace Core\Mod\Agentic\Services;
|
2026-01-27 00:28:29 +00:00
|
|
|
|
|
|
|
|
use Mod\Content\Models\ContentItem;
|
|
|
|
|
use Illuminate\Support\Facades\File;
|
|
|
|
|
use Symfony\Component\Yaml\Yaml;
|
|
|
|
|
|
|
|
|
|
class ContentService
|
|
|
|
|
{
|
|
|
|
|
protected string $batchPath;
|
|
|
|
|
|
|
|
|
|
protected string $promptPath;
|
|
|
|
|
|
|
|
|
|
protected string $draftsPath;
|
|
|
|
|
|
|
|
|
|
public function __construct(
|
|
|
|
|
protected AgenticManager $ai
|
|
|
|
|
) {
|
|
|
|
|
$this->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);
|
|
|
|
|
}
|
|
|
|
|
}
|