*/ private array $viewerPreferenceCache = []; public function __construct(private readonly ArtworkSearchIndexer $searchIndexer) { } /** * @return array{visibility:string,warn_on_detail:bool,is_guest:bool} */ public function viewerPreferences(?User $viewer): array { $defaultMode = $this->normalizeVisibilityPreference((string) config('maturity.viewer.default_mode', self::VIEW_BLUR)); $defaultWarnOnDetail = (bool) config('maturity.viewer.default_warn_on_detail', true); if (! $viewer) { return [ 'visibility' => $defaultMode, 'warn_on_detail' => $defaultWarnOnDetail, 'is_guest' => true, ]; } $viewerId = (int) $viewer->id; if (isset($this->viewerPreferenceCache[$viewerId])) { return $this->viewerPreferenceCache[$viewerId]; } $resolved = [ 'visibility' => $defaultMode, 'warn_on_detail' => $defaultWarnOnDetail, 'is_guest' => false, ]; if (Schema::hasTable('user_profiles')) { $row = DB::table('user_profiles') ->where('user_id', $viewerId) ->first(['mature_content_visibility', 'mature_content_warning_enabled']); if ($row !== null) { $resolved['visibility'] = $this->normalizeVisibilityPreference((string) ($row->mature_content_visibility ?? $defaultMode)); $resolved['warn_on_detail'] = array_key_exists('mature_content_warning_enabled', (array) $row) ? (bool) $row->mature_content_warning_enabled : $defaultWarnOnDetail; } } return $this->viewerPreferenceCache[$viewerId] = $resolved; } public function applyViewerFilter(Builder $query, ?User $viewer): Builder { if ($this->viewerPreferences($viewer)['visibility'] !== self::VIEW_HIDE) { return $query; } $table = $query->getModel()->getTable(); return $query ->whereRaw('COALESCE(' . $table . '.is_mature, 0) = 0') ->whereRaw("COALESCE(" . $table . ".maturity_status, '" . self::STATUS_CLEAR . "') != ?", [self::STATUS_SUSPECTED]); } public function appendSearchFilter(string $filter, ?User $viewer): string { $filter = trim($filter); if ($this->viewerPreferences($viewer)['visibility'] !== self::VIEW_HIDE) { return $filter; } $hideClause = 'is_mature_effective = false'; if ($filter === '') { return $hideClause; } return $filter . ' AND ' . $hideClause; } public function effectiveIsMature(mixed $artwork): bool { $level = Str::lower((string) $this->value($artwork, 'maturity_level', self::LEVEL_SAFE)); $status = Str::lower((string) $this->value($artwork, 'maturity_status', self::STATUS_CLEAR)); $isMature = (bool) $this->value($artwork, 'is_mature', false); return $isMature || $level === self::LEVEL_MATURE || $status === self::STATUS_SUSPECTED; } /** * @return array */ public function presentation(mixed $artwork, ?User $viewer): array { $preferences = $this->viewerPreferences($viewer); $effectiveIsMature = $this->effectiveIsMature($artwork); $visibilityMode = $preferences['visibility']; $shouldHide = $effectiveIsMature && $visibilityMode === self::VIEW_HIDE; $shouldBlur = $effectiveIsMature && ! $shouldHide && $visibilityMode !== self::VIEW_SHOW; $requiresInterstitial = $effectiveIsMature && (bool) $preferences['warn_on_detail']; $status = Str::lower((string) $this->value($artwork, 'maturity_status', self::STATUS_CLEAR)); $labels = $this->normalizeLabels($this->value($artwork, 'maturity_ai_labels', [])); return [ 'effective_level' => $effectiveIsMature ? self::LEVEL_MATURE : self::LEVEL_SAFE, 'level' => Str::lower((string) $this->value($artwork, 'maturity_level', self::LEVEL_SAFE)), 'source' => Str::lower((string) $this->value($artwork, 'maturity_source', self::SOURCE_LEGACY)), 'status' => $status, 'is_mature' => (bool) $this->value($artwork, 'is_mature', false), 'is_mature_effective' => $effectiveIsMature, 'ai_score' => $this->normalizeScore($this->value($artwork, 'maturity_ai_score')), 'ai_confidence' => $this->normalizeScore($this->value($artwork, 'maturity_ai_confidence', $this->value($artwork, 'maturity_ai_score'))), 'ai_label' => Str::lower((string) $this->value($artwork, 'maturity_ai_label', '')) ?: null, 'ai_labels' => $labels, 'ai_status' => Str::lower((string) $this->value($artwork, 'maturity_ai_status', self::AI_STATUS_NOT_REQUESTED)), 'ai_action_hint' => Str::lower((string) $this->value($artwork, 'maturity_ai_action_hint', '')) ?: null, 'ai_model' => $this->value($artwork, 'maturity_ai_model'), 'ai_threshold_used' => $this->normalizeScore($this->value($artwork, 'maturity_ai_threshold_used')), 'ai_analysis_time_ms' => is_numeric($this->value($artwork, 'maturity_ai_analysis_time_ms')) ? (int) $this->value($artwork, 'maturity_ai_analysis_time_ms') : null, 'ai_advisory' => $this->value($artwork, 'maturity_ai_advisory'), 'flag_reason' => $this->value($artwork, 'maturity_flag_reason'), 'is_flagged' => $status === self::STATUS_SUSPECTED, 'should_hide' => $shouldHide, 'should_blur' => $shouldBlur, 'requires_interstitial' => $requiresInterstitial, 'viewer_preference' => $visibilityMode, 'warning_title' => $effectiveIsMature ? 'Mature content warning' : null, 'warning_message' => $effectiveIsMature ? 'This artwork may contain mature material. Continue only if you want to view it.' : null, ]; } /** * @param array $payload * @return array */ public function decoratePayload(array $payload, mixed $artwork, ?User $viewer): array { $payload['maturity'] = $this->presentation($artwork, $viewer); return $payload; } /** * @param array> $items * @return array> */ public function filterPayloadItems(array $items, ?User $viewer): array { return array_values(array_filter($items, function (array $item) use ($viewer): bool { $maturity = Arr::get($item, 'maturity'); if (! is_array($maturity)) { return true; } return ! (bool) ($maturity['should_hide'] ?? false); })); } public function applyUploaderDeclaration(Artwork $artwork, bool $isMature): Artwork { $artwork->forceFill([ 'is_mature' => $isMature, 'maturity_level' => $isMature ? self::LEVEL_MATURE : self::LEVEL_SAFE, 'maturity_source' => self::SOURCE_USER, 'maturity_status' => $isMature ? self::STATUS_DECLARED : self::STATUS_CLEAR, 'maturity_declared_at' => now(), 'maturity_flagged_at' => $isMature ? $artwork->maturity_flagged_at : null, 'maturity_flag_reason' => $isMature ? $artwork->maturity_flag_reason : null, ])->saveQuietly(); $this->searchIndexer->update($artwork); return $artwork; } /** * @param array $analysis * @return array{score:float,labels:array,flagged:bool} */ public function applyAiAssessment(Artwork $artwork, array $analysis): array { $assessment = $this->normalizeAiAssessment($analysis); $labels = $assessment['labels']; $aiStatus = $assessment['status']; $flagged = $this->shouldFlagAssessment($artwork, $assessment); $existingMismatchCount = (int) ($artwork->maturity_mismatch_count ?? 0); $payload = [ 'maturity_ai_score' => $assessment['confidence'], 'maturity_ai_labels' => $labels === [] ? null : $labels, 'maturity_ai_label' => $assessment['maturity_label'], 'maturity_ai_confidence' => $assessment['confidence'], 'maturity_ai_model' => $assessment['model'], 'maturity_ai_threshold_used' => $assessment['threshold_used'], 'maturity_ai_analysis_time_ms' => $assessment['analysis_time_ms'], 'maturity_ai_action_hint' => $assessment['action_hint'], 'maturity_ai_advisory' => $assessment['advisory'], 'maturity_ai_status' => $aiStatus, 'maturity_ai_detected_at' => now(), ]; if ($aiStatus !== self::AI_STATUS_SUCCEEDED) { $artwork->forceFill($payload)->saveQuietly(); $this->searchIndexer->update($artwork); return $assessment; } $artwork->forceFill(array_merge($payload, [ 'maturity_status' => $flagged ? self::STATUS_SUSPECTED : ($artwork->is_mature ? self::STATUS_DECLARED : ($artwork->maturity_status ?: self::STATUS_CLEAR)), 'maturity_flagged_at' => $flagged ? now() : $artwork->maturity_flagged_at, 'maturity_flag_reason' => $flagged ? $this->buildAiFlagReason($assessment) : $artwork->maturity_flag_reason, 'maturity_mismatch_count' => $flagged ? $existingMismatchCount + 1 : $existingMismatchCount, ]))->saveQuietly(); $this->searchIndexer->update($artwork); return $assessment; } public function review(Artwork $artwork, string $action, Authenticatable $moderator, ?string $note = null): Artwork { $normalizedAction = Str::lower(trim($action)); $isMature = $this->effectiveIsMature($artwork); $moderatorId = (int) $moderator->getAuthIdentifier(); if ($normalizedAction === 'mark_safe') { $isMature = false; } if (in_array($normalizedAction, ['mark_mature', 'confirm'], true)) { $isMature = true; } if ($normalizedAction === 'confirm_current') { $isMature = $this->effectiveIsMature($artwork); } $artwork->forceFill([ 'is_mature' => $isMature, 'maturity_level' => $isMature ? self::LEVEL_MATURE : self::LEVEL_SAFE, 'maturity_source' => self::SOURCE_MODERATOR, 'maturity_status' => self::STATUS_REVIEWED, 'maturity_declared_at' => $isMature ? ($artwork->maturity_declared_at ?: now()) : $artwork->maturity_declared_at, 'maturity_reviewed_by' => $moderatorId, 'maturity_reviewed_at' => now(), 'maturity_reviewer_note' => $note, 'maturity_flag_reason' => $note ?: $artwork->maturity_flag_reason, ])->saveQuietly(); $this->searchIndexer->update($artwork); return $artwork->fresh(); } /** * @param array $analysis * @return array{score:float,labels:array,flagged:bool} */ public function assessAnalysis(array $analysis): array { $labels = []; $score = 0.0; $strong = collect((array) config('maturity.ai.strong_keywords', [])) ->map(static fn (mixed $keyword): string => Str::lower(trim((string) $keyword))) ->filter() ->values() ->all(); $medium = collect((array) config('maturity.ai.medium_keywords', [])) ->map(static fn (mixed $keyword): string => Str::lower(trim((string) $keyword))) ->filter() ->values() ->all(); $fragments = collect(array_merge( $this->analysisTextFragments($analysis['clip_tags'] ?? []), $this->analysisTextFragments($analysis['yolo_objects'] ?? []), [Str::lower(trim((string) ($analysis['blip_caption'] ?? '')))] )) ->filter() ->unique() ->values(); foreach ($fragments as $fragment) { foreach ($strong as $keyword) { if (Str::contains($fragment, $keyword)) { $score += 0.42; $labels[] = $keyword; } } foreach ($medium as $keyword) { if (Str::contains($fragment, $keyword)) { $score += 0.18; $labels[] = $keyword; } } } $labels = array_values(array_unique($labels)); $score = min(1.0, round($score, 4)); return [ 'confidence' => $score, 'score' => $score, 'labels' => $labels, 'flagged' => $score >= (float) config('maturity.ai.threshold', 0.68), 'status' => self::AI_STATUS_SUCCEEDED, 'maturity_label' => $score >= (float) config('maturity.ai.threshold', 0.68) ? self::LEVEL_MATURE : self::LEVEL_SAFE, 'action_hint' => $score >= (float) config('maturity.ai.threshold', 0.68) ? self::AI_ACTION_REVIEW : self::AI_ACTION_SAFE, 'model' => null, 'threshold_used' => (float) config('maturity.ai.threshold', 0.68), 'analysis_time_ms' => null, 'advisory' => null, ]; } /** * @param array $analysis * @return array{status:string,maturity_label:?string,confidence:?float,labels:array,action_hint:?string,model:?string,threshold_used:?float,analysis_time_ms:?int,advisory:?string,flagged:bool,score:?float} */ private function normalizeAiAssessment(array $analysis): array { if (! $this->looksLikeNormalizedAssessment($analysis)) { /** @var array{status:string,maturity_label:?string,confidence:?float,labels:array,action_hint:?string,model:?string,threshold_used:?float,analysis_time_ms:?int,advisory:?string,flagged:bool,score:?float} $legacy */ $legacy = $this->assessAnalysis($analysis); return $legacy; } $status = $this->normalizeAiStatus($analysis['status'] ?? null); $label = $this->normalizeAiLabel($analysis['maturity_label'] ?? ($analysis['label'] ?? null)); $confidence = $this->normalizeScore($analysis['confidence'] ?? ($analysis['score'] ?? null)); $labels = $this->normalizeLabels($analysis['labels'] ?? ($analysis['maturity_ai_labels'] ?? [])); $actionHint = $this->normalizeAiActionHint($analysis['action_hint'] ?? null); $model = is_scalar($analysis['model'] ?? null) ? trim((string) $analysis['model']) : null; $thresholdUsed = $this->normalizeScore($analysis['threshold_used'] ?? null); $analysisTime = is_numeric($analysis['analysis_time_ms'] ?? null) ? (int) $analysis['analysis_time_ms'] : null; $advisory = is_scalar($analysis['advisory'] ?? null) ? trim((string) $analysis['advisory']) : null; if ($labels === [] && $label !== null) { $labels[] = $label; } $flagged = $status === self::AI_STATUS_SUCCEEDED && in_array($actionHint, [self::AI_ACTION_REVIEW, self::AI_ACTION_FLAG_HIGH], true); return [ 'status' => $status, 'maturity_label' => $label, 'confidence' => $confidence, 'labels' => $labels, 'action_hint' => $actionHint, 'model' => $model !== '' ? $model : null, 'threshold_used' => $thresholdUsed, 'analysis_time_ms' => $analysisTime, 'advisory' => $advisory !== '' ? $advisory : null, 'flagged' => $flagged, 'score' => $confidence, ]; } /** * @param array $assessment */ private function shouldFlagAssessment(Artwork $artwork, array $assessment): bool { if (($assessment['status'] ?? self::AI_STATUS_FAILED) !== self::AI_STATUS_SUCCEEDED) { return false; } if ((bool) $artwork->is_mature) { return false; } return (bool) ($assessment['flagged'] ?? false) || in_array($assessment['action_hint'] ?? null, [self::AI_ACTION_REVIEW, self::AI_ACTION_FLAG_HIGH], true) || (($assessment['maturity_label'] ?? null) === self::LEVEL_MATURE); } /** * @param array $assessment */ private function buildAiFlagReason(array $assessment): string { $labels = array_slice($this->normalizeLabels($assessment['labels'] ?? []), 0, 5); $action = $this->normalizeAiActionHint($assessment['action_hint'] ?? null); $prefix = match ($action) { self::AI_ACTION_FLAG_HIGH => 'AI flagged high-confidence mature content', self::AI_ACTION_REVIEW => 'AI requested moderation review for mature content', default => 'AI suspected mature content', }; if ($labels === []) { return $prefix . '.'; } return $prefix . ' from: ' . implode(', ', $labels); } /** * @param array $rows * @return array */ private function analysisTextFragments(array $rows): array { return collect($rows) ->map(function (mixed $row): string { if (is_array($row)) { return Str::lower(trim((string) ($row['tag'] ?? $row['label'] ?? $row['name'] ?? ''))); } return Str::lower(trim((string) $row)); }) ->filter() ->values() ->all(); } private function normalizeVisibilityPreference(string $value): string { return match (Str::lower(trim($value))) { self::VIEW_HIDE => self::VIEW_HIDE, self::VIEW_SHOW => self::VIEW_SHOW, default => self::VIEW_BLUR, }; } /** * @return array */ private function normalizeLabels(mixed $labels): array { if (! is_array($labels)) { return []; } return collect($labels) ->map(static fn (mixed $label): string => trim((string) $label)) ->filter() ->values() ->all(); } private function normalizeScore(mixed $value): ?float { return is_numeric($value) ? round((float) $value, 4) : null; } /** * @param array $analysis */ private function looksLikeNormalizedAssessment(array $analysis): bool { return array_key_exists('maturity_label', $analysis) || array_key_exists('action_hint', $analysis) || array_key_exists('status', $analysis) || array_key_exists('threshold_used', $analysis) || array_key_exists('analysis_time_ms', $analysis); } private function normalizeAiStatus(mixed $value): string { return match (Str::lower(trim((string) $value))) { self::AI_STATUS_PENDING => self::AI_STATUS_PENDING, self::AI_STATUS_SKIPPED => self::AI_STATUS_SKIPPED, self::AI_STATUS_SUCCEEDED => self::AI_STATUS_SUCCEEDED, self::AI_STATUS_NOT_REQUESTED => self::AI_STATUS_NOT_REQUESTED, default => self::AI_STATUS_FAILED, }; } private function normalizeAiLabel(mixed $value): ?string { return match (Str::lower(trim((string) $value))) { self::LEVEL_SAFE => self::LEVEL_SAFE, self::LEVEL_MATURE => self::LEVEL_MATURE, 'adult', 'explicit', 'nsfw' => self::LEVEL_MATURE, 'clear', 'sfw' => self::LEVEL_SAFE, default => null, }; } private function normalizeAiActionHint(mixed $value): ?string { return match (Str::lower(trim((string) $value))) { self::AI_ACTION_SAFE, self::AI_ACTION_ALLOW, 'mark_safe', 'allow' => self::AI_ACTION_SAFE, self::AI_ACTION_REVIEW, 'queue', 'suspect' => self::AI_ACTION_REVIEW, self::AI_ACTION_FLAG_HIGH, 'block', 'mark_mature', 'mature' => self::AI_ACTION_FLAG_HIGH, default => null, }; } private function value(mixed $artwork, string $key, mixed $default = null): mixed { if ($artwork instanceof Artwork) { return $artwork->getAttribute($key) ?? $default; } if (is_array($artwork)) { return $artwork[$key] ?? $default; } if (! is_object($artwork)) { return $default; } return $artwork->{$key} ?? $default; } }