wip: article AI polish, category SEO fields, cover generator, membership plan seeder
This commit is contained in:
@@ -4,41 +4,48 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Ai\Jobs;
|
||||
|
||||
use App\Domain\Media\ArticleCoverService;
|
||||
use App\Models\Article;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Phase-2 stub: auto-pick or generate a cover image for an article.
|
||||
* Dispatch later from import scripts / artisan batch; process via Workerman/queue.
|
||||
* Auto-pick a cover from article body embeds / attachments.
|
||||
* `generate` strategy is reserved for future text-to-image.
|
||||
*/
|
||||
class GenerateArticleCoverJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public int $articleId,
|
||||
public string $strategy = 'auto', // auto|from_content|generate
|
||||
public string $strategy = 'auto', // auto|from_content|attachment|generate
|
||||
) {
|
||||
$this->onQueue('ai-content');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
public function handle(ArticleCoverService $covers): void
|
||||
{
|
||||
$article = Article::query()->find($this->articleId);
|
||||
|
||||
if ($article === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Intentionally unimplemented in phase 1.
|
||||
Log::info('GenerateArticleCoverJob stub skipped', [
|
||||
'article_id' => $article->id,
|
||||
'strategy' => $this->strategy,
|
||||
'cover_status' => $article->cover_status,
|
||||
]);
|
||||
try {
|
||||
$covers->apply($article, $this->strategy);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('GenerateArticleCoverJob failed.', [
|
||||
'article_id' => $this->articleId,
|
||||
'strategy' => $this->strategy,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
$article->forceFill([
|
||||
'cover_status' => ArticleCoverService::STATUS_FAILED,
|
||||
'cover_generated_at' => now(),
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,15 +34,27 @@ class OptimizeArticleContentJob implements ShouldQueue
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $llm->complete($article->content, [
|
||||
$result = $llm->complete((string) $article->content, [
|
||||
'title' => $article->title,
|
||||
'article_id' => $article->id,
|
||||
'content_format' => $article->content_format,
|
||||
]);
|
||||
|
||||
$article->forceFill([
|
||||
'ai_summary' => $result['summary'] ?? null,
|
||||
'ai_suggestions' => $result['suggestions'] ?? [],
|
||||
])->save();
|
||||
$updates = [
|
||||
'ai_summary' => filled($result['summary'] ?? null) ? (string) $result['summary'] : $article->ai_summary,
|
||||
'ai_suggestions' => array_values($result['suggestions'] ?? []),
|
||||
];
|
||||
|
||||
if (filled($result['polished_content'] ?? null)) {
|
||||
$updates['ai_polished_content'] = (string) $result['polished_content'];
|
||||
}
|
||||
|
||||
// Fill empty SEO description from the model when the author left it blank.
|
||||
if (blank($article->description) && filled($result['description'] ?? null)) {
|
||||
$updates['description'] = (string) $result['description'];
|
||||
}
|
||||
|
||||
$article->forceFill($updates)->save();
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('Article content optimization failed.', [
|
||||
'article_id' => $this->articleId,
|
||||
|
||||
@@ -17,58 +17,64 @@ class OpenAiCompatibleLlmProvider implements LlmProvider
|
||||
|
||||
public function complete(string $prompt, array $context = []): array
|
||||
{
|
||||
$response = $this->request([
|
||||
'model' => $this->settings->model ?? 'gpt-4o-mini',
|
||||
'messages' => [
|
||||
[
|
||||
'role' => 'system',
|
||||
'content' => 'You optimize blog article content. Respond with JSON containing summary (string) and suggestions (array of strings).',
|
||||
],
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => $prompt,
|
||||
],
|
||||
$title = (string) ($context['title'] ?? '');
|
||||
$format = (string) ($context['content_format'] ?? 'markdown');
|
||||
$formatHint = $format === 'html'
|
||||
? 'Keep valid HTML (no markdown). Preserve existing tags where useful.'
|
||||
: 'Keep Markdown (no raw HTML unless already present). Preserve headings/lists/code fences.';
|
||||
|
||||
$response = $this->chat([
|
||||
[
|
||||
'role' => 'system',
|
||||
'content' => implode("\n", [
|
||||
'You are an editor polishing blog posts for LaraBlog (Chinese and English OK).',
|
||||
'Improve clarity, flow, and grammar without inventing facts or changing the author\'s intent.',
|
||||
$formatHint,
|
||||
'Respond with JSON only (no markdown fences):',
|
||||
'{"summary":"short overview","description":"SEO description <=160 chars","polished_content":"full polished body","suggestions":["tip1","tip2"]}',
|
||||
]),
|
||||
],
|
||||
'response_format' => ['type' => 'json_object'],
|
||||
]);
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => "Title: {$title}\nFormat: {$format}\n\n---\n{$prompt}",
|
||||
],
|
||||
], preferJsonObject: true);
|
||||
|
||||
$content = data_get($response, 'choices.0.message.content');
|
||||
|
||||
if (! is_string($content)) {
|
||||
throw new RuntimeException('LLM completion response missing content.');
|
||||
}
|
||||
|
||||
$decoded = json_decode($content, true);
|
||||
$decoded = is_string($content) ? $this->decodeJsonObject($content) : null;
|
||||
|
||||
if (! is_array($decoded)) {
|
||||
throw new RuntimeException('LLM completion response is not valid JSON.');
|
||||
}
|
||||
|
||||
$suggestions = $decoded['suggestions'] ?? [];
|
||||
if (! is_array($suggestions)) {
|
||||
$suggestions = [];
|
||||
}
|
||||
|
||||
return [
|
||||
'summary' => (string) ($decoded['summary'] ?? ''),
|
||||
'suggestions' => array_values($decoded['suggestions'] ?? []),
|
||||
'description' => (string) ($decoded['description'] ?? ''),
|
||||
'polished_content' => (string) ($decoded['polished_content'] ?? $decoded['content'] ?? ''),
|
||||
'suggestions' => array_values(array_map('strval', $suggestions)),
|
||||
];
|
||||
}
|
||||
|
||||
public function moderate(string $content): array
|
||||
{
|
||||
$response = $this->request([
|
||||
'model' => $this->settings->model ?? 'gpt-4o-mini',
|
||||
'messages' => [
|
||||
[
|
||||
'role' => 'system',
|
||||
'content' => 'Moderate blog comments. Respond with JSON: {"status":"approved|rejected|needs_human","reason":"..."}',
|
||||
],
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => $content,
|
||||
],
|
||||
$response = $this->chat([
|
||||
[
|
||||
'role' => 'system',
|
||||
'content' => 'You moderate Chinese and English blog comments for spam, ads, scams, abuse, and irrelevant promo. Respond with JSON only (no markdown): {"status":"approved|rejected|needs_human","reason":"..."}. Reject clear spam/ads; approve normal discussion; use needs_human when unsure.',
|
||||
],
|
||||
'response_format' => ['type' => 'json_object'],
|
||||
]);
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => $content,
|
||||
],
|
||||
], preferJsonObject: true);
|
||||
|
||||
$payload = data_get($response, 'choices.0.message.content');
|
||||
$decoded = is_string($payload) ? json_decode($payload, true) : null;
|
||||
$decoded = is_string($payload) ? $this->decodeJsonObject($payload) : null;
|
||||
|
||||
if (! is_array($decoded) || ! isset($decoded['status'])) {
|
||||
return [
|
||||
@@ -77,12 +83,41 @@ class OpenAiCompatibleLlmProvider implements LlmProvider
|
||||
];
|
||||
}
|
||||
|
||||
$status = (string) $decoded['status'];
|
||||
if (! in_array($status, ['approved', 'rejected', 'needs_human'], true)) {
|
||||
$status = 'needs_human';
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => (string) $decoded['status'],
|
||||
'status' => $status,
|
||||
'reason' => isset($decoded['reason']) ? (string) $decoded['reason'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{role: string, content: string}> $messages
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function chat(array $messages, bool $preferJsonObject = false): array
|
||||
{
|
||||
$payload = [
|
||||
'model' => $this->settings->model ?? 'gpt-4o-mini',
|
||||
'messages' => $messages,
|
||||
];
|
||||
|
||||
if ($preferJsonObject) {
|
||||
try {
|
||||
return $this->request($payload + [
|
||||
'response_format' => ['type' => 'json_object'],
|
||||
]);
|
||||
} catch (\Throwable) {
|
||||
// Some OpenAI-compatible gateways (e.g. Agnes) reject response_format.
|
||||
}
|
||||
}
|
||||
|
||||
return $this->request($payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
@@ -91,9 +126,9 @@ class OpenAiCompatibleLlmProvider implements LlmProvider
|
||||
{
|
||||
$baseUrl = rtrim($this->settings->api_base_url ?? 'https://api.openai.com/v1', '/');
|
||||
|
||||
$response = Http::withToken($this->settings->api_key ?? '')
|
||||
$response = Http::withToken((string) ($this->settings->api_key ?? ''))
|
||||
->acceptJson()
|
||||
->timeout(60)
|
||||
->timeout(90)
|
||||
->post("{$baseUrl}/chat/completions", $payload)
|
||||
->throw()
|
||||
->json();
|
||||
@@ -104,4 +139,22 @@ class OpenAiCompatibleLlmProvider implements LlmProvider
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
protected function decodeJsonObject(string $content): ?array
|
||||
{
|
||||
$trimmed = trim($content);
|
||||
|
||||
if (preg_match('/```(?:json)?\s*(\{.*?\})\s*```/s', $trimmed, $matches) === 1) {
|
||||
$trimmed = $matches[1];
|
||||
} elseif (preg_match('/\{.*\}/s', $trimmed, $matches) === 1) {
|
||||
$trimmed = $matches[0];
|
||||
}
|
||||
|
||||
$decoded = json_decode($trimmed, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,12 @@ class StubLlmProvider implements LlmProvider
|
||||
{
|
||||
public function complete(string $prompt, array $context = []): array
|
||||
{
|
||||
$title = trim((string) ($context['title'] ?? 'Untitled'));
|
||||
|
||||
return [
|
||||
'summary' => 'Stub summary for content optimization.',
|
||||
'summary' => 'Stub summary for «'.$title.'».',
|
||||
'description' => 'Stub SEO description for '.$title.'.',
|
||||
'polished_content' => trim($prompt)."\n\n<!-- stub polished -->",
|
||||
'suggestions' => [
|
||||
'Review headings for clarity.',
|
||||
'Add a concise meta description.',
|
||||
|
||||
Reference in New Issue
Block a user