367 lines
14 KiB
PHP
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;
|
|
}
|
|
} |