Files
SkinbaseNova/app/Services/AiBiography/AiBiographyInputBuilder.php
2026-04-18 17:02:56 +02:00

431 lines
15 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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();
}
}