*/ public function build(User $user): array { $userId = (int) $user->id; $memberSinceYear = (int) $user->created_at->format('Y'); $yearsOnSkinbase = (int) now()->format('Y') - $memberSinceYear; $uploadsCount = $this->publicUploadsCount($userId); $featuredCount = $this->featuredCount($userId); $downloadsCount = $this->totalDownloads($userId); $topCategories = $this->topCategories($userId); $topTags = $this->topTags($userId); $bestWork = $this->bestPerformingWork($userId); $mostProductiveYear = $this->mostProductiveYear($userId); $evolutionCount = $this->evolutionCount($userId); $activityStatus = $this->activityStatus($userId); $milestones = $this->publicMilestoneSignals($userId); $eras = $this->publicEras($userId); return [ 'user_id' => $userId, 'username' => (string) $user->username, 'member_since_year' => $memberSinceYear, 'years_on_skinbase' => max(0, $yearsOnSkinbase), 'uploads_count' => $uploadsCount, 'featured_count' => $featuredCount, 'downloads_count' => $downloadsCount, 'top_categories' => $topCategories, 'top_tags' => $topTags, 'best_performing_work' => $bestWork, 'most_productive_year' => $mostProductiveYear, 'evolution_count' => $evolutionCount, 'current_activity_status' => $activityStatus, 'milestones' => $milestones, 'eras' => $eras, ]; } /** * Compute a deterministic SHA-256 hash from the normalized input. * Changing any meaningful field changes the hash, enabling stale detection. * * @param array $input */ public function sourceHash(array $input): string { // Exclude fields that should not affect staleness: // – user_id / username: identity, not profile signal // – downloads_count: noisy micro-increments that change frequently without // meaningfully altering what the biography should say $excluded = ['user_id', 'username', 'downloads_count']; $significant = array_diff_key($input, array_flip($excluded)); return hash('sha256', json_encode($significant, JSON_THROW_ON_ERROR)); } /** * Classify the creator's data richness for prompt and threshold decisions. * * rich – long history, featured work, milestones/eras/evolution * medium – some uploads, limited signal depth * sparse – very little data; may not warrant generation at all * * @param array $input from build() */ public function qualityTier(array $input): string { $uploads = (int) ($input['uploads_count'] ?? 0); $featured = (int) ($input['featured_count'] ?? 0); $years = (int) ($input['years_on_skinbase'] ?? 0); $milestones = (array) ($input['milestones'] ?? []); $eras = (array) ($input['eras'] ?? []); $evolution = (int) ($input['evolution_count'] ?? 0); $hasComeBack = ! empty($milestones['has_comeback']); $hasStreak = (int) ($milestones['best_upload_streak_months'] ?? 0) >= 3; $richSignals = ($featured >= 1 ? 1 : 0) + ($uploads >= 30 ? 1 : 0) + ($hasComeBack || $hasStreak ? 1 : 0) + (count($eras) >= 2 ? 1 : 0) + ($evolution >= 2 ? 1 : 0); if ($uploads >= 20 && $years >= 2 && $richSignals >= 2) { return 'rich'; } if ($uploads >= 5 || $featured >= 1 || ($years >= 1 && $richSignals >= 1)) { return 'medium'; } return 'sparse'; } /** * Check whether the creator has enough public data to warrant biography generation. * * Returns false for brand-new or essentially empty profiles where any * generated output would be generic or misleading. * * @param array $input from build() */ public function meetsMinimumThreshold(array $input): bool { $uploads = (int) ($input['uploads_count'] ?? 0); $featured = (int) ($input['featured_count'] ?? 0); $categories = (array) ($input['top_categories'] ?? []); $milestones = (array) ($input['milestones'] ?? []); $years = (int) ($input['years_on_skinbase'] ?? 0); return $uploads >= 3 || $featured >= 1 || ! empty($milestones['has_comeback']) || (int) ($milestones['best_upload_streak_months'] ?? 0) >= 3 || (count($categories) >= 1 && $uploads >= 1 && $years >= 1); } // ------------------------------------------------------------------------- // Private helpers – public data only // ------------------------------------------------------------------------- private function publicUploadsCount(int $userId): int { return (int) DB::table('artworks') ->where('user_id', $userId) ->where('is_public', true) ->where('is_approved', true) ->whereNotNull('published_at') ->whereNull('deleted_at') ->count(); } private function featuredCount(int $userId): int { if (! Schema::hasTable('artwork_features')) { return 0; } return (int) DB::table('artwork_features') ->join('artworks', 'artworks.id', '=', 'artwork_features.artwork_id') ->where('artworks.user_id', $userId) ->whereNull('artworks.deleted_at') ->count(); } private function totalDownloads(int $userId): int { if (! Schema::hasTable('artwork_stats')) { return 0; } return (int) DB::table('artworks') ->join('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id') ->where('artworks.user_id', $userId) ->where('artworks.is_public', true) ->where('artworks.is_approved', true) ->whereNotNull('artworks.published_at') ->whereNull('artworks.deleted_at') ->sum('artwork_stats.downloads'); } /** * @return list */ private function topCategories(int $userId): array { if (! Schema::hasTable('artwork_category') || ! Schema::hasTable('categories')) { return []; } return DB::table('artwork_category') ->join('artworks', 'artworks.id', '=', 'artwork_category.artwork_id') ->join('categories', 'categories.id', '=', 'artwork_category.category_id') ->where('artworks.user_id', $userId) ->where('artworks.is_public', true) ->where('artworks.is_approved', true) ->whereNotNull('artworks.published_at') ->whereNull('artworks.deleted_at') ->groupBy('categories.id', 'categories.name') ->orderByRaw('COUNT(*) DESC') ->orderBy('categories.name') ->limit(3) ->pluck('categories.name') ->map(fn ($n) => (string) $n) ->values() ->all(); } /** * @return list */ private function topTags(int $userId): array { if (! Schema::hasTable('artwork_tag') || ! Schema::hasTable('tags')) { return []; } return DB::table('artwork_tag') ->join('artworks', 'artworks.id', '=', 'artwork_tag.artwork_id') ->join('tags', 'tags.id', '=', 'artwork_tag.tag_id') ->where('artworks.user_id', $userId) ->where('artworks.is_public', true) ->where('artworks.is_approved', true) ->whereNotNull('artworks.published_at') ->whereNull('artworks.deleted_at') ->groupBy('tags.id', 'tags.name') ->orderByRaw('COUNT(*) DESC') ->orderBy('tags.name') ->limit(5) ->pluck('tags.name') ->map(fn ($n) => (string) $n) ->values() ->all(); } /** * @return array{title: string, year: int}|null */ private function bestPerformingWork(int $userId): ?array { $query = DB::table('artworks') ->where('artworks.user_id', $userId) ->where('artworks.is_public', true) ->where('artworks.is_approved', true) ->whereNotNull('artworks.published_at') ->whereNull('artworks.deleted_at') ->limit(1) ->select('artworks.title', 'artworks.published_at'); if (Schema::hasTable('artwork_stats')) { $query ->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id') ->orderByRaw('(COALESCE(artwork_stats.downloads, 0) + COALESCE(artwork_stats.views, 0) + COALESCE(artwork_stats.favorites, 0)) DESC'); } else { $query->orderByDesc('artworks.published_at'); } $row = $query->first(); if ($row === null) { return null; } return [ 'title' => (string) $row->title, 'year' => (int) date('Y', strtotime((string) $row->published_at)), ]; } private function mostProductiveYear(int $userId): ?int { // Use strftime for SQLite compatibility; MySQL also supports strftime via // a compatibility shim, but we use a driver-agnostic expression here. $driver = DB::getDriverName(); $yearExpr = $driver === 'sqlite' ? "strftime('%Y', published_at)" : 'YEAR(published_at)'; $row = DB::table('artworks') ->where('user_id', $userId) ->where('is_public', true) ->where('is_approved', true) ->whereNotNull('published_at') ->whereNull('deleted_at') ->selectRaw("{$yearExpr} as yr, COUNT(*) as cnt") ->groupByRaw($yearExpr) ->orderByRaw('COUNT(*) DESC') ->limit(1) ->first(); return $row !== null ? (int) $row->yr : null; } private function evolutionCount(int $userId): int { if (! Schema::hasTable('artwork_relations')) { return 0; } $evolutionTypes = [ ArtworkRelation::TYPE_REMASTER_OF, ArtworkRelation::TYPE_REMAKE_OF, ArtworkRelation::TYPE_REVISION_OF, ]; return (int) DB::table('artwork_relations') ->join('artworks as src', 'src.id', '=', 'artwork_relations.source_artwork_id') ->where('src.user_id', $userId) ->whereIn('artwork_relations.relation_type', $evolutionTypes) ->whereNull('src.deleted_at') ->count(); } private function activityStatus(int $userId): string { $latestPublished = DB::table('artworks') ->where('user_id', $userId) ->where('is_public', true) ->where('is_approved', true) ->whereNotNull('published_at') ->whereNull('deleted_at') ->max('published_at'); if ($latestPublished === null) { return 'inactive'; } $daysSinceLast = now()->diffInDays(date('Y-m-d', strtotime((string) $latestPublished))); if ($daysSinceLast <= 60) { return 'active'; } if ($daysSinceLast <= 365) { return 'recently_active'; } // Check for comeback: a gap > 180 days before the latest upload. $previousPublished = DB::table('artworks') ->where('user_id', $userId) ->where('is_public', true) ->where('is_approved', true) ->whereNotNull('published_at') ->whereNull('deleted_at') ->where('published_at', '<', $latestPublished) ->max('published_at'); if ($previousPublished !== null) { $gapDays = (int) (strtotime((string) $latestPublished) - strtotime((string) $previousPublished)) / 86400; if ($gapDays >= 180) { return 'returning'; } } return 'legacy'; } /** * @return array{has_comeback: bool, best_upload_streak_months: int} */ private function publicMilestoneSignals(int $userId): array { if (! Schema::hasTable('creator_milestones')) { return ['has_comeback' => false, 'best_upload_streak_months' => 0]; } $types = DB::table('creator_milestones') ->where('user_id', $userId) ->where('is_public', true) ->pluck('type') ->all(); $hasComeback = in_array('comeback_detected', $types, true); $streakRow = DB::table('creator_milestones') ->where('user_id', $userId) ->where('is_public', true) ->whereIn('type', ['upload_streak_3', 'upload_streak_6', 'upload_streak_9', 'upload_streak_12']) ->orderByRaw('priority DESC') ->limit(1) ->first(); $bestStreakMonths = 0; if ($streakRow !== null) { $streakMap = [ 'upload_streak_3' => 3, 'upload_streak_6' => 6, 'upload_streak_9' => 9, 'upload_streak_12' => 12, ]; $bestStreakMonths = $streakMap[$streakRow->type] ?? 0; } return [ 'has_comeback' => $hasComeback, 'best_upload_streak_months' => $bestStreakMonths, ]; } /** * @return list */ private function publicEras(int $userId): array { if (! Schema::hasTable('creator_eras')) { return []; } return DB::table('creator_eras') ->where('user_id', $userId) ->orderBy('starts_at') ->get(['title', 'starts_at', 'ends_at']) ->map(fn ($row): array => [ 'title' => (string) $row->title, 'starts_at' => (string) $row->starts_at, 'ends_at' => $row->ends_at !== null ? (string) $row->ends_at : null, ]) ->values() ->all(); } }