Save workspace changes
This commit is contained in:
430
app/Services/AiBiography/AiBiographyInputBuilder.php
Normal file
430
app/Services/AiBiography/AiBiographyInputBuilder.php
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user