161 lines
5.4 KiB
PHP
161 lines
5.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Domain\Ai;
|
|
|
|
use App\Contracts\LlmProvider;
|
|
use App\Settings\AiSettings;
|
|
use Illuminate\Support\Facades\Http;
|
|
use RuntimeException;
|
|
|
|
class OpenAiCompatibleLlmProvider implements LlmProvider
|
|
{
|
|
public function __construct(
|
|
protected AiSettings $settings,
|
|
) {}
|
|
|
|
public function complete(string $prompt, array $context = []): array
|
|
{
|
|
$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"]}',
|
|
]),
|
|
],
|
|
[
|
|
'role' => 'user',
|
|
'content' => "Title: {$title}\nFormat: {$format}\n\n---\n{$prompt}",
|
|
],
|
|
], preferJsonObject: true);
|
|
|
|
$content = data_get($response, 'choices.0.message.content');
|
|
$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'] ?? ''),
|
|
'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->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.',
|
|
],
|
|
[
|
|
'role' => 'user',
|
|
'content' => $content,
|
|
],
|
|
], preferJsonObject: true);
|
|
|
|
$payload = data_get($response, 'choices.0.message.content');
|
|
$decoded = is_string($payload) ? $this->decodeJsonObject($payload) : null;
|
|
|
|
if (! is_array($decoded) || ! isset($decoded['status'])) {
|
|
return [
|
|
'status' => 'needs_human',
|
|
'reason' => 'Unable to parse moderation response.',
|
|
];
|
|
}
|
|
|
|
$status = (string) $decoded['status'];
|
|
if (! in_array($status, ['approved', 'rejected', 'needs_human'], true)) {
|
|
$status = 'needs_human';
|
|
}
|
|
|
|
return [
|
|
'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>
|
|
*/
|
|
protected function request(array $payload): array
|
|
{
|
|
$baseUrl = rtrim($this->settings->api_base_url ?? 'https://api.openai.com/v1', '/');
|
|
|
|
$response = Http::withToken((string) ($this->settings->api_key ?? ''))
|
|
->acceptJson()
|
|
->timeout(90)
|
|
->post("{$baseUrl}/chat/completions", $payload)
|
|
->throw()
|
|
->json();
|
|
|
|
if (! is_array($response)) {
|
|
throw new RuntimeException('LLM provider returned an invalid response.');
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|