targets(); $limit = max(0, (int) ($this->option('limit') ?? 0)); $remaining = $limit > 0 ? $limit : null; $counts = [ 'scanned' => 0, 'flagged' => 0, 'created' => 0, 'updated' => 0, 'skipped' => 0, 'clean' => 0, 'auto_hidden' => 0, ]; $this->announceScanStart($targets, $limit); foreach ($targets as $target) { if ($remaining !== null && $remaining <= 0) { $this->comment('Scan limit reached. Stopping before the next content target.'); break; } $counts = $this->scanTarget($target, $counts, $remaining); } $this->table(['Metric', 'Count'], [ ['Scanned', $counts['scanned']], ['Flagged', $counts['flagged']], ['Created', $counts['created']], ['Updated', $counts['updated']], ['Auto-hidden', $counts['auto_hidden']], ['Clean', $counts['clean']], ['Skipped', $counts['skipped']], ]); Log::info('Content moderation scan complete.', [ 'targets' => array_map(static fn (ModerationContentType $target): string => $target->value, $targets), 'limit' => $limit > 0 ? $limit : null, 'from_id' => max(0, (int) ($this->option('from-id') ?? 0)) ?: null, 'force' => (bool) $this->option('force'), 'dry_run' => (bool) $this->option('dry-run'), 'counts' => $counts, ]); $this->info('Content moderation scan complete.'); return self::SUCCESS; } /** * @param array $counts * @return array */ private function scanTarget(ModerationContentType $target, array $counts, ?int &$remaining): array { $before = $counts; $this->info('Scanning ' . $target->label() . ' entries...'); $query = match ($target) { ModerationContentType::ArtworkComment, ModerationContentType::ArtworkDescription, ModerationContentType::ArtworkTitle, ModerationContentType::UserBio, ModerationContentType::UserProfileLink, ModerationContentType::CollectionTitle, ModerationContentType::CollectionDescription, ModerationContentType::StoryTitle, ModerationContentType::StoryContent, ModerationContentType::CardTitle, ModerationContentType::CardText => $this->sources->queryForType($target), }; $fromId = max(0, (int) ($this->option('from-id') ?? 0)); if ($fromId > 0) { $query->where('id', '>=', $fromId); } $query->chunkById(200, function ($rows) use ($target, &$counts, &$remaining): bool { foreach ($rows as $row) { if ($remaining !== null && $remaining <= 0) { return false; } $context = $this->sources->buildContext($target, $row); $snapshot = (string) ($context['content_snapshot'] ?? ''); $sourceId = (int) ($context['content_id'] ?? 0); if ($snapshot === '') { $counts['skipped']++; $this->verboseLine($target, $sourceId, 'skipped empty snapshot'); continue; } $analysis = $this->moderation->analyze($snapshot, $context); $counts['scanned']++; if (! $this->option('force') && ! $this->option('dry-run') && $this->persistence->hasCurrentFinding( (string) $context['content_type'], (int) $context['content_id'], $analysis->contentHash, $analysis->scannerVersion, )) { $counts['skipped']++; $this->verboseLine($target, $sourceId, 'skipped unchanged content'); $remaining = $remaining !== null ? $remaining - 1 : null; continue; } if ($this->option('dry-run')) { if ($analysis->status === ModerationStatus::Pending) { $counts['flagged']++; $this->verboseAnalysis($target, $sourceId, $analysis, 'dry-run flagged'); } else { $counts['clean']++; $this->verboseLine($target, $sourceId, 'dry-run clean'); } $remaining = $remaining !== null ? $remaining - 1 : null; continue; } $result = $this->processing->process($snapshot, $context, true); if ($analysis->status !== ModerationStatus::Pending) { $counts['clean']++; if ($result['updated']) { $counts['updated']++; } $this->verboseLine($target, $sourceId, $result['updated'] ? 'clean, existing finding updated' : 'clean'); $remaining = $remaining !== null ? $remaining - 1 : null; continue; } $counts['flagged']++; if ($result['created']) { $counts['created']++; } elseif ($result['updated']) { $counts['updated']++; } if ($result['auto_hidden']) { $counts['auto_hidden']++; } $outcome = $result['created'] ? 'flagged, finding created' : ($result['updated'] ? 'flagged, finding updated' : 'flagged'); if ($result['auto_hidden']) { $outcome .= ', auto-hidden'; } $this->verboseAnalysis($target, $sourceId, $analysis, $outcome); $remaining = $remaining !== null ? $remaining - 1 : null; } return true; }, 'id'); $targetCounts = [ 'scanned' => $counts['scanned'] - $before['scanned'], 'flagged' => $counts['flagged'] - $before['flagged'], 'created' => $counts['created'] - $before['created'], 'updated' => $counts['updated'] - $before['updated'], 'auto_hidden' => $counts['auto_hidden'] - $before['auto_hidden'], 'clean' => $counts['clean'] - $before['clean'], 'skipped' => $counts['skipped'] - $before['skipped'], ]; $this->line(sprintf( 'Finished %s: scanned=%d, flagged=%d, created=%d, updated=%d, auto-hidden=%d, clean=%d, skipped=%d', $target->label(), $targetCounts['scanned'], $targetCounts['flagged'], $targetCounts['created'], $targetCounts['updated'], $targetCounts['auto_hidden'], $targetCounts['clean'], $targetCounts['skipped'], )); return $counts; } /** * @param array $targets */ private function announceScanStart(array $targets, int $limit): void { $this->info('Starting content moderation scan...'); $this->line('Targets: ' . implode(', ', array_map(static fn (ModerationContentType $target): string => $target->label(), $targets))); $this->line('Mode: ' . ($this->option('dry-run') ? 'dry-run' : 'persist findings')); $this->line('Force re-scan: ' . ($this->option('force') ? 'yes' : 'no')); $this->line('From source ID: ' . (max(0, (int) ($this->option('from-id') ?? 0)) ?: 'start')); $this->line('Limit: ' . ($limit > 0 ? (string) $limit : 'none')); if ($this->output->isVerbose()) { $this->comment('Verbose mode enabled. Use -vv for detailed reasons and matched domains.'); } } private function verboseLine(ModerationContentType $target, int $sourceId, string $message): void { if (! $this->output->isVerbose()) { return; } $this->line(sprintf('[%s #%d] %s', $target->value, $sourceId, $message)); } private function verboseAnalysis(ModerationContentType $target, int $sourceId, mixed $analysis, string $prefix): void { if (! $this->output->isVerbose()) { return; } $message = sprintf( '[%s #%d] %s; score=%d; severity=%s; policy=%s; queue=%s', $target->value, $sourceId, $prefix, $analysis->score, $analysis->severity->value, $analysis->policyName ?? 'default', $analysis->status->value, ); if ($analysis->priorityScore !== null) { $message .= '; priority=' . $analysis->priorityScore; } if ($analysis->reviewBucket !== null) { $message .= '; bucket=' . $analysis->reviewBucket; } if ($analysis->aiLabel !== null) { $message .= '; ai=' . $analysis->aiLabel; if ($analysis->aiConfidence !== null) { $message .= ' (' . $analysis->aiConfidence . '%)'; } } $this->line($message); if ($this->output->isVeryVerbose()) { if ($analysis->matchedDomains !== []) { $this->line(' matched domains: ' . implode(', ', $analysis->matchedDomains)); } if ($analysis->matchedKeywords !== []) { $this->line(' matched keywords: ' . implode(', ', $analysis->matchedKeywords)); } if ($analysis->reasons !== []) { $this->line(' reasons: ' . implode(' | ', $analysis->reasons)); } } } /** * @return array */ private function targets(): array { $raw = trim((string) ($this->option('only') ?? '')); if ($raw === '') { return [ ModerationContentType::ArtworkComment, ModerationContentType::ArtworkDescription, ]; } $selected = collect(explode(',', $raw)) ->map(static fn (string $value): string => trim(strtolower($value))) ->filter() ->values(); $targets = []; if ($selected->contains('comments')) { $targets[] = ModerationContentType::ArtworkComment; } if ($selected->contains('descriptions')) { $targets[] = ModerationContentType::ArtworkDescription; } if ($selected->contains('titles') || $selected->contains('artwork_titles')) { $targets[] = ModerationContentType::ArtworkTitle; } if ($selected->contains('bios') || $selected->contains('user_bios')) { $targets[] = ModerationContentType::UserBio; } if ($selected->contains('profile-links') || $selected->contains('profile_links')) { $targets[] = ModerationContentType::UserProfileLink; } if ($selected->contains('collections') || $selected->contains('collection_titles')) { $targets[] = ModerationContentType::CollectionTitle; $targets[] = ModerationContentType::CollectionDescription; } if ($selected->contains('stories') || $selected->contains('story_titles')) { $targets[] = ModerationContentType::StoryTitle; $targets[] = ModerationContentType::StoryContent; } if ($selected->contains('cards') || $selected->contains('card_titles')) { $targets[] = ModerationContentType::CardTitle; $targets[] = ModerationContentType::CardText; } return $targets === [] ? [ ModerationContentType::ArtworkComment, ModerationContentType::ArtworkDescription, ModerationContentType::ArtworkTitle, ModerationContentType::UserBio, ModerationContentType::UserProfileLink, ModerationContentType::CollectionTitle, ModerationContentType::CollectionDescription, ModerationContentType::StoryTitle, ModerationContentType::StoryContent, ModerationContentType::CardTitle, ModerationContentType::CardText, ] : $targets; } }