431 lines
15 KiB
PHP
431 lines
15 KiB
PHP
<?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();
|
||
}
|
||
}
|