Files
daily-meal-api/app/Services/ContentModerationService.php
leonm 8b60283eeb
CI / 🧪 Tests Laravel (push) Successful in 2m34s
CI / 🐳 Build & Push Images (push) Successful in 2m26s
feat: translation codes
2026-08-14 12:46:29 +02:00

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, 'CONTENT_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, 'CONTENT_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, 'CONTENT_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' => ['CONTENT_MODERATION_REJECTED'],
]);
}
}