Files
SkinbaseNova/.deploy/artwork-evolution-release/app/Services/Maturity/ArtworkMaturityService.php
2026-04-18 17:02:56 +02:00

562 lines
22 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Maturity;
use App\Models\Artwork;
use App\Models\User;
use App\Services\ArtworkSearchIndexer;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
final class ArtworkMaturityService
{
public const LEVEL_SAFE = 'safe';
public const LEVEL_MATURE = 'mature';
public const SOURCE_AI = 'ai';
public const SOURCE_LEGACY = 'legacy';
public const SOURCE_MODERATOR = 'moderator';
public const SOURCE_USER = 'user';
public const STATUS_CLEAR = 'clear';
public const STATUS_DECLARED = 'declared';
public const STATUS_REVIEWED = 'reviewed';
public const STATUS_SUSPECTED = 'suspected';
public const AI_ACTION_SAFE = 'safe';
public const AI_ACTION_ALLOW = self::AI_ACTION_SAFE;
public const AI_ACTION_REVIEW = 'review';
public const AI_ACTION_FLAG_HIGH = 'flag_high';
public const AI_STATUS_FAILED = 'failed';
public const AI_STATUS_NOT_REQUESTED = 'not_requested';
public const AI_STATUS_PENDING = 'pending';
public const AI_STATUS_SKIPPED = 'skipped';
public const AI_STATUS_SUCCEEDED = 'succeeded';
public const VIEW_BLUR = 'blur';
public const VIEW_HIDE = 'hide';
public const VIEW_SHOW = 'show';
/**
* @var array<int, array{visibility:string,warn_on_detail:bool,is_guest:bool}>
*/
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<string, mixed>
*/
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<string, mixed> $payload
* @return array<string, mixed>
*/
public function decoratePayload(array $payload, mixed $artwork, ?User $viewer): array
{
$payload['maturity'] = $this->presentation($artwork, $viewer);
return $payload;
}
/**
* @param array<int, array<string, mixed>> $items
* @return array<int, array<string, mixed>>
*/
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<string, mixed> $analysis
* @return array{score:float,labels:array<int,string>,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<string, mixed> $analysis
* @return array{score:float,labels:array<int,string>,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<string, mixed> $analysis
* @return array{status:string,maturity_label:?string,confidence:?float,labels:array<int,string>,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<int,string>,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<string, mixed> $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<string, mixed> $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<int, mixed> $rows
* @return array<int, string>
*/
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<int, string>
*/
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<string, mixed> $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;
}
}