Files
SkinbaseNova/app/Http/Controllers/Settings/AiBiographyAdminController.php
2026-04-18 17:02:56 +02:00

367 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Models\CreatorAiBiography;
use App\Models\User;
use App\Services\AiBiography\AiBiographyService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
final class AiBiographyAdminController extends Controller
{
private const PER_PAGE = 20;
public function __construct(private readonly AiBiographyService $biographies)
{
}
public function index(Request $request): Response
{
$filters = $this->filters($request);
$records = $this->recordsQuery($filters)
->paginate(self::PER_PAGE)
->withQueryString()
->through(fn (CreatorAiBiography $record): array => $this->mapRecord($record));
return Inertia::render('Moderation/AiBiographyAdmin', [
'title' => 'AI Biography Review',
'records' => $records,
'filters' => $filters,
'stats' => $this->stats(),
'filterOptions' => [
'status' => [
['value' => 'all', 'label' => 'All statuses'],
['value' => CreatorAiBiography::STATUS_GENERATED, 'label' => 'Generated'],
['value' => CreatorAiBiography::STATUS_APPROVED, 'label' => 'Approved'],
['value' => CreatorAiBiography::STATUS_EDITED, 'label' => 'Edited'],
['value' => CreatorAiBiography::STATUS_NEEDS_REVIEW, 'label' => 'Needs review'],
['value' => CreatorAiBiography::STATUS_FAILED, 'label' => 'Failed'],
['value' => CreatorAiBiography::STATUS_SUPPRESSED, 'label' => 'Suppressed'],
],
'scope' => [
['value' => 'all', 'label' => 'All records'],
['value' => 'active', 'label' => 'Active only'],
['value' => 'inactive', 'label' => 'Inactive only'],
],
'tier' => [
['value' => 'all', 'label' => 'All tiers'],
['value' => CreatorAiBiography::TIER_RICH, 'label' => 'Rich'],
['value' => CreatorAiBiography::TIER_MEDIUM, 'label' => 'Medium'],
['value' => CreatorAiBiography::TIER_SPARSE, 'label' => 'Sparse'],
],
'visibility' => [
['value' => 'all', 'label' => 'All visibility'],
['value' => 'visible', 'label' => 'Visible'],
['value' => 'hidden', 'label' => 'Hidden'],
],
'review' => [
['value' => 'all', 'label' => 'All review states'],
['value' => 'needs_review', 'label' => 'Needs review'],
['value' => 'failed', 'label' => 'Failed / errored'],
['value' => 'user_edited', 'label' => 'User edited'],
],
],
'endpoints' => [
'index' => route('cp.ai-biography.index'),
'rebuildPattern' => route('cp.ai-biography.rebuild', ['user' => '__USER__']),
'approvePattern' => route('cp.ai-biography.approve', ['biography' => '__BIOGRAPHY__']),
'flagPattern' => route('cp.ai-biography.flag', ['biography' => '__BIOGRAPHY__']),
'hidePattern' => route('cp.ai-biography.hide', ['biography' => '__BIOGRAPHY__']),
'showPattern' => route('cp.ai-biography.show', ['biography' => '__BIOGRAPHY__']),
],
])->rootView('moderation');
}
public function rebuild(User $user): JsonResponse
{
if (! (bool) config('ai_biography.enabled', true)) {
return response()->json([
'success' => false,
'message' => 'AI Biography generation is currently disabled.',
], 409);
}
$active = $this->activeRecordForUser($user);
if ($active !== null && $active->is_user_edited) {
$result = $this->biographies->generate($user, CreatorAiBiography::REASON_ADMIN_BATCH);
} elseif ($active !== null) {
$result = $this->biographies->regenerate($user, true, CreatorAiBiography::REASON_ADMIN_BATCH);
} else {
$result = $this->biographies->generate($user, CreatorAiBiography::REASON_ADMIN_BATCH);
}
return response()->json([
'success' => (bool) $result['success'],
'message' => $this->rebuildMessage($result),
'result' => $result,
], $result['success'] ? 200 : 422);
}
public function approve(CreatorAiBiography $biography): JsonResponse
{
$biography->update([
'needs_review' => false,
'status' => $biography->is_user_edited
? CreatorAiBiography::STATUS_EDITED
: CreatorAiBiography::STATUS_APPROVED,
]);
return response()->json([
'success' => true,
'message' => 'Biography marked as reviewed.',
]);
}
public function flag(CreatorAiBiography $biography): JsonResponse
{
$biography->update([
'needs_review' => true,
'status' => $biography->is_user_edited
? CreatorAiBiography::STATUS_EDITED
: CreatorAiBiography::STATUS_NEEDS_REVIEW,
]);
return response()->json([
'success' => true,
'message' => 'Biography flagged for review.',
]);
}
public function hide(CreatorAiBiography $biography): JsonResponse
{
if (! $biography->is_active) {
return response()->json([
'success' => false,
'message' => 'Only active biographies can be hidden.',
], 422);
}
$this->biographies->hide($biography->user);
return response()->json([
'success' => true,
'message' => 'Biography hidden from public view.',
]);
}
public function show(CreatorAiBiography $biography): JsonResponse
{
if (! $biography->is_active) {
return response()->json([
'success' => false,
'message' => 'Only active biographies can be made visible.',
], 422);
}
$this->biographies->show($biography->user);
return response()->json([
'success' => true,
'message' => 'Biography is public again.',
]);
}
/**
* @return array<string, string>
*/
private function filters(Request $request): array
{
return [
'q' => trim((string) $request->query('q', '')),
'status' => $this->enumFilter(
(string) $request->query('status', 'all'),
[
'all',
CreatorAiBiography::STATUS_GENERATED,
CreatorAiBiography::STATUS_APPROVED,
CreatorAiBiography::STATUS_EDITED,
CreatorAiBiography::STATUS_NEEDS_REVIEW,
CreatorAiBiography::STATUS_FAILED,
CreatorAiBiography::STATUS_SUPPRESSED,
],
'all',
),
'scope' => $this->enumFilter((string) $request->query('scope', 'all'), ['all', 'active', 'inactive'], 'all'),
'tier' => $this->enumFilter(
(string) $request->query('tier', 'all'),
['all', CreatorAiBiography::TIER_RICH, CreatorAiBiography::TIER_MEDIUM, CreatorAiBiography::TIER_SPARSE],
'all',
),
'visibility' => $this->enumFilter((string) $request->query('visibility', 'all'), ['all', 'visible', 'hidden'], 'all'),
'review' => $this->enumFilter((string) $request->query('review', 'all'), ['all', 'needs_review', 'failed', 'user_edited'], 'all'),
];
}
/**
* @param array<string, string> $filters
*/
private function recordsQuery(array $filters): Builder
{
$query = CreatorAiBiography::query()
->with('user:id,username,name,email,created_at')
->latest('created_at')
->latest('id');
if ($filters['q'] !== '') {
$search = '%' . Str::lower($filters['q']) . '%';
$query->whereHas('user', function (Builder $userQuery) use ($search): void {
$userQuery->where(function (Builder $matchQuery) use ($search): void {
$matchQuery->whereRaw('LOWER(username) LIKE ?', [$search])
->orWhereRaw('LOWER(COALESCE(name, "")) LIKE ?', [$search])
->orWhereRaw('LOWER(COALESCE(email, "")) LIKE ?', [$search]);
});
});
}
if ($filters['status'] !== 'all') {
$query->where('status', $filters['status']);
}
if ($filters['scope'] === 'active') {
$query->where('is_active', true);
} elseif ($filters['scope'] === 'inactive') {
$query->where('is_active', false);
}
if ($filters['tier'] !== 'all') {
$query->where('input_quality_tier', $filters['tier']);
}
if ($filters['visibility'] === 'visible') {
$query->where('is_hidden', false);
} elseif ($filters['visibility'] === 'hidden') {
$query->where('is_hidden', true);
}
if ($filters['review'] === 'needs_review') {
$query->where('needs_review', true);
} elseif ($filters['review'] === 'failed') {
$query->where(function (Builder $failedQuery): void {
$failedQuery->where('status', CreatorAiBiography::STATUS_FAILED)
->orWhereNotNull('last_error_code');
});
} elseif ($filters['review'] === 'user_edited') {
$query->where('is_user_edited', true);
}
return $query;
}
/**
* @return array<string, int>
*/
private function stats(): array
{
if (! Schema::hasTable('creator_ai_biographies')) {
return [
'total_records' => 0,
'active_records' => 0,
'needs_review' => 0,
'hidden_active' => 0,
'failed' => 0,
'user_edited_active' => 0,
];
}
return [
'total_records' => (int) CreatorAiBiography::query()->count(),
'active_records' => (int) CreatorAiBiography::query()->where('is_active', true)->count(),
'needs_review' => (int) CreatorAiBiography::query()->where('needs_review', true)->count(),
'hidden_active' => (int) CreatorAiBiography::query()->where('is_active', true)->where('is_hidden', true)->count(),
'failed' => (int) CreatorAiBiography::query()->where(function (Builder $query): void {
$query->where('status', CreatorAiBiography::STATUS_FAILED)
->orWhereNotNull('last_error_code');
})->count(),
'user_edited_active' => (int) CreatorAiBiography::query()->where('is_active', true)->where('is_user_edited', true)->count(),
];
}
/**
* @return array<string, mixed>
*/
private function mapRecord(CreatorAiBiography $record): array
{
$user = $record->user;
$username = (string) ($user?->username ?? '');
$isStale = $record->is_active && $user !== null ? $this->biographies->isStale($user) : false;
return [
'id' => (int) $record->id,
'user_id' => (int) $record->user_id,
'user' => [
'id' => $user?->id,
'username' => $username,
'display_name' => $user?->name ?: ($username !== '' ? '@' . $username : 'Unknown creator'),
'email' => $user?->email,
'profile_url' => $username !== '' ? route('profile.show', ['username' => Str::lower($username)]) : null,
'gallery_url' => $username !== '' ? route('profile.gallery', ['username' => Str::lower($username)]) : null,
],
'text' => $record->text,
'excerpt' => Str::limit((string) $record->text, 220),
'status' => (string) $record->status,
'is_active' => (bool) $record->is_active,
'is_hidden' => (bool) $record->is_hidden,
'is_user_edited' => (bool) $record->is_user_edited,
'needs_review' => (bool) $record->needs_review,
'is_stale' => $isStale,
'source_hash' => $record->source_hash,
'model' => $record->model,
'prompt_version' => $record->prompt_version,
'input_quality_tier' => $record->input_quality_tier,
'generation_reason' => $record->generation_reason,
'generated_at' => $record->generated_at?->toIso8601String(),
'approved_at' => $record->approved_at?->toIso8601String(),
'last_attempted_at' => $record->last_attempted_at?->toIso8601String(),
'last_error_code' => $record->last_error_code,
'last_error_reason' => $record->last_error_reason,
'created_at' => $record->created_at?->toIso8601String(),
'updated_at' => $record->updated_at?->toIso8601String(),
];
}
private function activeRecordForUser(User $user): ?CreatorAiBiography
{
return CreatorAiBiography::query()
->where('user_id', (int) $user->id)
->where('is_active', true)
->latest('id')
->first();
}
/**
* @param array{success: bool, action: string, errors: list<string>} $result
*/
private function rebuildMessage(array $result): string
{
if ($result['success']) {
return match ($result['action']) {
'draft_stored' => 'New AI draft stored while preserving the active user-edited biography.',
default => 'Biography rebuild completed.',
};
}
return $result['errors'][0] ?? 'Biography rebuild failed.';
}
/**
* @param list<string> $allowed
*/
private function enumFilter(string $value, array $allowed, string $fallback): string
{
$normalized = trim(Str::lower($value));
return in_array($normalized, $allowed, true) ? $normalized : $fallback;
}
}