php-agentic/Services/ContentService.php

463 lines
14 KiB
PHP
Raw Permalink Normal View History

2026-01-27 00:28:29 +00:00
<?php
declare(strict_types=1);
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);
}
}