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 $messages * @return array */ 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 $payload * @return array */ 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|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; } }