Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View File

@@ -0,0 +1,430 @@
<?php
declare(strict_types=1);
namespace App\Services\AiBiography;
use App\Models\ArtworkRelation;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Builds a normalized, public-safe input payload from creator data.
*
* Data sources: user record, user_profiles, creator_milestones, creator_eras,
* artworks (public only), artwork_features, artwork_relations.
*
* Privacy rules:
* Only public, approved, non-deleted artworks are used.
* No private milestones (is_public = false).
* No moderation, staff, or hidden data.
* No personal attributes (age, gender, location, religion, etc.).
*/
final class AiBiographyInputBuilder
{
/**
* Build and return the normalized input array for a creator.
*
* @return array<string, mixed>
*/
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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string>
*/
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<string>
*/
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<array{title: string, starts_at: string, ends_at: string|null}>
*/
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();
}
}