133 lines
4.0 KiB
PHP
133 lines
4.0 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
|
use Throwable;
|
|
|
|
class ContentModerationService
|
|
{
|
|
/**
|
|
* @param array<int, mixed> $texts
|
|
* @param array<int, mixed> $images
|
|
*/
|
|
public function ensureAcceptable(array $texts = [], array $images = []): void
|
|
{
|
|
$normalizedTexts = collect($texts)
|
|
->flatten()
|
|
->filter(fn (mixed $text): bool => is_string($text) && filled(trim($text)))
|
|
->map(fn (string $text): string => trim($text))
|
|
->values()
|
|
->all();
|
|
$normalizedImages = collect($images)
|
|
->filter(fn (mixed $image): bool => $image instanceof UploadedFile || $this->isRemoteImageUrl($image))
|
|
->values()
|
|
->all();
|
|
|
|
if ($normalizedTexts === [] && $normalizedImages === []) {
|
|
return;
|
|
}
|
|
|
|
if ($this->containsExplicitlyBlockedText($normalizedTexts)) {
|
|
$this->rejectContent();
|
|
}
|
|
|
|
if (! config('moderation.content.enabled')) {
|
|
return;
|
|
}
|
|
|
|
$apiKey = trim((string) config('moderation.content.openai_key'));
|
|
|
|
if ($apiKey === '') {
|
|
throw new HttpException(503, __('api.moderation.unavailable'));
|
|
}
|
|
|
|
$input = [];
|
|
|
|
if ($normalizedTexts !== []) {
|
|
$input[] = [
|
|
'type' => 'text',
|
|
'text' => implode("\n", $normalizedTexts),
|
|
];
|
|
}
|
|
|
|
foreach ($normalizedImages as $image) {
|
|
$input[] = [
|
|
'type' => 'image_url',
|
|
'image_url' => [
|
|
'url' => $image instanceof UploadedFile
|
|
? $this->uploadedFileDataUrl($image)
|
|
: $image,
|
|
],
|
|
];
|
|
}
|
|
|
|
try {
|
|
$response = Http::baseUrl(rtrim((string) config('moderation.content.openai_url'), '/'))
|
|
->withToken($apiKey)
|
|
->acceptJson()
|
|
->timeout((int) config('moderation.content.timeout', 12))
|
|
->retry(2, 250)
|
|
->post('/moderations', [
|
|
'model' => config('moderation.content.model'),
|
|
'input' => $input,
|
|
])
|
|
->throw();
|
|
} catch (Throwable $exception) {
|
|
report($exception);
|
|
|
|
throw new HttpException(503, __('api.moderation.unavailable'), $exception);
|
|
}
|
|
|
|
if (collect($response->json('results', []))->contains(
|
|
fn (mixed $result): bool => is_array($result) && ($result['flagged'] ?? false) === true
|
|
)) {
|
|
$this->rejectContent();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string> $texts
|
|
*/
|
|
private function containsExplicitlyBlockedText(array $texts): bool
|
|
{
|
|
$content = Str::lower(Str::ascii(implode("\n", $texts)));
|
|
|
|
return collect(config('moderation.content.blocked_phrases', []))
|
|
->filter(fn (mixed $phrase): bool => is_string($phrase) && filled($phrase))
|
|
->contains(fn (string $phrase): bool => Str::contains($content, Str::lower(Str::ascii($phrase))));
|
|
}
|
|
|
|
private function uploadedFileDataUrl(UploadedFile $image): string
|
|
{
|
|
$contents = file_get_contents($image->getRealPath());
|
|
|
|
if ($contents === false) {
|
|
throw new HttpException(503, __('api.moderation.unavailable'));
|
|
}
|
|
|
|
return sprintf(
|
|
'data:%s;base64,%s',
|
|
$image->getMimeType() ?: 'application/octet-stream',
|
|
base64_encode($contents),
|
|
);
|
|
}
|
|
|
|
private function isRemoteImageUrl(mixed $image): bool
|
|
{
|
|
return is_string($image)
|
|
&& Str::startsWith($image, ['https://', 'http://']);
|
|
}
|
|
|
|
private function rejectContent(): never
|
|
{
|
|
throw ValidationException::withMessages([
|
|
'content' => [__('api.moderation.rejected')],
|
|
]);
|
|
}
|
|
}
|