Files
Atomcms-edit/app/Services/ContentModerationService.php
2026-05-09 17:32:17 +02:00

275 lines
8.1 KiB
PHP
Executable File

<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Miscellaneous\WebsiteSetting;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
readonly class ContentModerationService
{
private const int MODERATION_CACHE_DURATION_MINUTES = 5;
private const string SETTINGS_CACHE_KEY = 'content_moderation_settings';
private const int SETTINGS_CACHE_DURATION = 300;
private const string BLOCKED_WORDS_CACHE_KEY = 'blocked_words';
private string $openAiApiKey;
private bool $useOpenAI;
private float $sensitivity;
/** @var array<mixed> */
private array $blockedWords;
public function __construct()
{
$this->openAiApiKey = config('services.openai.api_key', '');
$this->useOpenAI = $this->openAiApiKey !== '';
$this->sensitivity = (float) ($this->getSetting('ai_filter_sensitivity') ?? 0.8);
$this->blockedWords = $this->loadBlockedWords();
}
public function moderate(string $content, string $type = 'general'): array
{
$trimmedContent = trim($content);
if ($trimmedContent === '') {
return [
'approved' => true,
'score' => 0,
'reason' => null,
'detected_words' => [],
];
}
$localResult = $this->localModerate($trimmedContent);
if ($localResult['blocked']) {
return [
'approved' => false,
'score' => 1.0,
'reason' => 'Blocked content detected',
'detected_words' => $localResult['words'],
'method' => 'local',
];
}
if ($this->useOpenAI) {
return $this->openAIModerate($trimmedContent, $type);
}
return [
'approved' => true,
'score' => $localResult['score'],
'reason' => null,
'detected_words' => [],
'method' => 'local',
];
}
private function localModerate(string $content): array
{
$text = strtolower(strip_tags($content));
$detectedWords = array_filter(
$this->blockedWords,
fn (string $word): bool => stripos($text, $word) !== false,
);
if ($detectedWords !== []) {
return [
'blocked' => true,
'words' => array_values($detectedWords),
'score' => 1.0,
];
}
$suspiciousPatterns = [
'/(.)\1{4,}/i',
'/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/',
'/\b(\w*?[\x27\`]+\w*?)+\b/i',
];
$patternScore = array_reduce(
$suspiciousPatterns,
fn (float $score, string $pattern): float => preg_match($pattern, $text) ? $score + 0.3 : $score,
0.0,
);
return [
'blocked' => $patternScore >= $this->sensitivity,
'words' => [],
'score' => min($patternScore, 1.0),
];
}
private function openAIModerate(string $content, string $type): array
{
$cacheKey = 'moderation_' . md5($content . $type);
return Cache::remember($cacheKey, now()->addMinutes(self::MODERATION_CACHE_DURATION_MINUTES), function () use ($content): array {
try {
$response = Http::withHeaders([
'Authorization' => 'Bearer ' . $this->openAiApiKey,
'Content-Type' => 'application/json',
])->post('https://api.openai.com/v1/moderations', [
'input' => $content,
]);
if ($response->successful()) {
$result = $response->json();
$moderationResult = $result['results'][0];
$categories = $moderationResult['categories'];
$categoryScores = $moderationResult['category_scores'];
$maxScore = 0.0;
$triggeredCategories = [];
foreach ($categories as $category => $flagged) {
$categoryScore = $categoryScores[$category] ?? 0;
if ($flagged || $categoryScore > $this->sensitivity) {
$triggeredCategories[] = $category;
$maxScore = max($maxScore, $categoryScore);
}
}
$approved = $triggeredCategories === [] || $maxScore < $this->sensitivity;
return [
'approved' => $approved,
'score' => $maxScore,
'reason' => $approved ? null : implode(', ', $triggeredCategories),
'detected_words' => $moderationResult['flagged_tokens'] ?? [],
'method' => 'openai',
'categories' => $triggeredCategories,
];
}
} catch (\Exception $e) {
report($e);
}
return [
'approved' => true,
'score' => 0,
'reason' => null,
'detected_words' => [],
'method' => 'fallback',
];
});
}
public function addBlockedWord(string $word): bool
{
$cleanWord = strtolower(trim($word));
$key = 'filter_word_' . $cleanWord;
WebsiteSetting::updateOrCreate(
['key' => $key],
['value' => '1', 'comment' => 'Blocked word: ' . $word],
);
Cache::forget(self::BLOCKED_WORDS_CACHE_KEY);
return true;
}
public function removeBlockedWord(string $word): bool
{
$cleanWord = strtolower(trim($word));
$key = 'filter_word_' . $cleanWord;
WebsiteSetting::where('key', $key)->delete();
Cache::forget(self::BLOCKED_WORDS_CACHE_KEY);
return true;
}
/**
* @return list<string>
*/
public function getBlockedWords(): array
{
return $this->blockedWords;
}
public function setSensitivity(float $sensitivity): void
{
$newSensitivity = max(0, min(1, $sensitivity));
WebsiteSetting::updateOrCreate(
['key' => 'ai_filter_sensitivity'],
['value' => (string) $newSensitivity, 'comment' => 'AI filter sensitivity (0-1)'],
);
Cache::forget(self::SETTINGS_CACHE_KEY);
CacheService::clearContentModeration();
}
public function getSensitivity(): float
{
return $this->sensitivity;
}
public function isUsingOpenAI(): bool
{
return $this->useOpenAI;
}
public function isEnabled(): bool
{
return (bool) $this->getSetting('ai_filter_enabled');
}
public function shouldAutoReject(): bool
{
return (bool) $this->getSetting('ai_filter_auto_reject');
}
public function getStats(): array
{
return [
'enabled' => $this->isEnabled(),
'auto_reject' => $this->shouldAutoReject(),
'method' => $this->useOpenAI ? 'openai' : 'local',
'sensitivity' => $this->sensitivity,
'blocked_words_count' => count($this->blockedWords),
];
}
/**
* @return list<string>
*/
private function loadBlockedWords(): array
{
return Cache::remember(self::BLOCKED_WORDS_CACHE_KEY, self::SETTINGS_CACHE_DURATION, fn (): array => WebsiteSetting::where('key', 'like', 'filter_word_%')
->where('value', '1')
->pluck('key')
->map(fn (string $key): string => str_replace('filter_word_', '', $key))
->values()
->toArray());
}
private function getSetting(string $key): ?string
{
return $this->getSettings()[$key] ?? null;
}
/**
* @return array<string, string|null>
*/
private function getSettings(): array
{
return Cache::remember(self::SETTINGS_CACHE_KEY, self::SETTINGS_CACHE_DURATION, fn (): array => WebsiteSetting::whereIn('key', [
'ai_filter_sensitivity',
'ai_filter_enabled',
'ai_filter_auto_reject',
])->pluck('value', 'key')->toArray());
}
}